fix(checkout): create Postgres customers row on order completion (PayPal login bug)

Portal login + forgot-password read the Postgres customers table (bcrypt), NOT
ERPNext. ensureCompliancePortalUser (the common path for Stripe/PayPal/crypto via
handlePaymentComplete) only provisioned the ERPNext customer/website-user and
never created the customers row -- so customers (notably PayPal, who reach this
path directly) had no account to log into or reset a password against. Now upserts
the customers row (no password; ON CONFLICT keeps any existing hash) with name +
company so they can register/reset and log in immediately.

Also: narrowed the placeholder-email skip from 'any synthetic@ or pipeline.com' to
exactly 'synthetic@pipeline.com' (the FMCSA-census placeholder) so real customers
on those real consumer domains aren't wrongly skipped -- which is what bit Paul
Wilson. Added cc support to sendEmail. e2e-paypal-portal-fix.mjs is the regression
test (seeds a compliance order, runs handlePaymentComplete, asserts the customers
row is created). Rescue scripts for the affected customer included.
This commit is contained in:
justin 2026-06-09 14:28:19 -05:00
parent b437f66bc8
commit 9987b1e30d
5 changed files with 274 additions and 8 deletions

View file

@ -36,11 +36,12 @@ function getTransporter(): nodemailer.Transporter {
// ─── Generic send ─────────────────────────────────────────────────────────────
export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string }): Promise<void> {
export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string; cc?: string }): Promise<void> {
const t = getTransporter();
await t.sendMail({
from: SMTP_FROM,
to: opts.to,
...(opts.cc ? { cc: opts.cc } : {}),
subject: opts.subject,
html: opts.html,
text: opts.text || "",

View file

@ -188,18 +188,40 @@ async function ensureCompliancePortalUser(
if (!first) return;
const email = ((first.customer_email as string) || "").toLowerCase().trim();
const name = (first.customer_name as string) || email.split("@")[0] || "Customer";
const company = (first.customer_company as string) || null;
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}`);
// Skip only the genuine FMCSA-census placeholder, never a real customer who
// happens to use these (real) consumer domains. The census placeholder is
// exactly "synthetic@pipeline.com"; treat that one string as non-deliverable
// and anything else as a real address.
if (email === "synthetic@pipeline.com") {
console.warn(`[checkout] Skipping portal provisioning for ${orderId}: FMCSA-census placeholder email`);
return;
}
// ── Portal login account (Postgres `customers` row) ──────────────────
// The portal login + forgot-password read the Postgres `customers` table
// (bcrypt password_hash), NOT ERPNext. We create the row here (no password)
// so the customer can immediately register/reset to set a password and log
// in to track their order. The Stripe path historically relied on the
// customer self-registering; PayPal/crypto orders reach here directly and
// otherwise had NO customers row, which is why PayPal customers could not log
// in or reset their password. Idempotent (ON CONFLICT keeps any existing
// password_hash). See docs / Paul Wilson incident 2026-06-09.
try {
await pool.query(
`INSERT INTO customers (email, name, company)
VALUES ($1, $2, $3)
ON CONFLICT (email) DO UPDATE SET
name = COALESCE(customers.name, EXCLUDED.name),
company = COALESCE(customers.company, EXCLUDED.company)`,
[email, name, company],
);
} catch (custErr) {
console.warn("[checkout] Could not upsert portal customers row:", custErr);
}
// Provision the ERPNext Website User (idempotent) and link to the Customer.
const { portalUserCreated } = await findOrCreateCustomer(email, name);