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:
parent
2b13c36c93
commit
f6419759e6
3 changed files with 115 additions and 1 deletions
|
|
@ -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>[],
|
||||||
|
|
|
||||||
|
|
@ -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[])];
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue