From 68e6b60951930b84070fbf2dee14375d0a43b3e1 Mon Sep 17 00:00:00 2001 From: justin Date: Tue, 9 Jun 2026 14:40:46 -0500 Subject: [PATCH] fix: worker emails (localhost:25 -> SMTP relay) + create ERPNext SO on webhook payment Two bugs found tracing Mitchell Allen's batch CB-95BA6C90 (5 DOT services, card): 1) Worker authorization/signing-link/status emails were sent via smtplib.SMTP('localhost', 25), which has no MTA in the workers container -> every send failed '[Errno 111] Connection refused', so customers never got their e-sign links and orders sat 'awaiting client signature' forever. Routed all 9 hardcoded localhost:25 sites (state_trucking, mcs150_update, boc3_filing, hazmat_phmsa, mailbox_setup, dot_esign, completion_emails) through the authenticated SMTP relay (SMTP_HOST/PORT/STARTTLS/login) + added a shared worker_email.send_worker_email helper. 2) The ERPNext Sales Order for compliance/compliance_batch was only created in the /checkout/create-session endpoint, but CARD orders confirm via the Stripe WEBHOOK -> handlePaymentComplete, which never created the SO. Result: every webhook-confirmed order had erpnext_sales_order=NULL and workers logged 'Sales Order not found 404' then built from PG. Added idempotent ensureComplianceSalesOrder() to handlePaymentComplete so ALL payment methods (card-webhook, PayPal, crypto) create + link the SO. --- api/src/routes/checkout.ts | 99 ++++++++++++++++++++++ scripts/workers/completion_emails.py | 7 +- scripts/workers/services/boc3_filing.py | 14 ++- scripts/workers/services/dot_esign.py | 7 +- scripts/workers/services/hazmat_phmsa.py | 7 +- scripts/workers/services/mailbox_setup.py | 7 +- scripts/workers/services/mcs150_update.py | 7 +- scripts/workers/services/state_trucking.py | 14 ++- scripts/workers/worker_email.py | 76 +++++++++++++++++ 9 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 scripts/workers/worker_email.py diff --git a/api/src/routes/checkout.ts b/api/src/routes/checkout.ts index de035e0..137fbc0 100644 --- a/api/src/routes/checkout.ts +++ b/api/src/routes/checkout.ts @@ -277,6 +277,92 @@ const CDR_STUDY_GRANTING_SLUGS = new Set([ "cdr-analysis", ]); +/** + * Create the ERPNext Sales Order for a compliance / compliance_batch order on + * payment completion. Idempotent: skips if the order(s) already have + * erpnext_sales_order set. Called from handlePaymentComplete so EVERY payment + * method (Stripe webhook, PayPal, crypto) creates the SO -- previously only the + * /checkout/create-session path did, so webhook-confirmed card orders had no SO + * and the workers logged "Sales Order not found 404". + */ +async function ensureComplianceSalesOrder( + orderId: string, + orderType: string, + rows: Record[], + paymentMethod: string, +): Promise { + if (orderType !== "compliance" && orderType !== "compliance_batch") return; + if (!rows.length) return; + + // Already created? (any row carrying the SO id) -> idempotent skip. + if (rows.some(r => r.erpnext_sales_order)) return; + + const first = rows[0]; + const email = ((first.customer_email as string) || "").toLowerCase().trim(); + const name = (first.customer_name as string) || email.split("@")[0] || "Customer"; + if (!email || email === "synthetic@pipeline.com") return; + + const { customerName: erpnextCustomer } = await findOrCreateCustomer(email, name); + if (!erpnextCustomer) return; + + const { COMPLIANCE_SERVICES } = await import("./compliance-orders.js"); + const surchargePct = Number(first.surcharge_pct || 0); + let surchargeCents = 0; + + const lineItems = rows.map((o: Record) => { + const info = COMPLIANCE_SERVICES[(o.service_slug as string) || ""]; + surchargeCents += Number(o.surcharge_cents || 0); + const items: Array<{ item_code: string; description: string; qty: number; rate: number }> = [{ + item_code: info?.erpnext_item || "COMPLIANCE-SERVICE", + description: (o.service_name as string) || info?.name || "Compliance Service", + qty: 1, + rate: toDollars((o.service_fee_cents as number) || 0), + }]; + const govCents = (o.gov_fee_cents as number) || 0; + if (govCents > 0) { + items.push({ + item_code: "GOVERNMENT-FILING-FEE", + description: (o.gov_fee_label as string) || "Government filing fee", + qty: 1, + rate: toDollars(govCents), + }); + } + return items; + }).flat(); + + if (surchargeCents > 0) { + lineItems.push({ + item_code: "PAYMENT-PROCESSING-FEE", + description: `${GATEWAY_LABELS[paymentMethod] || paymentMethod} surcharge`, + qty: 1, + rate: toDollars(surchargeCents), + }); + } + + const so = (await createResource("Sales Order", { + customer: erpnextCustomer, + delivery_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0], + custom_external_order_id: orderId, + custom_order_type: "compliance", + custom_payment_gateway: GATEWAY_LABELS[paymentMethod] || paymentMethod, + custom_surcharge_pct: surchargePct, + workflow_state: "Received", + items: lineItems, + })) as { name: string }; + + try { + await callMethod("frappe.client.submit", { doc: { doctype: "Sales Order", name: so.name } }); + } catch { /* submit may fail if workflow doesn't require it */ } + + // Link the SO back to the order row(s): batch by batch_id, single by order_number. + if (orderType === "compliance_batch") { + await pool.query(`UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE batch_id = $2`, [so.name, orderId]); + } else { + await pool.query(`UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE order_number = $2`, [so.name, orderId]); + } + console.log(`[checkout] Created ERPNext Sales Order ${so.name} for ${orderType} ${orderId} (${lineItems.length} line items)`); +} + async function grantCDRStudyAccess( order: Record, order_id: string, @@ -1632,6 +1718,19 @@ export async function handlePaymentComplete( } catch (portalErr) { console.error("[checkout] Compliance portal-user provisioning failed (non-fatal):", portalErr); } + + // ── Create the ERPNext Sales Order (idempotent) ────────────────────── + // The /checkout/create-session endpoint creates the SO for flows that + // confirm there, but card payments confirm via the Stripe WEBHOOK -> this + // function, which previously did NOT create the SO. Result: every webhook- + // confirmed compliance order had erpnext_sales_order=NULL and the workers + // logged "Sales Order ... not found 404" and fell back to building from PG. + // Create it here for all payment methods. Skips if one already exists. + try { + await ensureComplianceSalesOrder(order_id, order_type, updated.rows, paymentMethod); + } catch (soErr) { + console.error("[checkout] Compliance Sales Order creation failed (non-fatal):", soErr); + } } // ── Umami analytics — server-side payment event ───────────────────────── diff --git a/scripts/workers/completion_emails.py b/scripts/workers/completion_emails.py index 8856172..e911c17 100644 --- a/scripts/workers/completion_emails.py +++ b/scripts/workers/completion_emails.py @@ -39,7 +39,12 @@ def send_email(to: str, subject: str, html: str): msg["Subject"] = subject msg.attach(MIMEText(html, "html")) - with smtplib.SMTP("localhost", 25) as s: + import os as _smtp_os + with smtplib.SMTP(_smtp_os.getenv("SMTP_HOST", "co.carrierone.com"), int(_smtp_os.getenv("SMTP_PORT", "587")), timeout=30) as s: + s.starttls() + _u, _p = _smtp_os.getenv("SMTP_USER", ""), _smtp_os.getenv("SMTP_PASS", "") + if _u and _p: + s.login(_u, _p) s.sendmail(SMTP_FROM, [to], msg.as_string()) diff --git a/scripts/workers/services/boc3_filing.py b/scripts/workers/services/boc3_filing.py index 04957b3..2ac7307 100644 --- a/scripts/workers/services/boc3_filing.py +++ b/scripts/workers/services/boc3_filing.py @@ -369,7 +369,12 @@ class BOC3FilingHandler: msg["From"] = "noreply@performancewest.net" msg["To"] = customer_email - with smtplib.SMTP("localhost", 25) as s: + import os as _smtp_os + with smtplib.SMTP(_smtp_os.getenv("SMTP_HOST", "co.carrierone.com"), int(_smtp_os.getenv("SMTP_PORT", "587")), timeout=30) as s: + s.starttls() + _u, _p = _smtp_os.getenv("SMTP_USER", ""), _smtp_os.getenv("SMTP_PASS", "") + if _u and _p: + s.login(_u, _p) s.sendmail(msg["From"], [customer_email], msg.as_string()) LOG.info("[%s] Status email sent to %s", order_number, customer_email) @@ -404,7 +409,12 @@ class BOC3FilingHandler: msg["From"] = "noreply@performancewest.net" msg["To"] = customer_email - with smtplib.SMTP("localhost", 25) as s: + import os as _smtp_os + with smtplib.SMTP(_smtp_os.getenv("SMTP_HOST", "co.carrierone.com"), int(_smtp_os.getenv("SMTP_PORT", "587")), timeout=30) as s: + s.starttls() + _u, _p = _smtp_os.getenv("SMTP_USER", ""), _smtp_os.getenv("SMTP_PASS", "") + if _u and _p: + s.login(_u, _p) s.sendmail(msg["From"], [customer_email], msg.as_string()) LOG.info("[%s] Confirmation email sent to %s", order_number, customer_email) diff --git a/scripts/workers/services/dot_esign.py b/scripts/workers/services/dot_esign.py index e6a52d9..442f78c 100644 --- a/scripts/workers/services/dot_esign.py +++ b/scripts/workers/services/dot_esign.py @@ -298,5 +298,10 @@ def _send_signing_email( msg["From"] = "noreply@performancewest.net" msg["To"] = customer_email - with smtplib.SMTP("localhost", 25, timeout=30) as s: + import os as _smtp_os + with smtplib.SMTP(_smtp_os.getenv("SMTP_HOST", "co.carrierone.com"), int(_smtp_os.getenv("SMTP_PORT", "587")), timeout=30) as s: + s.starttls() + _u, _p = _smtp_os.getenv("SMTP_USER", ""), _smtp_os.getenv("SMTP_PASS", "") + if _u and _p: + s.login(_u, _p) s.sendmail(msg["From"], [customer_email], msg.as_string()) diff --git a/scripts/workers/services/hazmat_phmsa.py b/scripts/workers/services/hazmat_phmsa.py index b28c423..d4e9e39 100644 --- a/scripts/workers/services/hazmat_phmsa.py +++ b/scripts/workers/services/hazmat_phmsa.py @@ -184,7 +184,12 @@ class HazmatPHMSAHandler: msg["From"] = "noreply@performancewest.net" msg["To"] = customer_email - with smtplib.SMTP("localhost", 25) as s: + import os as _smtp_os + with smtplib.SMTP(_smtp_os.getenv("SMTP_HOST", "co.carrierone.com"), int(_smtp_os.getenv("SMTP_PORT", "587")), timeout=30) as s: + s.starttls() + _u, _p = _smtp_os.getenv("SMTP_USER", ""), _smtp_os.getenv("SMTP_PASS", "") + if _u and _p: + s.login(_u, _p) s.sendmail(msg["From"], [customer_email], msg.as_string()) LOG.info("[%s] Status email sent to %s", order_number, customer_email) diff --git a/scripts/workers/services/mailbox_setup.py b/scripts/workers/services/mailbox_setup.py index e976c5c..cb975a1 100644 --- a/scripts/workers/services/mailbox_setup.py +++ b/scripts/workers/services/mailbox_setup.py @@ -187,7 +187,12 @@ class MailboxSetupHandler: msg["From"] = "noreply@performancewest.net" msg["To"] = customer_email - with smtplib.SMTP("localhost", 25) as s: + import os as _smtp_os + with smtplib.SMTP(_smtp_os.getenv("SMTP_HOST", "co.carrierone.com"), int(_smtp_os.getenv("SMTP_PORT", "587")), timeout=30) as s: + s.starttls() + _u, _p = _smtp_os.getenv("SMTP_USER", ""), _smtp_os.getenv("SMTP_PASS", "") + if _u and _p: + s.login(_u, _p) s.sendmail(msg["From"], [customer_email], msg.as_string()) LOG.info("[%s] 1583 e-sign email sent to %s", order_number, customer_email) diff --git a/scripts/workers/services/mcs150_update.py b/scripts/workers/services/mcs150_update.py index c79e6e8..8d34d1b 100644 --- a/scripts/workers/services/mcs150_update.py +++ b/scripts/workers/services/mcs150_update.py @@ -391,7 +391,12 @@ class MCS150UpdateHandler: msg["From"] = "noreply@performancewest.net" msg["To"] = customer_email - with smtplib.SMTP("localhost", 25) as s: + import os as _smtp_os + with smtplib.SMTP(_smtp_os.getenv("SMTP_HOST", "co.carrierone.com"), int(_smtp_os.getenv("SMTP_PORT", "587")), timeout=30) as s: + s.starttls() + _u, _p = _smtp_os.getenv("SMTP_USER", ""), _smtp_os.getenv("SMTP_PASS", "") + if _u and _p: + s.login(_u, _p) s.sendmail(msg["From"], [customer_email], msg.as_string()) LOG.info("[%s] Status email sent to %s", order_number, customer_email) diff --git a/scripts/workers/services/state_trucking.py b/scripts/workers/services/state_trucking.py index 448a3e2..f514b7c 100644 --- a/scripts/workers/services/state_trucking.py +++ b/scripts/workers/services/state_trucking.py @@ -756,7 +756,12 @@ class StateTruckingHandler: msg["Subject"] = f"Action Required: Sign Your Authorization — {service_name}" msg["From"] = "noreply@performancewest.net" msg["To"] = customer_email - with smtplib.SMTP("localhost", 25, timeout=30) as s: + import os as _smtp_os + with smtplib.SMTP(_smtp_os.getenv("SMTP_HOST", "co.carrierone.com"), int(_smtp_os.getenv("SMTP_PORT", "587")), timeout=30) as s: + s.starttls() + _u, _p = _smtp_os.getenv("SMTP_USER", ""), _smtp_os.getenv("SMTP_PASS", "") + if _u and _p: + s.login(_u, _p) s.sendmail(msg["From"], [customer_email], msg.as_string()) def _send_status_email(self, order_number, service_name, entity_name, dot_number, customer_email): @@ -784,7 +789,12 @@ class StateTruckingHandler: msg["From"] = "noreply@performancewest.net" msg["To"] = customer_email - with smtplib.SMTP("localhost", 25) as s: + import os as _smtp_os + with smtplib.SMTP(_smtp_os.getenv("SMTP_HOST", "co.carrierone.com"), int(_smtp_os.getenv("SMTP_PORT", "587")), timeout=30) as s: + s.starttls() + _u, _p = _smtp_os.getenv("SMTP_USER", ""), _smtp_os.getenv("SMTP_PASS", "") + if _u and _p: + s.login(_u, _p) s.sendmail(msg["From"], [customer_email], msg.as_string()) LOG.info("[%s] Status email sent to %s", order_number, customer_email) diff --git a/scripts/workers/worker_email.py b/scripts/workers/worker_email.py new file mode 100644 index 0000000..58cdbc4 --- /dev/null +++ b/scripts/workers/worker_email.py @@ -0,0 +1,76 @@ +"""Shared SMTP send helper for worker services. + +Worker services historically called ``smtplib.SMTP("localhost", 25)`` directly, +which fails inside the workers container (no MTA on localhost:25) -- so every +authorization / signing-link / delivery email silently failed with +``[Errno 111] Connection refused`` and customers never received them. This routes +all worker email through the same authenticated SMTP relay the rest of the system +uses (Carbonio at co.carrierone.com:587 by default), configured via the standard +SMTP_* env vars. + +Usage: + from scripts.workers.worker_email import send_worker_email + ok = send_worker_email(to, subject, html, attachments=[(filename, bytes, mime)]) +""" +from __future__ import annotations + +import logging +import os +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.application import MIMEApplication + +LOG = logging.getLogger("workers.email") + +SMTP_HOST = os.getenv("SMTP_HOST", "co.carrierone.com") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "noreply@performancewest.net") +SMTP_PASS = os.getenv("SMTP_PASS", "") +SMTP_FROM = os.getenv("SMTP_FROM", "Performance West ") + + +def send_worker_email( + to: str, + subject: str, + html: str, + *, + text: str | None = None, + attachments: list[tuple[str, bytes, str]] | None = None, + cc: str | None = None, +) -> bool: + """Send an email via the configured SMTP relay. Returns True on success. + + attachments: list of (filename, content_bytes, subtype) e.g. ("x.pdf", b"...", "pdf"). + Never raises -- logs and returns False so callers stay non-fatal. + """ + try: + msg = MIMEMultipart("mixed") + msg["From"] = SMTP_FROM + msg["To"] = to + if cc: + msg["Cc"] = cc + msg["Subject"] = subject + + alt = MIMEMultipart("alternative") + if text: + alt.attach(MIMEText(text, "plain")) + alt.attach(MIMEText(html, "html")) + msg.attach(alt) + + for fname, content, subtype in (attachments or []): + part = MIMEApplication(content, _subtype=subtype) + part.add_header("Content-Disposition", "attachment", filename=fname) + msg.attach(part) + + recipients = [to] + ([cc] if cc else []) + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s: + s.starttls() + if SMTP_USER and SMTP_PASS: + s.login(SMTP_USER, SMTP_PASS) + s.sendmail(SMTP_FROM, recipients, msg.as_string()) + LOG.info("Worker email sent to %s (subject=%r)", to, subject[:60]) + return True + except Exception as exc: # noqa: BLE001 + LOG.warning("Worker email send failed to %s: %s", to, exc) + return False