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.
This commit is contained in:
justin 2026-06-02 22:44:34 -05:00
parent 2b13c36c93
commit f6419759e6
3 changed files with 115 additions and 1 deletions

View file

@ -165,6 +165,71 @@ async function findOrCreateCustomer(
return { customerName, portalUserCreated }; 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<string, unknown>[],
): Promise<void> {
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. * Fetch order from PG and build Stripe line items + surcharge basis.
* Returns null if order not found or not in pending_payment state. * Returns null if order not found or not in pending_payment state.
@ -1338,6 +1403,18 @@ export async function handlePaymentComplete(
} catch (intakeErr) { } catch (intakeErr) {
console.error("[checkout] Compliance intake email failed (non-fatal):", 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 ───────────────────────── // ── Umami analytics — server-side payment event ─────────────────────────
@ -2000,7 +2077,7 @@ router.get("/api/v1/checkout/crypto-details", async (req, res) => {
// ─── Compliance intake email ──────────────────────────────────────────────── // ─── Compliance intake email ────────────────────────────────────────────────
async function sendComplianceIntakeEmail( export async function sendComplianceIntakeEmail(
orderId: string, orderId: string,
orderType: string, orderType: string,
orders: Record<string, unknown>[], orders: Record<string, unknown>[],

View file

@ -15,6 +15,25 @@ import { randomBytes } from "crypto";
const router = Router(); 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) ────────────────────────────────────── // ── Service catalog (prices in cents) ──────────────────────────────────────
const COMPLIANCE_SERVICES: Record< const COMPLIANCE_SERVICES: Record<
string, string,
@ -1037,6 +1056,12 @@ router.post("/api/v1/compliance-orders", async (req, res) => {
return; return;
} }
const emailErr = emailError(customer_email);
if (emailErr) {
res.status(400).json({ error: emailErr });
return;
}
const service = COMPLIANCE_SERVICES[service_slug]; const service = COMPLIANCE_SERVICES[service_slug];
if (!service) { if (!service) {
res.status(400).json({ 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." }); res.status(400).json({ error: "customer_email and customer_name are required." });
return; return;
} }
{
const emailErr = emailError(customer_email);
if (emailErr) {
res.status(400).json({ error: emailErr });
return;
}
}
// Deduplicate and validate service slugs // Deduplicate and validate service slugs
let services = [...new Set(rawServices as string[])]; let services = [...new Set(rawServices as string[])];

View file

@ -338,6 +338,11 @@ def _build_portal_onboard_html(pg_order: dict | None) -> str:
order_number = pg_order.get("order_number", "") order_number = pg_order.get("order_number", "")
if not email: if not email:
return "" 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) token = _generate_set_password_token(email, order_number)
url = f"{PORTAL_URL.rstrip('/')}/set-password?token={token}" url = f"{PORTAL_URL.rstrip('/')}/set-password?token={token}"
return _ONBOARD_TEMPLATE.format(url=url, ttl_hours=SET_PASSWORD_TTL_HOURS) return _ONBOARD_TEMPLATE.format(url=url, ttl_hours=SET_PASSWORD_TTL_HOURS)