new-site/scripts/workers/services/mcs150_update.py
justin 28b1af341d Wire fulfillment alerts to Telegram + surface order progress in portal + even out ERPNext sync
Telegram notifications:
- Add shared scripts/workers/telegram_notify.py (send_telegram, notify_fulfillment_todo,
  create_admin_todo) so every worker alerts the operator the same way; fire-and-forget.
- Fire notify_fulfillment_todo after each admin_todos insert across all 8 service
  handlers (9 sites) so no fulfillment task waits unseen.
  (Orders + quotes + tickets already notified via checkout/quotes/tickets routes.)

Client portal order progress:
- order-timeline: derive real per-step status from live signals (payment paid,
  e-signature signed, fulfillment_status) instead of a static template; add
  current_step to the response.
- Extract pure applyLiveStatus into order-timeline-status.ts (DB-free) + unit test
  (api/test/test_timeline_status.ts, 8 cases).
- portal /me now returns compliance_orders.fulfillment_status.
- Dashboard renders a client-safe Progress badge (In progress / Action needed /
  Filed-awaiting-confirmation / Completed); batches show the most actionable status.
  No back-office mechanics exposed.

ERPNext sync parity:
- Create a Sales Order for formation and fcc_carrier_registration orders (previously
  only canada_crtc + compliance synced); write erpnext_sales_order back to each table.
  Non-blocking, matches existing pattern.

Verified: API tsc clean, timeline unit tests 8/8, Astro build 58 pages,
cms10114/ink/paper_batch Python tests still green, no mechanics leaks.
2026-06-07 03:17:46 -05:00

399 lines
17 KiB
Python

