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>
564 lines
22 KiB
TypeScript
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;
|