From f6419759e61d98375ecf9d8556b8de00bb97450a Mon Sep 17 00:00:00 2001 From: justin Date: Tue, 2 Jun 2026 22:44:34 -0500 Subject: [PATCH] portal: converge all compliance orders on the single ERPNext portal Root cause of customers being unable to log in: ERPNext (portal.performancewest.net) is the intended single portal and already surfaces compliance/trucking orders (performancewest_erpnext/www/orders.py reads compliance_orders by email). But only the Stripe checkout path provisioned the ERPNext Website User up-front (findOrCreateCustomer). PayPal / crypto / remediation-pipeline orders go straight to handlePaymentComplete, which created NO portal user and never set portal_user_created -> no login + no set-password invite (exactly what happened to the Paul Wilson / Compound Technologies PayPal order). - handlePaymentComplete: add ensureCompliancePortalUser() in the shared post-payment path so EVERY paid compliance order (any payment method) gets an ERPNext portal account + the set-password invite. Idempotent. - Guard against placeholder emails (synthetic@/pipeline.com etc): skip portal provisioning and the set-password invite for non-deliverable addresses. - compliance-orders API: validate email format AND reject placeholder addresses at order creation (was: presence-only, so synthetic@pipeline.com passed). - delivery_worker: never email a set-password invite to a placeholder address. Note: the legacy PG-customers login (api/routes/portal-auth.ts, /account/*) is CRTC/formation-era and only backfills canada_crtc_orders/orders, never compliance_orders. ERPNext is now the consistent portal for compliance. --- api/src/routes/checkout.ts | 79 ++++++++++++++++++++++++++++- api/src/routes/compliance-orders.ts | 32 ++++++++++++ scripts/workers/delivery_worker.py | 5 ++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/api/src/routes/checkout.ts b/api/src/routes/checkout.ts index 574527e..4e0b56c 100644 --- a/api/src/routes/checkout.ts +++ b/api/src/routes/checkout.ts @@ -165,6 +165,71 @@ async function findOrCreateCustomer( return { customerName, portalUserCreated }; } +/** + * Ensure a compliance customer has an ERPNext portal account and persist the + * `portal_user_created` flag on their order rows. + * + * ERPNext (portal.performancewest.net) is the single customer portal. Normal + * Stripe checkout provisions the Website User up-front via findOrCreateCustomer, + * but PayPal / crypto / remediation-pipeline orders reach handlePaymentComplete + * without ever creating one — leaving those customers unable to log in. This + * runs in that shared post-payment path so EVERY paid compliance order gets a + * portal account regardless of how it was created or paid. Fully idempotent: + * ensureWebsiteUser no-ops if the User already exists, and we only flip the + * flag (which gates the delivery worker's set-password invite) the first time. + */ +async function ensureCompliancePortalUser( + orderId: string, + orderType: string, + rows: Record[], +): Promise { + const first = rows[0]; + if (!first) return; + const email = ((first.customer_email as string) || "").toLowerCase().trim(); + const name = (first.customer_name as string) || email.split("@")[0] || "Customer"; + if (!email) return; + + // Skip obviously non-deliverable placeholder addresses so we never create a + // dead portal account or fire a set-password invite into the void. (e.g. the + // FMCSA-census "synthetic@pipeline.com" placeholder used when no real email + // was found.) These orders need a real email before onboarding. + const domain = email.split("@")[1] || ""; + if (domain === "pipeline.com" || email.startsWith("synthetic@")) { + console.warn(`[checkout] Skipping portal provisioning for ${orderId}: placeholder email ${email}`); + return; + } + + // Provision the ERPNext Website User (idempotent) and link to the Customer. + const { portalUserCreated } = await findOrCreateCustomer(email, name); + + // Persist the flag so the success page + delivery worker surface the + // set-password onboarding. Set it whenever the account is new; harmless to + // re-set. Update every row for the batch (match by batch_id) or the single + // order. + if (portalUserCreated) { + const table = orderType === "compliance_batch" || orderType === "compliance" + ? "compliance_orders" + : null; + if (table) { + try { + if (orderType === "compliance_batch") { + await pool.query( + `UPDATE ${table} SET portal_user_created = TRUE WHERE batch_id = $1`, + [orderId], + ); + } else { + await pool.query( + `UPDATE ${table} SET portal_user_created = TRUE WHERE order_number = $1`, + [orderId], + ); + } + } catch (pgErr) { + console.warn("[checkout] Could not persist portal_user_created flag:", pgErr); + } + } + } +} + /** * Fetch order from PG and build Stripe line items + surcharge basis. * Returns null if order not found or not in pending_payment state. @@ -1338,6 +1403,18 @@ export async function handlePaymentComplete( } catch (intakeErr) { console.error("[checkout] Compliance intake email failed (non-fatal):", intakeErr); } + + // Ensure the customer has an ERPNext portal account so they can log in + // at portal.performancewest.net and see this order. ERPNext is the single + // portal. The Stripe checkout path creates the Website User up-front in + // findOrCreateCustomer(), but PayPal / crypto / remediation-pipeline paths + // come straight here and would otherwise skip it (this was the cause of + // customers who paid via PayPal being unable to log in). Idempotent. + try { + await ensureCompliancePortalUser(order_id, order_type, updated.rows); + } catch (portalErr) { + console.error("[checkout] Compliance portal-user provisioning failed (non-fatal):", portalErr); + } } // ── Umami analytics — server-side payment event ───────────────────────── @@ -2000,7 +2077,7 @@ router.get("/api/v1/checkout/crypto-details", async (req, res) => { // ─── Compliance intake email ──────────────────────────────────────────────── -async function sendComplianceIntakeEmail( +export async function sendComplianceIntakeEmail( orderId: string, orderType: string, orders: Record[], diff --git a/api/src/routes/compliance-orders.ts b/api/src/routes/compliance-orders.ts index 55e72ec..3afffc7 100644 --- a/api/src/routes/compliance-orders.ts +++ b/api/src/routes/compliance-orders.ts @@ -15,6 +15,25 @@ import { randomBytes } from "crypto"; const router = Router(); +// ── Email validation ──────────────────────────────────────────────────────── +// Reject malformed addresses AND known non-deliverable placeholders (e.g. the +// FMCSA-census "synthetic@pipeline.com" used when no real email was found) at +// order-creation time, so we never seed an order/portal account with an +// address we can't actually reach. +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const PLACEHOLDER_EMAIL_DOMAINS = new Set(["pipeline.com", "example.com", "test.com"]); + +function emailError(raw: unknown): string | null { + const email = String(raw || "").trim().toLowerCase(); + if (!email) return "customer_email is required."; + if (!EMAIL_RE.test(email)) return "customer_email is not a valid email address."; + const domain = email.split("@")[1] || ""; + if (email.startsWith("synthetic@") || PLACEHOLDER_EMAIL_DOMAINS.has(domain)) { + return "customer_email appears to be a placeholder; a real email address is required."; + } + return null; +} + // ── Service catalog (prices in cents) ────────────────────────────────────── const COMPLIANCE_SERVICES: Record< string, @@ -1037,6 +1056,12 @@ router.post("/api/v1/compliance-orders", async (req, res) => { return; } + const emailErr = emailError(customer_email); + if (emailErr) { + res.status(400).json({ error: emailErr }); + return; + } + const service = COMPLIANCE_SERVICES[service_slug]; if (!service) { res.status(400).json({ @@ -1251,6 +1276,13 @@ router.post("/api/v1/compliance-orders/batch", async (req, res) => { res.status(400).json({ error: "customer_email and customer_name are required." }); return; } + { + const emailErr = emailError(customer_email); + if (emailErr) { + res.status(400).json({ error: emailErr }); + return; + } + } // Deduplicate and validate service slugs let services = [...new Set(rawServices as string[])]; diff --git a/scripts/workers/delivery_worker.py b/scripts/workers/delivery_worker.py index b85f004..d8290e6 100644 --- a/scripts/workers/delivery_worker.py +++ b/scripts/workers/delivery_worker.py @@ -338,6 +338,11 @@ def _build_portal_onboard_html(pg_order: dict | None) -> str: order_number = pg_order.get("order_number", "") if not email: return "" + # Never send a set-password invite to a known placeholder address (e.g. the + # FMCSA-census "synthetic@pipeline.com" used when no real email was found). + em = email.strip().lower() + if em.startswith("synthetic@") or em.split("@")[-1] in {"pipeline.com", "example.com", "test.com"}: + return "" token = _generate_set_password_token(email, order_number) url = f"{PORTAL_URL.rstrip('/')}/set-password?token={token}" return _ONBOARD_TEMPLATE.format(url=url, ttl_hours=SET_PASSWORD_TTL_HOURS)