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);

View file

@ -0,0 +1,69 @@
/**
* e2e-paypal-portal-fix.mjs - verify that completing a compliance order (the
* path PayPal/Stripe/crypto all funnel through) creates the Postgres `customers`
* row, so the customer can log in / reset password. Regression test for the
* Paul Wilson PayPal login bug (2026-06-09).
*
* It seeds a fake pending compliance order, calls the compiled
* handlePaymentComplete(), then asserts a customers row now exists. Cleans up.
*
* Run: docker exec performancewest-api-1 node /app/scripts/e2e-paypal-portal-fix.mjs
*/
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const { handlePaymentComplete } = await import("/app/dist/routes/checkout.js");
const EMAIL = `e2e-portal+${Date.now().toString().slice(-7)}@example-e2e.test`;
const ORDER = `CO-E2E${Date.now().toString().slice(-6)}`;
let fail = 0;
const ok = (m) => console.log(" [PASS] " + m);
const bad = (m) => { console.log(" [FAIL] " + m); fail++; };
try {
// 1) seed a pending compliance order (admin-assisted D&A, no intake gating needed for this test)
await pool.query(
`INSERT INTO compliance_orders
(order_number, service_slug, service_name, customer_name, customer_email,
customer_company, payment_method, payment_status, total_cents, service_fee_cents)
VALUES ($1,'dot-drug-alcohol','DOT Drug & Alcohol Compliance Program',
'E2E Tester','${EMAIL}','E2E Co','paypal','pending_payment',14900,14900)`,
[ORDER],
);
ok(`seeded pending compliance order ${ORDER}`);
// precondition: no customers row yet
const before = await pool.query(`SELECT id FROM customers WHERE email=$1`, [EMAIL]);
if (before.rows.length === 0) ok("precondition: no customers row before payment");
else bad("precondition failed: customers row already existed");
// 2) simulate payment completion (the PayPal/Stripe/crypto common path)
await handlePaymentComplete(ORDER, "compliance", "e2e-test-session");
ok("handlePaymentComplete ran");
// 3) assert: order marked paid + customers row created
const ord = await pool.query(`SELECT payment_status FROM compliance_orders WHERE order_number=$1`, [ORDER]);
if (ord.rows[0]?.payment_status === "paid") ok("order marked paid");
else bad(`order not paid (got ${ord.rows[0]?.payment_status})`);
const after = await pool.query(`SELECT id, name, company, password_hash FROM customers WHERE email=$1`, [EMAIL]);
if (after.rows.length === 1) {
ok(`customers row created (id=${after.rows[0].id}, name=${after.rows[0].name}, company=${after.rows[0].company})`);
if (after.rows[0].password_hash === null) ok("customers row has no password (correct: customer sets it via register/reset)");
else bad("customers row unexpectedly has a password_hash");
} else {
bad("customers row was NOT created (the bug would still be present)");
}
} catch (e) {
bad("exception: " + e.message);
} finally {
// cleanup
await pool.query(`DELETE FROM password_reset_tokens WHERE customer_id IN (SELECT id FROM customers WHERE email=$1)`, [EMAIL]).catch(()=>{});
await pool.query(`DELETE FROM customers WHERE email=$1`, [EMAIL]).catch(()=>{});
await pool.query(`DELETE FROM compliance_orders WHERE order_number=$1`, [ORDER]).catch(()=>{});
console.log(" cleaned up test order + customers row");
await pool.end();
}
console.log(fail === 0 ? "\n=== ALL CHECKS PASSED ===" : `\n=== ${fail} CHECK(S) FAILED ===`);
process.exit(fail === 0 ? 0 : 1);

View file

@ -0,0 +1,69 @@
/**
* Correction email for Paul Wilson - the earlier "next steps" email wrongly said
* "no action is required." His MCS-150 and UCR DO require him to complete the
* intake form (and the MCS-150 needs his signature, since it's a perjury
* certification we never auto-submit). This sends the correct intake links.
* CC justin@performancewest.net.
*
* Run: docker exec performancewest-api-1 node /app/scripts/rescue-paul-correct.mjs
*/
import pg from "pg";
import nodemailer from "nodemailer";
const EMAIL = "synthetic@pipeline.com";
const CC = "justin@performancewest.net";
const NAME = "Paul Wilson";
const COMPANY = "Compound Technologies, Inc";
const SITE = process.env.DOMAIN ? `https://${process.env.DOMAIN}` : "https://performancewest.net";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const mailer = nodemailer.createTransport({
host: process.env.SMTP_HOST || "co.carrierone.com",
port: parseInt(process.env.SMTP_PORT || "587", 10),
secure: false,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
const FROM = process.env.SMTP_FROM || "Performance West <noreply@performancewest.net>";
const firstName = NAME.split(" ")[0];
const { rows } = await pool.query(
`SELECT order_number, service_name, service_slug, COALESCE(intake_data_validated,false) AS done
FROM compliance_orders WHERE customer_email=$1 ORDER BY created_at`,
[EMAIL],
);
const items = rows.map(o => {
const url = `${SITE}/order/${o.service_slug}?order=${o.order_number}`;
const note = o.service_slug === "mcs150-update"
? " (we will prepare the filing from your intake, then send it to you to sign before we submit to FMCSA)"
: "";
return `<li style="margin:8px 0;font-size:14px;color:#374151">
<a href="${url}" style="color:#1e40af;font-weight:600;text-decoration:underline">${o.service_name}</a>
<span style="color:#888;font-family:monospace">(${o.order_number})</span>${note}
</li>`;
}).join("");
await mailer.sendMail({
from: FROM, to: EMAIL, cc: CC,
subject: "Correction: please complete your intake forms to start your filings",
html: `<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#222">
<h2 style="color:#1a2744;margin:0 0 8px">Quick correction - one step needed from you</h2>
<p>Hi ${firstName}, apologies - my previous email said no action was needed. That was not
correct. To begin your filings for ${COMPANY}, please complete the short intake form for
each service below (about 2-5 minutes each). We cannot start a filing until its intake is done.</p>
<ul style="padding-left:18px">${items}</ul>
<p style="font-size:14px;color:#374151">For the <strong>MCS-150 Biennial Update</strong>, after you
complete intake we prepare the update and send it to you to review and sign - we never submit a
certification to FMCSA without your signature. The <strong>Drug &amp; Alcohol program binder</strong>
is delivered to you to review and adopt once its intake is complete.</p>
<p style="font-size:13px;color:#666">If you have not set your portal password yet, use the link in the
separate "Set your password" email so you can log in and track everything. Questions? Reply here or
call 1-888-411-0383.</p>
<p style="font-size:12px;color:#9ca3af">Performance West Inc. &middot; performancewest.net &middot; 1-888-411-0383</p>
</div>`,
text: `Hi ${firstName}, correction: my earlier email wrongly said no action was needed. Please complete the intake form for each service to start your filings:\n` +
rows.map(o => `- ${o.service_name} (${o.order_number}): ${SITE}/order/${o.service_slug}?order=${o.order_number}`).join("\n") +
`\nThe MCS-150 will be sent to you to sign before we submit to FMCSA. Questions? 1-888-411-0383.`,
});
console.log(`[correct] sent correction with intake links to ${EMAIL} (cc ${CC}) for ${rows.length} orders`);
await pool.end();

105
scripts/rescue-paul.mjs Normal file
View file

@ -0,0 +1,105 @@
/**
* One-off rescue for Paul Wilson (Compound Technologies, Inc) - PayPal compliance
* orders where the portal `customers` row was never created (PayPal path bug),
* so he could not log in or reset his password.
*
* Steps:
* 1. Set his email back to synthetic@pipeline.com on all his compliance orders.
* 2. Create his row in the `customers` table (the table portal login/forgot-
* password actually reads) if missing.
* 3. Send a password-set link (so he can log in) + re-send the intake email,
* both CC'd to justin@performancewest.net.
*
* Run in the api container: docker exec performancewest-api-1 node /app/scripts/rescue-paul.mjs
*/
import pg from "pg";
import crypto from "crypto";
import nodemailer from "nodemailer";
const OLD_EMAIL = "spx-7@adelphia.net";
const NEW_EMAIL = "synthetic@pipeline.com";
const CC = "justin@performancewest.net";
const NAME = "Paul Wilson";
const COMPANY = "Compound Technologies, Inc";
const SITE = process.env.DOMAIN ? `https://${process.env.DOMAIN}` : "https://performancewest.net";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const mailer = nodemailer.createTransport({
host: process.env.SMTP_HOST || "co.carrierone.com",
port: parseInt(process.env.SMTP_PORT || "587", 10),
secure: false,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
const FROM = process.env.SMTP_FROM || "Performance West <noreply@performancewest.net>";
const log = (m) => console.log("[rescue] " + m);
// 1) flip email back to the address he messaged from
const upd = await pool.query(
`UPDATE compliance_orders SET customer_email=$1 WHERE customer_email=$2 RETURNING order_number, service_name`,
[NEW_EMAIL, OLD_EMAIL],
);
log(`updated ${upd.rowCount} order(s) email -> ${NEW_EMAIL}: ${upd.rows.map(r => r.order_number).join(", ")}`);
const orders = upd.rows.length ? upd.rows : (await pool.query(
`SELECT order_number, service_name FROM compliance_orders WHERE customer_email=$1`, [NEW_EMAIL])).rows;
// 2) ensure customers row exists (portal login reads this table)
const cust = 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)
RETURNING id, email, (password_hash IS NOT NULL) AS has_pw`,
[NEW_EMAIL, NAME, COMPANY],
);
const customer = cust.rows[0];
log(`customers row id=${customer.id} email=${customer.email} has_password=${customer.has_pw}`);
// 3a) password-set link (reuse the forgot-password token mechanism)
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000); // 60 min
await pool.query(
`INSERT INTO password_reset_tokens (customer_id, token, expires_at) VALUES ($1,$2,$3)`,
[customer.id, token, expires],
);
const resetLink = `${SITE}/account/reset-password?token=${token}`;
const firstName = NAME.split(" ")[0];
await mailer.sendMail({
from: FROM, to: NEW_EMAIL, cc: CC,
subject: "Set your Performance West password to log in",
html: `<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;color:#222">
<h2 style="color:#1a2744;margin:0 0 8px">Set your password</h2>
<p>Hi ${firstName},</p>
<p>Thanks for your order. To finish setting up your account so you can log in to the
Performance West portal and track your filings, click below to choose a password.
This link expires in 60 minutes.</p>
<p style="margin:24px 0"><a href="${resetLink}" style="background:#2d4e78;color:#fff;padding:12px 28px;border-radius:8px;text-decoration:none;font-weight:600">Set my password &rarr;</a></p>
<p style="font-size:13px;color:#666">Or paste this link into your browser:<br>${resetLink}</p>
<p style="font-size:13px;color:#666">Questions? Reply to this email or call 1-888-411-0383.</p>
</div>`,
text: `Hi ${firstName}, set your Performance West password to log in: ${resetLink} (expires in 60 minutes).`,
});
log(`password-set link sent to ${NEW_EMAIL} (cc ${CC})`);
// 3b) re-send the intake / next-steps email
const orderListHtml = orders.map(o =>
`<li style="margin:4px 0">${o.service_name} <span style="color:#888;font-family:monospace">(${o.order_number})</span></li>`).join("");
await mailer.sendMail({
from: FROM, to: NEW_EMAIL, cc: CC,
subject: "Your Performance West compliance order - next steps",
html: `<div style="font-family:Arial,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#222">
<h2 style="color:#1a2744;margin:0 0 8px">We're getting started</h2>
<p>Hi ${firstName}, thank you for your order with Performance West. Your payment is confirmed and we have the following services in progress for ${COMPANY}:</p>
<ul style="padding-left:18px">${orderListHtml}</ul>
<p>These are admin-assisted filings handled by our team - no action is required from you to begin. If we need any additional detail (for example FMCSA login delegation or a signature), we will reach out.</p>
<p>You can track everything in your portal once you set your password (see the separate email we just sent you).</p>
<p style="font-size:13px;color:#666">Questions? Reply to this email or call 1-888-411-0383.</p>
<p style="font-size:12px;color:#9ca3af">Performance West Inc. &middot; performancewest.net &middot; 1-888-411-0383</p>
</div>`,
text: `Hi ${firstName}, your Performance West order is confirmed. Services: ${orders.map(o => o.service_name + " (" + o.order_number + ")").join("; ")}. Set your password via the separate email to track in the portal.`,
});
log(`intake/next-steps email sent to ${NEW_EMAIL} (cc ${CC})`);
await pool.end();
log("DONE");