"""
MCS-150 Biennial Update Service Handler.
The MCS-150 is filed through the FMCSA Portal (portal.fmcsa.dot.gov) using
Login.gov credentials + MFA. Since we can't automate through MFA, this is
an admin-assisted service:
1. Client orders MCS-150 update
2. We collect their updated info via intake form
3. Admin logs into FMCSA Portal with client's credentials (provided by client)
OR we prepare the data and walk the client through filing via screen share
4. We verify the update was accepted
5. We send confirmation with updated company snapshot
Service slug: mcs150-update
Price: $79
Gov fee: $0
Intake data needed:
- DOT number
- Legal name (confirm/update)
- DBA name (confirm/update)
- Principal business address
- Mailing address
- Phone number
- Email address
- Number of power units
- Number of drivers
- Operation type (interstate/intrastate)
- Carrier operation (authorized for hire, exempt for hire, private)
- Cargo types
- Hazmat (Y/N)
- Annual mileage + year
- FMCSA Portal login credentials (Login.gov email + password)
OR "I need help creating my Login.gov account"
Filing approach:
Option A: Client provides Login.gov credentials → admin files directly
Option B: Guided filing via screen share (Zoom/Teams) → $29 upcharge
Option C: We prepare a pre-filled PDF → client uploads themselves (cheapest)
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from scripts.workers.telegram_notify import notify_fulfillment_todo
LOG = logging.getLogger("workers.services.mcs150_update")
class MCS150UpdateHandler:
"""Handle MCS-150 biennial update orders."""
SERVICE_SLUG = "mcs150-update"
SERVICE_NAME = "MCS-150 Biennial Update"
async def process(self, order_data: dict) -> list[str]:
"""Entry point called by job_server. Delegates to handle()."""
order_number = order_data.get("order_number", order_data.get("name", ""))
return self.handle(order_data, order_number)
def handle(self, order_data: dict, order_number: str) -> list[str]:
"""
Process an MCS-150 update order.
Since FMCSA Portal requires Login.gov MFA, this creates an admin
todo rather than automating the filing directly.
"""
LOG.info("[%s] Processing MCS-150 update order", order_number)
intake = order_data.get("intake_data") or {}
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)
return []
# Check current MCS-150 status via FMCSA API
mcs150_status = self._check_current_status(dot_number)
# Step 1: Fill the official MCS-150 PDF
pdf_path = None
try:
from scripts.document_gen.templates.mcs150_pdf_filler import fill_mcs150
pdf_path = fill_mcs150(intake, order_number=order_number)
LOG.info("[%s] Filled MCS-150 PDF: %s", order_number, pdf_path)
except Exception as exc:
LOG.error("[%s] PDF fill failed: %s", order_number, exc)
# Step 2: Upload PDF to MinIO for storage
minio_path = None
pdf_url = None
if pdf_path:
try:
from minio import Minio
mc = Minio(
f"{os.environ.get('MINIO_ENDPOINT', 'minio')}:{os.environ.get('MINIO_PORT', '9000')}",
access_key=os.environ.get("MINIO_ACCESS_KEY", ""),
secret_key=os.environ.get("MINIO_SECRET_KEY", ""),
secure=False,
)
bucket = os.environ.get("MINIO_BUCKET", "performancewest")
minio_path = f"filings/mcs150/{order_number}/{os.path.basename(pdf_path)}"
mc.fobj_put(bucket, minio_path, open(pdf_path, "rb"),
length=os.path.getsize(pdf_path),
content_type="application/pdf")
# Generate presigned URL for fax/web submission
from datetime import timedelta
pdf_url = mc.presigned_get_object(bucket, minio_path, expires=timedelta(hours=2))
LOG.info("[%s] PDF uploaded to MinIO: %s", order_number, minio_path)
except Exception as exc:
LOG.error("[%s] MinIO upload failed: %s", order_number, exc)
# Step 3: Check for photo ID
photo_id_path = None
if intake.get("photo_id_uploaded"):
try:
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
cur = conn.cursor()
cur.execute(
"SELECT minio_paths FROM id_upload_tokens WHERE order_number = %s AND front_uploaded = TRUE ORDER BY created_at DESC LIMIT 1",
(order_number,),
)
row = cur.fetchone()
conn.close()
if row and row[0]:
paths = json.loads(row[0]) if isinstance(row[0], str) else row[0]
if paths.get("front"):
photo_id_path = paths["front"]
LOG.info("[%s] Photo ID found: %s", order_number, photo_id_path)
except Exception as exc:
LOG.warning("[%s] Could not retrieve photo ID: %s", order_number, exc)
# 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 []
# 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
is_production = os.environ.get("NODE_ENV") == "production" or os.environ.get("ENV") == "production"
filing_result = None
if pdf_path and not is_production:
LOG.info("[%s] DEV MODE — skipping actual FMCSA submission", order_number)
filing_result = {
"success": True,
"method": "dev_skip",
"attested_pdf": None,
"submitted_at": datetime.now().isoformat(),
"screenshot_path": None,
"fax_log_id": None,
"error": None,
}
elif pdf_path:
try:
import asyncio
from scripts.workers.fax_sender import submit_filing
loop = asyncio.new_event_loop()
filing_result = loop.run_until_complete(submit_filing(
original_pdf_path=pdf_path,
pdf_url=pdf_url or "",
photo_id_path=photo_id_path,
order_number=order_number,
dot_number=dot_number,
mc_number=intake.get("mc_number", ""),
entity_name=entity_name,
document_type="MCS-150 Biennial Update",
web_retries=3,
web_retry_interval=600,
))
loop.close()
if filing_result and filing_result.get("success"):
LOG.info("[%s] Filing submitted via %s", order_number, filing_result.get("method"))
else:
LOG.warning("[%s] Electronic filing failed: %s", order_number,
filing_result.get("error") if filing_result else "unknown")
except Exception as exc:
LOG.error("[%s] Filing submission error: %s", order_number, exc)
# Step 5: Update order status in database
try:
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
cur = conn.cursor()
status_data = {
"mcs150_status": mcs150_status,
"pdf_minio_path": minio_path,
"filing_method": filing_result.get("method") if filing_result else None,
"filing_success": filing_result.get("success") if filing_result else False,
"fax_log_id": filing_result.get("fax_log_id") if filing_result else None,
"screenshot_path": filing_result.get("screenshot_path") if filing_result else None,
"submitted_at": filing_result.get("submitted_at") if filing_result else None,
"attested_pdf": filing_result.get("attested_pdf") if filing_result else None,
}
cur.execute("""
UPDATE compliance_orders SET intake_data = jsonb_set(
COALESCE(intake_data, '{}'::jsonb),
'{filing_status}', %s::jsonb
) WHERE order_number = %s
""", (json.dumps(status_data), order_number))
conn.commit()
conn.close()
except Exception as exc:
LOG.error("[%s] DB update failed: %s", order_number, exc)
# Step 6: Create admin todo (for manual verification + customer delivery)
try:
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
filed_method = filing_result.get("method", "pending") if filing_result else "pending"
filed_ok = filing_result.get("success", False) if filing_result else False
todo_title = f"MCS-150 {'Filed' if filed_ok else 'Review'}{entity_name} (DOT {dot_number})"
todo_priority = "low" if filed_ok else "normal"
todo_description = (
f"MCS-150 for {entity_name} (DOT {dot_number}).\n"
f"Filing method: {filed_method}\n"
f"Status: {'SUBMITTED — verify in 5-10 days' if filed_ok else 'NEEDS MANUAL FILING'}\n"
f"Customer: {customer_email}\n"
f"PDF: {minio_path or 'not generated'}"
)
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')
""", (
todo_title,
"filing",
todo_priority,
order_number,
self.SERVICE_SLUG,
todo_description,
json.dumps({
"order_number": order_number,
"dot_number": dot_number,
"entity_name": entity_name,
"filing_result": filing_result,
}),
))
conn.commit()
notify_fulfillment_todo(
title=todo_title,
order_number=order_number,
service_slug=self.SERVICE_SLUG,
priority=todo_priority,
description=todo_description,
)
conn.close()
except Exception as exc:
LOG.error("[%s] Failed to create admin todo: %s", order_number, exc)
# Step 7: Send client status email
self._send_status_email(order_number, entity_name, dot_number, customer_email)
return [minio_path] if minio_path else []
def _check_current_status(self, dot_number: str) -> str:
"""Check current MCS-150 status via FMCSA API."""
try:
import urllib.request
api_key = os.environ.get("FMCSA_API_KEY", "")
if not api_key:
return "API key not configured"
url = f"https://mobile.fmcsa.dot.gov/qc/services/carriers/{dot_number}?webKey={api_key}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
carrier = data.get("content", {}).get("carrier", {})
outdated = carrier.get("mcs150Outdated", "?")
status = carrier.get("statusCode", "?")
allowed = carrier.get("allowedToOperate", "?")
return (
f"Status: {status}, Allowed: {allowed}, "
f"MCS-150 Outdated: {outdated}"
)
except Exception as exc:
return f"Could not check: {exc}"
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 psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
todo_title = f"Awaiting client signature — {entity_name} (DOT {dot_number})"
todo_description = (
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."
)
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')
""",
(
todo_title,
"filing", "low", order_number, slug,
todo_description,
json.dumps({"order_number": order_number, "dot_number": dot_number,
"entity_name": entity_name, "awaiting_signature": True}),
),
)
conn.commit()
notify_fulfillment_todo(
title=todo_title,
order_number=order_number,
service_slug=slug,
priority="low",
description=todo_description,
)
conn.close()
LOG.info("[%s] Pending-signature todo created", order_number)
except Exception as 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."""
if not customer_email:
return
try:
import smtplib
from email.mime.text import MIMEText
body = (
f"Hi,\n\n"
f"We've received your MCS-150 biennial update order for "
f"{entity_name} (DOT# {dot_number}).\n\n"
f"Order: {order_number}\n\n"
f"Our team will review your intake information and complete "
f"the filing within 1-2 business days. We'll send you a "
f"confirmation with your updated company snapshot once it's done.\n\n"
f"If we need your FMCSA Portal login credentials, we'll reach "
f"out via a separate secure email.\n\n"
f"Questions? Reply to this email or call (888) 411-0383.\n\n"
f"Performance West Inc.\n"
f"DOT Compliance Services\n"
)
msg = MIMEText(body)
msg["Subject"] = f"MCS-150 Update In Progress — {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] Status email sent to %s", order_number, customer_email)
except Exception as exc:
LOG.warning("[%s] Failed to send status email: %s", order_number, exc)