new-site/api/src/routes/identity.ts
justin a7d7fee154 Fix 6 bugs found in compliance and checkout flows
1. CRITICAL: Add compliance_batch to stripe session tableMap —
   session IDs weren't being stored for batch orders
2. CRITICAL: Fix batch orders using order_number instead of batch_id
   when storing stripe_session_id
3. MAJOR: Tax deductibility note only shows for compliance orders,
   not CRTC/formation/bundles
4. MAJOR: Identity verification fallback changed from localhost:4321
   to performancewest.net with warning log
5. MEDIUM: Fix discount rounding — last service absorbs remainder
   to prevent cent loss across batch orders
6. LOW: Validate at least one paid service in batch orders
7. Standardize support email to info@performancewest.net everywhere

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:56:12 -05:00

564 lines
22 KiB
TypeScript

/**
* 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<string, unknown>).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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
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<void> {
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;