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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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<string, unknown>[],
|
||||
|
|
|
|||
|
|
@ -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[])];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue