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:
parent
b437f66bc8
commit
9987b1e30d
5 changed files with 274 additions and 8 deletions
|
|
@ -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 || "",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
69
scripts/e2e-paypal-portal-fix.mjs
Normal file
69
scripts/e2e-paypal-portal-fix.mjs
Normal 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);
|
||||
69
scripts/rescue-paul-correct.mjs
Normal file
69
scripts/rescue-paul-correct.mjs
Normal 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 & 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. · performancewest.net · 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
105
scripts/rescue-paul.mjs
Normal 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 →</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. · performancewest.net · 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");
|
||||
Loading…
Add table
Add a link
Reference in a new issue