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.
This commit is contained in:
parent
220f301453
commit
68e6b60951
9 changed files with 229 additions and 9 deletions
|
|
@ -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<string, unknown>[],
|
||||
paymentMethod: string,
|
||||
): Promise<void> {
|
||||
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<string, any>) => {
|
||||
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<string, unknown>,
|
||||
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 ─────────────────────────
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
76
scripts/workers/worker_email.py
Normal file
76
scripts/workers/worker_email.py
Normal file
|
|
@ -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 <noreply@performancewest.net>")
|
||||
|
||||
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue