diff --git a/api/src/email.ts b/api/src/email.ts index 82f8b66..2832fe2 100644 --- a/api/src/email.ts +++ b/api/src/email.ts @@ -36,11 +36,12 @@ function getTransporter(): nodemailer.Transporter { // ─── Generic send ───────────────────────────────────────────────────────────── -export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string }): Promise { +export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string; cc?: string }): Promise { 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 || "", diff --git a/api/src/routes/checkout.ts b/api/src/routes/checkout.ts index a79b748..a88bc85 100644 --- a/api/src/routes/checkout.ts +++ b/api/src/routes/checkout.ts @@ -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); diff --git a/scripts/e2e-paypal-portal-fix.mjs b/scripts/e2e-paypal-portal-fix.mjs new file mode 100644 index 0000000..55aeec7 --- /dev/null +++ b/scripts/e2e-paypal-portal-fix.mjs @@ -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); diff --git a/scripts/rescue-paul-correct.mjs b/scripts/rescue-paul-correct.mjs new file mode 100644 index 0000000..9e8315b --- /dev/null +++ b/scripts/rescue-paul-correct.mjs @@ -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 "; +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 `
  • + ${o.service_name} + (${o.order_number})${note} +
  • `; +}).join(""); + +await mailer.sendMail({ + from: FROM, to: EMAIL, cc: CC, + subject: "Correction: please complete your intake forms to start your filings", + html: `
    +

    Quick correction - one step needed from you

    +

    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.

    +
      ${items}
    +

    For the MCS-150 Biennial Update, 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 Drug & Alcohol program binder + is delivered to you to review and adopt once its intake is complete.

    +

    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.

    +

    Performance West Inc. · performancewest.net · 1-888-411-0383

    +
    `, + 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(); diff --git a/scripts/rescue-paul.mjs b/scripts/rescue-paul.mjs new file mode 100644 index 0000000..1cb8001 --- /dev/null +++ b/scripts/rescue-paul.mjs @@ -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 "; + +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: `
    +

    Set your password

    +

    Hi ${firstName},

    +

    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.

    +

    Set my password →

    +

    Or paste this link into your browser:
    ${resetLink}

    +

    Questions? Reply to this email or call 1-888-411-0383.

    +
    `, + 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 => + `
  • ${o.service_name} (${o.order_number})
  • `).join(""); +await mailer.sendMail({ + from: FROM, to: NEW_EMAIL, cc: CC, + subject: "Your Performance West compliance order - next steps", + html: `
    +

    We're getting started

    +

    Hi ${firstName}, thank you for your order with Performance West. Your payment is confirmed and we have the following services in progress for ${COMPANY}:

    +
      ${orderListHtml}
    +

    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.

    +

    You can track everything in your portal once you set your password (see the separate email we just sent you).

    +

    Questions? Reply to this email or call 1-888-411-0383.

    +

    Performance West Inc. · performancewest.net · 1-888-411-0383

    +
    `, + 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");