/** * Stripe Identity Routes * * Provides director KYC via Stripe Identity for ALL order types and payment methods. * Identity verification is a prerequisite to order creation and payment — no exceptions. * * Flow: * 1. Customer enters director name + DOB on the order form (step 2) * 2. Step 4: "Verify Identity" button → POST /api/v1/identity/create-session * Returns { session_id, client_secret, url } — client redirects to Stripe-hosted flow * 3. Customer completes ID capture on Stripe's page * 4. Stripe webhook fires identity.verification_session.verified (or .requires_input) * → We extract name + DOB from the report, compare to form values * → Store result in identity_verifications table * 5. Order page polls GET /api/v1/identity/session/:id for result * 6. On 'verified': customer proceeds to step 5 (review + payment method) * On 'needs_review': order is submitted but held for admin — payment collected, * but pipeline doesn't start until admin clears * On 'failed': customer is blocked; shown error * * Name matching tiers (against extracted ID name): * exact score 100 → verified (name_match: exact) * fuzzy_pass score 85-99 → verified (acceptable — nickname/middle name variations) * fuzzy_warn score 70-84 → needs_review (possible typo or legal name difference) * mismatch score < 70 → failed (clearly different person) * * DOB matching: * exact → good * no DOB on ID → needs_review (some passports omit DOB field) * mismatch → needs_review (not a hard block — DOB typos happen, but flag it) */ import express, { Router, raw } from "express"; import Stripe from "stripe"; import { pool } from "../db.js"; import { submitLimiter } from "../middleware/rate-limit.js"; import { callMethod } from "../erpnext-client.js"; const router = Router(); const STRIPE_SECRET_KEY = (process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_SECRET_KEY?.trim()) || process.env.STRIPE_SECRET_KEY || ""; const STRIPE_IDENTITY_WEBHOOK_SECRET = (process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_IDENTITY_WEBHOOK_SECRET?.trim()) || process.env.STRIPE_IDENTITY_WEBHOOK_SECRET || ""; const DOMAIN = process.env.DOMAIN ? `https://${process.env.DOMAIN}` : (() => { console.error("[identity] WARNING: DOMAIN env var not set — identity verification return URLs will fail"); return "https://performancewest.net"; })(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const stripe = STRIPE_SECRET_KEY ? new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2026-03-25.dahlia" as any }) : null; // ─── Name comparison ───────────────────────────────────────────────────────── function normalize(s: string): string { return s .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9\s]/g, " ") .replace(/\s+/g, " ") .trim(); } function levenshtein(a: string, b: string): number { if (a === b) return 0; if (!a.length) return b.length; if (!b.length) return a.length; const m = a.length, n = b.length; const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), ); for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j-1], dp[i][j-1], dp[i-1][j]); return dp[m][n]; } function nameSimilarity(a: string, b: string): number { if (!a && !b) return 100; if (!a || !b) return 0; const dist = levenshtein(a, b); return Math.round((1 - dist / Math.max(a.length, b.length)) * 100); } type NameMatchResult = "exact" | "fuzzy_pass" | "fuzzy_warn" | "mismatch"; function compareNames(formName: string, idFirst: string, idLast: string): { score: number; result: NameMatchResult } { const formNorm = normalize(formName); const idFull1 = normalize(`${idFirst} ${idLast}`); const idFull2 = normalize(`${idLast} ${idFirst}`); const idFull3 = normalize(`${idLast}, ${idFirst}`); const score = Math.max( nameSimilarity(formNorm, idFull1), nameSimilarity(formNorm, idFull2), nameSimilarity(formNorm, idFull3), ); let result: NameMatchResult; if (score === 100) result = "exact"; else if (score >= 85) result = "fuzzy_pass"; else if (score >= 70) result = "fuzzy_warn"; else result = "mismatch"; return { score, result }; } type DobMatchResult = "exact" | "no_dob_on_id" | "mismatch"; function compareDob( formDob: string | null | undefined, idYear: number | null, idMonth: number | null, idDay: number | null, ): DobMatchResult { if (!idYear && !idMonth && !idDay) return "no_dob_on_id"; if (!formDob) return "no_dob_on_id"; const d = new Date(formDob); if (isNaN(d.getTime())) return "no_dob_on_id"; if ( d.getFullYear() === idYear && (d.getMonth() + 1) === idMonth && d.getDate() === idDay ) return "exact"; return "mismatch"; } function deriveOverallResult( nameMatch: NameMatchResult, dobMatch: DobMatchResult, docExpired: boolean, ): "verified" | "needs_review" | "failed" { if (nameMatch === "mismatch") return "failed"; if (docExpired) return "needs_review"; if (nameMatch === "fuzzy_warn") return "needs_review"; if (dobMatch === "mismatch") return "needs_review"; return "verified"; } // ─── POST /api/v1/identity/create-session ──────────────────────────────────── /** * Create a Stripe Identity VerificationSession for the director. * Called when the customer clicks "Verify Identity" on step 4. * * Body: { director_name, director_dob?, customer_email, order_type } * Returns: { session_id, client_secret, url } */ router.post("/api/v1/identity/create-session", express.json({ limit: "100kb" }), submitLimiter, async (req, res) => { if (!stripe) { res.status(503).json({ error: "Identity verification not configured (STRIPE_SECRET_KEY missing)" }); return; } const { director_name, director_dob, customer_email, order_type = "canada_crtc", order_name } = req.body ?? {}; if (!director_name || typeof director_name !== "string" || director_name.trim().length < 2) { res.status(400).json({ error: "director_name is required" }); return; } try { // Create Stripe Identity VerificationSession const session = await stripe.identity.verificationSessions.create({ type: "document", metadata: { director_name: director_name.trim(), director_dob: director_dob ?? "", customer_email: customer_email ?? "", order_type, order_name: order_name ?? "", }, options: { document: { // Accept passports, driver licenses, and national ID cards globally allowed_types: ["driving_license", "passport", "id_card"], require_id_number: false, require_live_capture: true, require_matching_selfie: false, // selfie optional — too much friction for our use case }, }, return_url: `${DOMAIN}/order/identity-complete`, }); // Store pending record in DB await pool.query( `INSERT INTO identity_verifications (stripe_session_id, stripe_status, form_director_name, form_director_dob, customer_email, order_type, ip_address, user_agent, name_match, dob_match, overall_result) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', 'pending', 'pending') ON CONFLICT (stripe_session_id) DO NOTHING`, [ session.id, session.status, director_name.trim(), director_dob ?? null, customer_email ?? null, order_type, (req as unknown as Record).clientIp ?? req.ip, req.headers["user-agent"] ?? null, ], ); res.json({ session_id: session.id, client_secret: session.client_secret, url: session.url, status: session.status, }); } catch (err) { console.error("[identity] create-session error:", err); res.status(500).json({ error: "Could not create identity verification session" }); } }); // ─── GET /api/v1/identity/session/:id ──────────────────────────────────────── /** * Poll the status of an identity verification session. * The order form polls this every 3s after the customer returns from Stripe. */ router.get("/api/v1/identity/session/:id", async (req, res) => { const { id } = req.params; try { const { rows } = await pool.query( `SELECT stripe_session_id, stripe_status, overall_result, name_match, name_match_score, dob_match, doc_expired, id_doc_type, id_issuing_country, verified_at FROM identity_verifications WHERE stripe_session_id = $1`, [id], ); if (!rows.length) { // Not in DB yet — webhook may not have arrived. Check Stripe directly and // process inline so the client isn't stuck polling forever. if (!stripe) { res.status(404).json({ error: "Session not found" }); return; } const session = await stripe.identity.verificationSessions.retrieve(id, { expand: ["last_verification_report"], }); // If Stripe already has a terminal status, process it now without waiting for webhook if (session.status === "verified" || session.status === "requires_input" || session.status === "canceled") { try { await handleVerificationComplete({ type: "identity.verification_session." + (session.status === "verified" ? "verified" : "requires_input"), data: { object: session } } as unknown as Stripe.Event); } catch (processErr) { console.warn("[identity] inline process failed, returning status only:", processErr); res.json({ session_id: id, status: session.status, overall_result: session.status === "verified" ? "verified" : "failed" }); return; } // Now fetch from DB — should be populated const { rows: newRows } = await pool.query( `SELECT stripe_session_id, stripe_status, overall_result, name_match, name_match_score, dob_match, doc_expired, id_doc_type, id_issuing_country, verified_at FROM identity_verifications WHERE stripe_session_id = $1`, [id], ); if (newRows.length) { const row = newRows[0] as Record; res.json({ session_id: row.stripe_session_id, status: row.stripe_status, overall_result: row.overall_result, name_match: row.name_match, name_match_score: row.name_match_score, dob_match: row.dob_match, doc_expired: row.doc_expired, doc_type: row.id_doc_type, issuing_country: row.id_issuing_country, verified_at: row.verified_at, }); return; } } // Still processing on Stripe's side res.json({ session_id: id, status: session.status, overall_result: "pending" }); return; } const row = rows[0] as Record; // If DB still shows pending but enough time has passed, re-check Stripe and // process inline — handles the case where the webhook is delayed or missing. if (row.overall_result === "pending" && stripe) { try { const session = await stripe.identity.verificationSessions.retrieve(id, { expand: ["last_verification_report"], }); if (session.status === "verified" || session.status === "requires_input" || session.status === "canceled") { await handleVerificationComplete({ type: "identity.verification_session." + (session.status === "verified" ? "verified" : "requires_input"), data: { object: session } } as unknown as Stripe.Event); // Re-fetch updated row const { rows: updated } = await pool.query( `SELECT stripe_session_id, stripe_status, overall_result, name_match, name_match_score, dob_match, doc_expired, id_doc_type, id_issuing_country, verified_at FROM identity_verifications WHERE stripe_session_id = $1`, [id], ); if (updated.length) { const r2 = updated[0] as Record; res.json({ session_id: r2.stripe_session_id, status: r2.stripe_status, overall_result: r2.overall_result, name_match: r2.name_match, name_match_score: r2.name_match_score, dob_match: r2.dob_match, doc_expired: r2.doc_expired, doc_type: r2.id_doc_type, issuing_country: r2.id_issuing_country, verified_at: r2.verified_at, }); return; } } } catch (retryErr) { console.warn("[identity] inline reprocess failed:", retryErr); } } res.json({ session_id: row.stripe_session_id, status: row.stripe_status, overall_result: row.overall_result, name_match: row.name_match, name_match_score: row.name_match_score, dob_match: row.dob_match, doc_expired: row.doc_expired, doc_type: row.id_doc_type, issuing_country: row.id_issuing_country, verified_at: row.verified_at, }); } catch (err) { console.error("[identity] session status error:", err); res.status(500).json({ error: "Could not retrieve session status" }); } }); // ─── POST /api/v1/webhooks/stripe-identity ──────────────────────────────────── /** * Stripe Identity webhook handler. * Separate secret from the payment webhook — register a separate endpoint * in Stripe Dashboard for identity events: * identity.verification_session.verified * identity.verification_session.requires_input * identity.verification_session.canceled * * Must be mounted BEFORE express.json() middleware (needs raw Buffer). */ router.post( "/api/v1/webhooks/stripe-identity", raw({ type: "application/json" }), async (req, res) => { if (!stripe) { res.status(503).json({ error: "Stripe not configured" }); return; } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( req.body as Buffer, req.headers["stripe-signature"] ?? "", STRIPE_IDENTITY_WEBHOOK_SECRET, ); } catch (err) { console.error("[identity-webhook] Signature verification failed:", err); res.status(400).json({ error: "Invalid signature" }); return; } try { switch (event.type) { case "identity.verification_session.verified": case "identity.verification_session.requires_input": { await handleVerificationComplete(event); break; } case "identity.verification_session.canceled": { const session = event.data.object as Stripe.Identity.VerificationSession; await pool.query( `UPDATE identity_verifications SET stripe_status = 'canceled', overall_result = 'failed' WHERE stripe_session_id = $1`, [session.id], ); break; } default: break; } res.json({ received: true }); } catch (err) { console.error("[identity-webhook] Handler error:", err); res.json({ received: true, error: "handler error" }); } }, ); // ─── Webhook handler: process completed verification ───────────────────────── async function handleVerificationComplete(event: Stripe.Event): Promise { const session = event.data.object as Stripe.Identity.VerificationSession; const sessionId = session.id; // Fetch the verification report for extracted document data let report: Stripe.Identity.VerificationReport | null = null; if (stripe && session.last_verification_report) { try { const reportId = typeof session.last_verification_report === "string" ? session.last_verification_report : session.last_verification_report.id; report = await stripe.identity.verificationReports.retrieve(reportId); } catch (err) { console.error("[identity-webhook] Could not fetch verification report:", err); } } // Pull form values from metadata const meta = session.metadata ?? {}; const formName = meta.director_name ?? ""; const formDob = meta.director_dob ?? null; // Extract document fields from report const doc = report?.document; const idFirstName = doc?.first_name ?? null; const idLastName = doc?.last_name ?? null; const idDobYear = (doc?.dob as { year?: number } | null)?.year ?? null; const idDobMonth = (doc?.dob as { month?: number } | null)?.month ?? null; const idDobDay = (doc?.dob as { day?: number } | null)?.day ?? null; const idDocType = doc?.type ?? null; const idCountry = doc?.issuing_country ?? null; const idDocNum = doc?.number ?? null; // stored then redacted // Expiry check const expiryYear = (doc?.expiration_date as { year?: number } | null)?.year ?? null; const expiryMonth = (doc?.expiration_date as { month?: number } | null)?.month ?? null; const expiryDay = (doc?.expiration_date as { day?: number } | null)?.day ?? null; let docExpired = false; if (expiryYear && expiryMonth && expiryDay) { const expiry = new Date(expiryYear, expiryMonth - 1, expiryDay); docExpired = expiry < new Date(); } // Compare names + DOB let nameMatch: NameMatchResult = "mismatch"; let nameScore = 0; let dobMatch: DobMatchResult | "pending" = "pending"; if (idFirstName || idLastName) { const nm = compareNames(formName, idFirstName ?? "", idLastName ?? ""); nameMatch = nm.result; nameScore = nm.score; dobMatch = compareDob(formDob, idDobYear, idDobMonth, idDobDay); } const stripeStatus = session.status; // In test mode (sk_test_ key), Stripe always returns "Jenny Rosen" as the test // identity — skip name/DOB comparison and auto-pass if Stripe verified the doc. const isTestMode = STRIPE_SECRET_KEY.startsWith("sk_test_"); if (isTestMode && stripeStatus === "verified") { nameMatch = "exact"; nameScore = 100; dobMatch = "exact"; docExpired = false; console.log("[identity-webhook] Test mode: bypassing name/DOB match (Stripe test identity is always Jenny Rosen)"); } // If Stripe says 'requires_input', the document check failed — treat as failed const effectiveName = stripeStatus === "verified" ? nameMatch : "mismatch" as NameMatchResult; const overallResult = stripeStatus === "verified" ? deriveOverallResult(effectiveName, dobMatch as DobMatchResult, docExpired) : "failed"; const idFullName = [idFirstName, idLastName].filter(Boolean).join(" ") || null; await pool.query( `UPDATE identity_verifications SET stripe_status = $1, stripe_report_id = $2, id_first_name = $3, id_last_name = $4, id_full_name_extracted = $5, id_dob_year = $6, id_dob_month = $7, id_dob_day = $8, id_doc_type = $9, id_issuing_country = $10, id_expiry_year = $11, id_expiry_month = $12, id_expiry_day = $13, doc_expired = $14, name_match_score = $15, name_match = $16, dob_match = $17, overall_result = $18, verified_at = CASE WHEN $18 IN ('verified','needs_review') THEN NOW() ELSE NULL END WHERE stripe_session_id = $19`, [ stripeStatus, report?.id ?? null, idFirstName, idLastName, idFullName, idDobYear, idDobMonth, idDobDay, idDocType, idCountry, expiryYear, expiryMonth, expiryDay, docExpired, nameScore, effectiveName, (dobMatch === "pending" ? "no_dob_on_id" : dobMatch) as DobMatchResult, overallResult, sessionId, ], ); console.log( `[identity-webhook] Session ${sessionId}: stripe=${stripeStatus}, name=${effectiveName}(${nameScore}%), dob=${dobMatch}, expired=${docExpired} → ${overallResult}`, ); // Sync identity status to ERPNext Sales Order (best-effort) const orderName = meta.order_name || null; if (orderName) { try { const erpnextStatus = overallResult === "verified" ? "Verified" : overallResult === "needs_review" ? "Needs Review" : overallResult === "failed" ? "Failed" : "Pending"; await callMethod( "performancewest_erpnext.api.update_identity_status", { order_name: orderName, status: erpnextStatus, session_id: sessionId, }, ); console.log(`[identity] Synced identity status to ERPNext: ${orderName} → ${erpnextStatus}`); } catch (err) { // Log but don't fail — PG is source of truth for identity, ERPNext sync is best-effort console.error("[identity] Failed to sync identity status to ERPNext:", err); } } // If needs_review, alert admin if (overallResult === "needs_review") { console.warn( `[identity-webhook] NEEDS REVIEW: session ${sessionId} — name score ${nameScore}%, dob=${dobMatch}, expired=${docExpired}. Form name: "${formName}", ID name: "${idFullName}"`, ); // TODO: send admin alert email / ERPNext notification } } export default router;