fix: worker emails (localhost:25 -> SMTP relay) + create ERPNext SO on webhook payment

Two bugs found tracing Mitchell Allen's batch CB-95BA6C90 (5 DOT services, card):

1) Worker authorization/signing-link/status emails were sent via
   smtplib.SMTP('localhost', 25), which has no MTA in the workers container ->
   every send failed '[Errno 111] Connection refused', so customers never got
   their e-sign links and orders sat 'awaiting client signature' forever. Routed
   all 9 hardcoded localhost:25 sites (state_trucking, mcs150_update, boc3_filing,
   hazmat_phmsa, mailbox_setup, dot_esign, completion_emails) through the
   authenticated SMTP relay (SMTP_HOST/PORT/STARTTLS/login) + added a shared
   worker_email.send_worker_email helper.

2) The ERPNext Sales Order for compliance/compliance_batch was only created in
   the /checkout/create-session endpoint, but CARD orders confirm via the Stripe
   WEBHOOK -> handlePaymentComplete, which never created the SO. Result: every
   webhook-confirmed order had erpnext_sales_order=NULL and workers logged
   'Sales Order not found 404' then built from PG. Added idempotent
   ensureComplianceSalesOrder() to handlePaymentComplete so ALL payment methods
   (card-webhook, PayPal, crypto) create + link the SO.
This commit is contained in:
justin 2026-06-09 14:40:46 -05:00
parent 220f301453
commit 68e6b60951
9 changed files with 229 additions and 9 deletions

View file

@ -277,6 +277,92 @@ const CDR_STUDY_GRANTING_SLUGS = new Set([
"cdr-analysis",
]);
/**
* Create the ERPNext Sales Order for a compliance / compliance_batch order on
* payment completion. Idempotent: skips if the order(s) already have
* erpnext_sales_order set. Called from handlePaymentComplete so EVERY payment
* method (Stripe webhook, PayPal, crypto) creates the SO -- previously only the
* /checkout/create-session path did, so webhook-confirmed card orders had no SO
* and the workers logged "Sales Order not found 404".
*/
async function ensureComplianceSalesOrder(
orderId: string,
orderType: string,
rows: Record<string, unknown>[],
paymentMethod: string,
): Promise<void> {
if (orderType !== "compliance" && orderType !== "compliance_batch") return;
if (!rows.length) return;
// Already created? (any row carrying the SO id) -> idempotent skip.
if (rows.some(r => r.erpnext_sales_order)) return;
const first = rows[0];
const email = ((first.customer_email as string) || "").toLowerCase().trim();
const name = (first.customer_name as string) || email.split("@")[0] || "Customer";
if (!email || email === "synthetic@pipeline.com") return;
const { customerName: erpnextCustomer } = await findOrCreateCustomer(email, name);
if (!erpnextCustomer) return;
const { COMPLIANCE_SERVICES } = await import("./compliance-orders.js");
const surchargePct = Number(first.surcharge_pct || 0);
let surchargeCents = 0;
const lineItems = rows.map((o: Record<string, any>) => {
const info = COMPLIANCE_SERVICES[(o.service_slug as string) || ""];
surchargeCents += Number(o.surcharge_cents || 0);
const items: Array<{ item_code: string; description: string; qty: number; rate: number }> = [{
item_code: info?.erpnext_item || "COMPLIANCE-SERVICE",
description: (o.service_name as string) || info?.name || "Compliance Service",
qty: 1,
rate: toDollars((o.service_fee_cents as number) || 0),
}];
const govCents = (o.gov_fee_cents as number) || 0;
if (govCents > 0) {
items.push({
item_code: "GOVERNMENT-FILING-FEE",
description: (o.gov_fee_label as string) || "Government filing fee",
qty: 1,
rate: toDollars(govCents),
});
}
return items;
}).flat();
if (surchargeCents > 0) {
lineItems.push({
item_code: "PAYMENT-PROCESSING-FEE",
description: `${GATEWAY_LABELS[paymentMethod] || paymentMethod} surcharge`,
qty: 1,
rate: toDollars(surchargeCents),
});
}
const so = (await createResource("Sales Order", {
customer: erpnextCustomer,
delivery_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0],
custom_external_order_id: orderId,
custom_order_type: "compliance",
custom_payment_gateway: GATEWAY_LABELS[paymentMethod] || paymentMethod,
custom_surcharge_pct: surchargePct,
workflow_state: "Received",
items: lineItems,
})) as { name: string };
try {
await callMethod("frappe.client.submit", { doc: { doctype: "Sales Order", name: so.name } });
} catch { /* submit may fail if workflow doesn't require it */ }
// Link the SO back to the order row(s): batch by batch_id, single by order_number.
if (orderType === "compliance_batch") {
await pool.query(`UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE batch_id = $2`, [so.name, orderId]);
} else {
await pool.query(`UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE order_number = $2`, [so.name, orderId]);
}
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for ${orderType} ${orderId} (${lineItems.length} line items)`);
}
async function grantCDRStudyAccess(
order: Record<string, unknown>,
order_id: string,
@ -1632,6 +1718,19 @@ export async function handlePaymentComplete(
} catch (portalErr) {
console.error("[checkout] Compliance portal-user provisioning failed (non-fatal):", portalErr);
}
// ── Create the ERPNext Sales Order (idempotent) ──────────────────────
// The /checkout/create-session endpoint creates the SO for flows that
// confirm there, but card payments confirm via the Stripe WEBHOOK -> this
// function, which previously did NOT create the SO. Result: every webhook-
// confirmed compliance order had erpnext_sales_order=NULL and the workers
// logged "Sales Order ... not found 404" and fell back to building from PG.
// Create it here for all payment methods. Skips if one already exists.
try {
await ensureComplianceSalesOrder(order_id, order_type, updated.rows, paymentMethod);
} catch (soErr) {
console.error("[checkout] Compliance Sales Order creation failed (non-fatal):", soErr);
}
}
// ── Umami analytics — server-side payment event ─────────────────────────