Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
562
api/src/routes/identity.ts
Normal file
562
api/src/routes/identity.ts
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
/**
|
||||
* 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}` : "http://localhost:4321";
|
||||
|
||||
// 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue