From 02112facf5f75bf41f47f3ec72183b0e783e7c9d Mon Sep 17 00:00:00 2001 From: justin Date: Sun, 31 May 2026 20:30:09 -0500 Subject: [PATCH] capture client signature before filing signed DOT forms Forms that legally require the client's signature were not being captured correctly: - MCS-150 handler created a perjury e-sign record but then submitted to FMCSA anyway, before the client signed. Now it gates submission: request the signature, hold, and only file when handle_esign_completed re-dispatches with client_approved=True. - MCS-150 e-sign links were signed with JWT_SECRET/ADMIN_JWT_SECRET, but the portal verifies with CUSTOMER_JWT_SECRET, so every link returned "Invalid portal link." New shared dot_esign helper signs with CUSTOMER_JWT_SECRET. - carrier-closeout (final MCS-150 Out of Business) and entity-dissolution (Articles of Dissolution + no-lawsuits/liens/judgments attestation) captured no signature at all. Both now request a signed attestation before the workflow proceeds. - mc-authority / emergency-temporary-authority now get a correctly labeled OP-1 applicant certification instead of an "MCS-150" record. Also fixes a latent dispatcher bug: order["service_slug"] was never set, so handlers sharing a class fell back to their default SERVICE_SLUG. This made entity-dissolution run the carrier-closeout branch and mc-authority/etc. look like mcs150-update. Now the resolved slug is injected into order_data. Portal e-sign page now renders the document-specific certification text from metadata.perjury_text (so the dissolution no-liabilities attestation and OP-1 cert are actually shown to the signer), not just a generic perjury line. Co-Authored-By: Claude Opus 4.8 --- scripts/workers/job_server.py | 8 + scripts/workers/services/carrier_closeout.py | 35 ++- scripts/workers/services/dot_esign.py | 255 +++++++++++++++++++ scripts/workers/services/mcs150_update.py | 140 +++++----- site/public/portal/esign/index.html | 12 + 5 files changed, 366 insertions(+), 84 deletions(-) create mode 100644 scripts/workers/services/dot_esign.py diff --git a/scripts/workers/job_server.py b/scripts/workers/job_server.py index 4e73845..a781cb5 100644 --- a/scripts/workers/job_server.py +++ b/scripts/workers/job_server.py @@ -1141,6 +1141,14 @@ def handle_process_compliance_service(payload: dict) -> dict: if order_number: order["order_number"] = order_number + # Expose the resolved service slug to the handler. Several services share one + # handler class (e.g. mc-authority / usdot-reactivation -> MCS150UpdateHandler, + # entity-dissolution -> CarrierCloseoutHandler), and they branch on the slug. + # Without this, order_data.get("service_slug") is absent and handlers fall back + # to their default SERVICE_SLUG, mis-identifying the ordered service. + if service_slug: + order["service_slug"] = service_slug + # Inject eSign approval flags from payload (set by handle_esign_completed) if payload.get("client_approved"): order["client_approved"] = True diff --git a/scripts/workers/services/carrier_closeout.py b/scripts/workers/services/carrier_closeout.py index 975f745..3e43ea8 100644 --- a/scripts/workers/services/carrier_closeout.py +++ b/scripts/workers/services/carrier_closeout.py @@ -66,7 +66,40 @@ class CarrierCloseoutHandler: "Sequential wind-down steps:\n" + "\n".join(f" {i + 1}. {s}" for i, s in enumerate(steps)) ) - self._create_todo(order_number, intake, title, description, slug, priority="high") + + # Signature gate. Both close-out (final MCS-150 "Out of Business") and + # entity dissolution require the client's signed authorization/attestation + # before we file anything. On first run we request the signature and hold; + # handle_esign_completed re-dispatches with client_approved=True once signed. + from scripts.workers.services.dot_esign import requires_signature, request_dot_esign + client_approved = bool(order_data.get("client_approved")) + customer_email = order_data.get("customer_email", "") + + if requires_signature(slug) and not client_approved: + request_dot_esign( + order_number=order_number, + slug=slug, + entity_name=name, + customer_email=customer_email, + dot_number=dot if dot != "N/A" else "", + ) + LOG.info("[%s] Awaiting client signature before %s — holding workflow", order_number, slug) + self._create_todo( + order_number, intake, + f"{title} [AWAITING CLIENT SIGNATURE]", + description + f"\n\nStatus: AWAITING CLIENT SIGNATURE — link emailed to {customer_email}." + "\nWorkflow auto-resumes once the client signs.", + slug, priority="low", + ) + return [f"Awaiting signature: {title}"] + + # Signed (or re-dispatched after signing) — queue the actionable workflow. + self._create_todo( + order_number, intake, + f"{title} [SIGNED — READY TO FILE]", + description + "\n\nStatus: Client signed the authorization. Proceed with filing.", + slug, priority="high", + ) return [f"Close-out workflow queued: {title}"] def _create_todo(self, order_number, intake, title, description, slug, priority="normal"): diff --git a/scripts/workers/services/dot_esign.py b/scripts/workers/services/dot_esign.py new file mode 100644 index 0000000..62d8886 --- /dev/null +++ b/scripts/workers/services/dot_esign.py @@ -0,0 +1,255 @@ +"""DOT/FMCSA e-signature helper. + +Creates an `esign_records` row and emails the client a signing link, using the +local Postfix relay (localhost:25) like the other DOT status emails. This is the +DOT-side counterpart to telecom/esign_helper.py, but without the external +SMTP-auth dependency. + +CRITICAL: portal links are signed with CUSTOMER_JWT_SECRET — the same secret the +API portal middleware (api/src/middleware/portalAuth.ts) verifies with. Signing +with any other secret yields an "Invalid portal link." error for the client. + +Which DOT forms legally require the client's signature BEFORE we file: + - mcs150 forms (MCS-150 biennial / new USDOT / reactivation) — perjury cert + - operating authority (OP-1 / MC / emergency temporary authority) — applicant cert + - carrier close-out (final MCS-150 "Out of Business") — perjury cert + - entity dissolution (Articles of Dissolution) — no-liabilities attestation + +UCR, drug-&-alcohol consortium enrollment, audit prep, and BOC-3 (signed by the +process agent, not the carrier) do NOT file a client-signed federal form, so they +skip signature capture entirely. +""" +from __future__ import annotations + +import logging +import os + +LOG = logging.getLogger("workers.services.dot_esign") + +_MCS150_PERJURY = ( + "I certify under penalty of perjury that the information in this MCS-150 is " + "true and correct to the best of my knowledge and belief, and that I am " + "authorized to file it on behalf of the motor carrier. I understand that " + "making a false statement is punishable under 18 U.S.C. § 1001." +) +_OP1_CERT = ( + "I certify that I am authorized to apply for operating authority on behalf of " + "the applicant, that the information provided is true and correct, and that the " + "applicant will comply with all applicable FMCSA safety and insurance " + "requirements. I understand that a false statement is punishable under " + "18 U.S.C. § 1001." +) +_CLOSEOUT_CERT = ( + "I authorize Performance West Inc. to file a final MCS-150 marking this carrier " + "OUT OF BUSINESS and to deactivate the USDOT number and operating authority " + "listed above. I certify under penalty of perjury that the information is true " + "and correct and that I am authorized to wind down this motor carrier." +) +_DISSOLUTION_ATTEST = ( + "I attest that, to the best of my knowledge, this entity has NO outstanding " + "lawsuits, liens, judgments, or unsatisfied creditor claims. I authorize " + "Performance West Inc. to file Articles of Dissolution and final returns on the " + "entity's behalf, and I certify that I am authorized to dissolve this entity. I " + "understand that dissolving an entity with unresolved liabilities may expose its " + "members or officers to personal liability." +) + +# slug -> signing config. A slug absent from this map requires NO signature. +DOT_SIGNING: dict[str, dict] = { + "mcs150-update": { + "document_type": "mcs150", + "document_title": "MCS-150 Biennial Update — Certification Under Penalty of Perjury", + "perjury_text": _MCS150_PERJURY, + }, + "dot-registration": { + "document_type": "mcs150", + "document_title": "New USDOT Registration (MCS-150) — Certification Under Penalty of Perjury", + "perjury_text": _MCS150_PERJURY, + }, + "usdot-reactivation": { + "document_type": "mcs150", + "document_title": "USDOT Reactivation (MCS-150) — Certification Under Penalty of Perjury", + "perjury_text": _MCS150_PERJURY, + }, + "dot-full-compliance": { + "document_type": "mcs150", + "document_title": "DOT Full Compliance (MCS-150) — Certification Under Penalty of Perjury", + "perjury_text": _MCS150_PERJURY, + }, + "mc-authority": { + "document_type": "operating-authority", + "document_title": "Operating Authority Application (OP-1) — Applicant Certification", + "perjury_text": _OP1_CERT, + }, + "emergency-temporary-authority": { + "document_type": "operating-authority", + "document_title": "Emergency Temporary Authority Request — Applicant Certification", + "perjury_text": _OP1_CERT, + }, + "carrier-closeout": { + "document_type": "carrier-closeout", + "document_title": "Carrier Close-Out Authorization & Final MCS-150 (Out of Business)", + "perjury_text": _CLOSEOUT_CERT, + }, + "entity-dissolution": { + "document_type": "entity-dissolution", + "document_title": "Entity Dissolution Authorization & No-Liabilities Attestation", + "perjury_text": _DISSOLUTION_ATTEST, + }, +} + + +def requires_signature(slug: str) -> bool: + """True if the DOT service files a form that needs the client's signature.""" + return slug in DOT_SIGNING + + +def request_dot_esign( + order_number: str, + slug: str, + entity_name: str, + customer_email: str, + dot_number: str = "", + document_minio_key: str = "", + extra_metadata: dict | None = None, + expires_days: int = 7, +) -> int | None: + """Create a pending esign record for a DOT form and email the signing link. + + Idempotent per (order_number, document_type): re-running will not duplicate a + pending/signed record. Returns the esign_records.id, or None on failure. + """ + cfg = DOT_SIGNING.get(slug) + if not cfg: + return None # this service does not require a signature + if not customer_email: + LOG.warning("[%s] No customer email — cannot request signature", order_number) + return None + + import json + + document_type = cfg["document_type"] + document_title = cfg["document_title"] + perjury_text = cfg["perjury_text"] + + metadata = { + "dot_number": dot_number, + "service_slug": slug, + "perjury_text": perjury_text, + } + if extra_metadata: + metadata.update(extra_metadata) + + # 1. Upsert the pending record + esign_id = None + try: + import psycopg2 + + conn = psycopg2.connect(os.environ.get("DATABASE_URL", "")) + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO esign_records ( + order_number, document_type, document_title, entity_name, + document_minio_key, document_metadata, + requires_perjury, status, expires_at + ) VALUES (%s, %s, %s, %s, %s, %s, TRUE, 'pending', + NOW() + (%s || ' days')::interval) + ON CONFLICT (order_number, document_type) + WHERE status IN ('pending', 'signed') + DO UPDATE SET + document_title = EXCLUDED.document_title, + entity_name = EXCLUDED.entity_name, + document_minio_key = EXCLUDED.document_minio_key, + document_metadata = EXCLUDED.document_metadata, + expires_at = EXCLUDED.expires_at, + updated_at = NOW() + RETURNING id + """, + ( + order_number, document_type, document_title, entity_name, + document_minio_key, json.dumps(metadata), str(expires_days), + ), + ) + row = cur.fetchone() + esign_id = row[0] if row else None + conn.commit() + conn.close() + except Exception as exc: + LOG.error("[%s] Failed to create esign record (%s): %s", order_number, document_type, exc) + return None + + # 2. Email the signing link (signed with CUSTOMER_JWT_SECRET to match the portal) + try: + _send_signing_email( + order_number=order_number, + document_type=document_type, + document_title=document_title, + entity_name=entity_name, + dot_number=dot_number, + customer_email=customer_email, + expires_days=expires_days, + ) + LOG.info("[%s] Signing link sent to %s (%s)", order_number, customer_email, document_type) + except Exception as exc: + LOG.warning("[%s] Could not send signing email (record exists): %s", order_number, exc) + + return esign_id + + +def _send_signing_email( + order_number: str, + document_type: str, + document_title: str, + entity_name: str, + dot_number: str, + customer_email: str, + expires_days: int, +) -> None: + import smtplib + from email.mime.text import MIMEText + + try: + import jwt as pyjwt + except ImportError: # pragma: no cover + import PyJWT as pyjwt # type: ignore + + secret = os.environ.get("CUSTOMER_JWT_SECRET", "changeme_long_random_string") + domain = os.environ.get("DOMAIN", "performancewest.net") + + from datetime import datetime, timedelta, timezone + + token = pyjwt.encode( + { + "order_id": order_number, + "order_type": document_type, + "email": customer_email, + "exp": datetime.now(timezone.utc) + timedelta(days=expires_days), + }, + secret, + algorithm="HS256", + ) + sign_url = f"https://{domain}/portal/esign?token={token}" + + dot_line = f" (DOT# {dot_number})" if dot_number else "" + body = ( + f"Hi,\n\n" + f"Your {document_title} for {entity_name}{dot_line} has been prepared and " + f"is ready for your signature.\n\n" + f"Federal law requires your certification before we can submit this filing. " + f"Please review and sign here:\n{sign_url}\n\n" + f"This link expires in {expires_days} days.\n\n" + f"Once you sign, we file with the appropriate agency and send you " + f"confirmation.\n\n" + f"Order: {order_number}\n" + f"Questions? Call (888) 411-0383.\n\n" + f"Performance West Inc.\n" + ) + + msg = MIMEText(body) + msg["Subject"] = f"Action Required: Sign Your {document_title} — {entity_name}" + msg["From"] = "noreply@performancewest.net" + msg["To"] = customer_email + + with smtplib.SMTP("localhost", 25, timeout=30) as s: + s.sendmail(msg["From"], [customer_email], msg.as_string()) diff --git a/scripts/workers/services/mcs150_update.py b/scripts/workers/services/mcs150_update.py index bd84eaf..228d95d 100644 --- a/scripts/workers/services/mcs150_update.py +++ b/scripts/workers/services/mcs150_update.py @@ -74,10 +74,18 @@ class MCS150UpdateHandler: if isinstance(intake, str): intake = json.loads(intake) + slug = order_data.get("service_slug", self.SERVICE_SLUG) dot_number = intake.get("dot_number", "") entity_name = intake.get("entity_name", order_data.get("customer_name", "")) customer_email = order_data.get("customer_email", "") + # The client signs the perjury certification before we file. When they + # sign, handle_esign_completed re-dispatches this handler with + # client_approved=True so we proceed past the signing checkpoint. + from scripts.workers.services.dot_esign import requires_signature, request_dot_esign + needs_signature = requires_signature(slug) + client_approved = bool(order_data.get("client_approved")) + # Validate required fields if not dot_number: LOG.error("[%s] Missing DOT number in intake data", order_number) @@ -140,50 +148,27 @@ class MCS150UpdateHandler: except Exception as exc: LOG.warning("[%s] Could not retrieve photo ID: %s", order_number, exc) - # Step 4: Create e-sign record for perjury declaration - # Customer must sign before we submit to FMCSA - if pdf_path and minio_path: - try: - import psycopg2 - conn = psycopg2.connect(os.environ.get("DATABASE_URL", "")) - cur = conn.cursor() - cur.execute(""" - INSERT INTO esign_records ( - order_number, document_type, document_title, entity_name, - document_minio_key, document_metadata, - requires_perjury, status, expires_at - ) VALUES (%s, %s, %s, %s, %s, %s, TRUE, 'pending', NOW() + interval '7 days') - ON CONFLICT (order_number, document_type) - WHERE status IN ('pending', 'signed') DO NOTHING - """, ( - order_number, - "mcs150", - "MCS-150 Biennial Update — Certification Under Penalty of Perjury", - entity_name, - minio_path, - json.dumps({ - "dot_number": dot_number, - "form_type": "mcs150", - "perjury_text": ( - "I hereby certify under penalty of perjury that the information " - "contained in this MCS-150 form is true and correct to the best " - "of my knowledge and belief. I understand that making a false " - "statement is punishable under 18 U.S.C. § 1001." - ), - }), - )) - conn.commit() - conn.close() - LOG.info("[%s] E-sign record created for MCS-150 perjury declaration", order_number) + # Step 4: SIGNATURE GATE. If this form needs the client's signed + # certification and they haven't signed yet, request the signature and + # STOP before filing. We never submit a perjury certification to FMCSA + # until the client has actually signed it. + if needs_signature and not client_approved: + request_dot_esign( + order_number=order_number, + slug=slug, + entity_name=entity_name, + customer_email=customer_email, + dot_number=dot_number, + document_minio_key=minio_path or "", + ) + LOG.info("[%s] Awaiting client signature before filing %s — not submitting yet", + order_number, slug) + self._create_pending_signature_todo( + order_number, entity_name, dot_number, slug, minio_path, customer_email) + return [minio_path] if minio_path else [] - # Send e-sign link to customer - self._send_esign_email(order_number, entity_name, dot_number, customer_email) - - # NOTE: The filing will be triggered by the esign_completed handler - # after the customer signs. For now, also proceed with submission - # since many orders may not have esign set up yet. - except Exception as exc: - LOG.error("[%s] E-sign setup failed (proceeding anyway): %s", order_number, exc) + # Past this point: either no signature is required for this service, or + # the client has signed (re-dispatched with client_approved=True). # Step 5: Submit electronically (3x web → fax fallback) # GUARD: Skip actual submission in dev/test environments @@ -320,48 +305,37 @@ class MCS150UpdateHandler: except Exception as exc: return f"Could not check: {exc}" - def _send_esign_email(self, order_number, entity_name, dot_number, customer_email): - """Send e-sign link for MCS-150 perjury declaration.""" - if not customer_email: - return + def _create_pending_signature_todo(self, order_number, entity_name, dot_number, + slug, minio_path, customer_email): + """Low-priority admin todo noting we're waiting on the client's signature.""" try: - import smtplib - import jwt - from email.mime.text import MIMEText - - secret = os.environ.get("JWT_SECRET", os.environ.get("ADMIN_JWT_SECRET", "")) - token = jwt.encode( - {"order_id": order_number, "order_type": "mcs150", "email": customer_email}, - secret, algorithm="HS256", - ) - domain = os.environ.get("DOMAIN", "performancewest.net") - esign_url = f"https://{domain}/portal/esign?token={token}" - - body = ( - f"Hi,\n\n" - f"Your MCS-150 Biennial Update for {entity_name} (DOT# {dot_number}) " - f"has been prepared and is ready for your signature.\n\n" - f"Federal law requires your certification under penalty of perjury " - f"before we can submit this form to FMCSA.\n\n" - f"Please review and sign here:\n{esign_url}\n\n" - f"This link expires in 7 days.\n\n" - f"Once you sign, we will submit the form to FMCSA electronically " - f"and provide you with a Certificate of Filing.\n\n" - f"Questions? Call (888) 411-0383.\n\n" - f"Performance West Inc.\n" - ) - - msg = MIMEText(body) - msg["Subject"] = f"Action Required: Sign Your MCS-150 — {entity_name} (DOT {dot_number})" - msg["From"] = "noreply@performancewest.net" - msg["To"] = customer_email - - with smtplib.SMTP("localhost", 25) as s: - s.sendmail(msg["From"], [customer_email], msg.as_string()) - - LOG.info("[%s] E-sign email sent to %s", order_number, customer_email) + import psycopg2 + conn = psycopg2.connect(os.environ.get("DATABASE_URL", "")) + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO admin_todos ( + title, category, priority, order_number, service_slug, + description, data, status + ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending') + """, + ( + f"Awaiting client signature — {entity_name} (DOT {dot_number})", + "filing", "low", order_number, slug, + f"{slug} for {entity_name} (DOT {dot_number}).\n" + f"Status: AWAITING CLIENT SIGNATURE before filing.\n" + f"Signing link emailed to {customer_email}.\n" + f"PDF: {minio_path or 'not generated'}\n" + f"Filing auto-resumes once the client signs.", + json.dumps({"order_number": order_number, "dot_number": dot_number, + "entity_name": entity_name, "awaiting_signature": True}), + ), + ) + conn.commit() + conn.close() + LOG.info("[%s] Pending-signature todo created", order_number) except Exception as exc: - LOG.warning("[%s] Failed to send e-sign email: %s", order_number, exc) + LOG.warning("[%s] Failed to create pending-signature todo: %s", order_number, exc) def _send_status_email(self, order_number, entity_name, dot_number, customer_email): """Send client an email that we're working on their update.""" diff --git a/site/public/portal/esign/index.html b/site/public/portal/esign/index.html index 73e9a29..f7e7f1c 100644 --- a/site/public/portal/esign/index.html +++ b/site/public/portal/esign/index.html @@ -101,6 +101,8 @@ body{font-family:'Inter',system-ui,sans-serif;color:#1f2937;background:#f1f5f9;l

Step 3 — Confirm & Submit

+ + @@ -174,6 +176,16 @@ body{font-family:'Inter',system-ui,sans-serif;color:#1f2937;background:#f1f5f9;l document.getElementById("entity-confirm").textContent = data.entity_name; document.getElementById("perjury-date").textContent = new Date().toLocaleDateString("en-US", {year:"numeric",month:"long",day:"numeric"}); + // Show the specific certification/attestation language for this document + // (e.g. the dissolution "no outstanding lawsuits, liens, or judgments" + // attestation, or the OP-1 / MCS-150 certification). + var attestText = data.metadata && data.metadata.perjury_text; + if (attestText) { + var ab = document.getElementById("attestation-box"); + ab.textContent = attestText; + ab.classList.remove("hidden"); + } + if (data.requires_perjury) { document.getElementById("perjury-box").classList.remove("hidden"); }