trucking: stamp e-signature exactly on form signature lines + state authorization gate
Capture-to-form signature placement so the customer's drawn or typed signature lands right on the signature rule of the actual form, not in a sidecar page. - migration 085: esign_records.signature_anchors (JSONB exact PDF coords, lower-left origin, points) + signed_document_minio_key - signature_stamper.py: signature_box() anchors; anchors_from_acroform() pulls the signature field /Rect from a real AcroForm (e.g. MCS-150 certifySignature); stamp_signature() overlays PNG (auto-trimmed so ink rests on the rule) or typed name, scaled to actual page size - state_trucking_authorization.py: renders the Limited Authorization to File PDF and returns (pdf_bytes, anchors) - esign_stamp.py: stamp_esign_document() downloads unsigned PDF, stamps, uploads _signed.pdf, sets signed_document_minio_key (idempotent) - dot_esign.py: extract certifySignature anchor for MCS-150/closeout forms so the federal perjury cert is signed on the line - state_trucking.py: authorization gate — first run emails signing link and PAUSES; resumes with client_approved after signing - job_server handle_esign_completed: stamp then re-dispatch - tests: test_signature_placement.py (custom form), and test_mcs150_signature_placement.py (official AcroForm) both assert the signature lands inside the recorded signature box (verified visually)
This commit is contained in:
parent
345979ed00
commit
7ed06780bb
9 changed files with 1322 additions and 5 deletions
|
|
@ -233,6 +233,46 @@ class StateTruckingHandler:
|
|||
base_state = intake.get("base_state", intake.get("phy_state", ""))
|
||||
operating_states = intake.get("operating_states", [])
|
||||
|
||||
# ── Authorization gate ───────────────────────────────────────────
|
||||
# Every state portal filing legally requires the customer's signed
|
||||
# "Limited Authorization to File State Motor Carrier Compliance
|
||||
# Documents" before we touch a state portal/tax system. On the first
|
||||
# run we generate that authorization, email the signing link, and
|
||||
# PAUSE. The pipeline resumes here with client_approved=True once the
|
||||
# customer signs (handle_esign_completed re-dispatches us).
|
||||
client_approved = bool(order_data.get("client_approved"))
|
||||
signed_auth_key = None
|
||||
if not client_approved:
|
||||
requested = self._request_authorization(
|
||||
order_number=order_number,
|
||||
service_slug=service_slug,
|
||||
service_name=service_name,
|
||||
entity_name=entity_name,
|
||||
customer_email=customer_email,
|
||||
dot_number=dot_number,
|
||||
mc_number=str(intake.get("mc_number", "")),
|
||||
fein_last4=str(intake.get("fein_last4", "")),
|
||||
base_state=base_state,
|
||||
operating_states=operating_states,
|
||||
signer_name=str(intake.get("signer_name", order_data.get("customer_name", ""))),
|
||||
signer_title=str(intake.get("signer_title", "")),
|
||||
)
|
||||
if requested:
|
||||
LOG.info(
|
||||
"[%s] Authorization requested — pipeline PAUSED pending signature",
|
||||
order_number,
|
||||
)
|
||||
return []
|
||||
# If we could not request a signature (e.g. no email), fall through
|
||||
# and create the admin todo so ops can chase the authorization.
|
||||
LOG.warning(
|
||||
"[%s] Could not request authorization e-sign — proceeding to admin todo",
|
||||
order_number,
|
||||
)
|
||||
else:
|
||||
signed_auth_key = self._signed_authorization_key(order_number)
|
||||
LOG.info("[%s] Authorization signed (%s) — proceeding to filing", order_number, signed_auth_key)
|
||||
|
||||
# Slug-specific intake fields collected by StateTruckingIntakeStep.
|
||||
intake_summary = self._summarize_intake(service_slug, intake)
|
||||
|
||||
|
|
@ -263,6 +303,7 @@ class StateTruckingHandler:
|
|||
"intake_summary": intake_summary,
|
||||
"state_requirements": state_reqs,
|
||||
"steps": steps,
|
||||
"signed_authorization_minio_key": signed_auth_key,
|
||||
}
|
||||
|
||||
# Render the slug-specific intake fields into the description.
|
||||
|
|
@ -431,10 +472,236 @@ class StateTruckingHandler:
|
|||
return {"agency": reqs[keys[0]], "url": reqs.get(keys[1], "")}
|
||||
return None
|
||||
|
||||
# ── Authorization (signed "Limited Authorization to File") ──────────
|
||||
|
||||
# document_type used in esign_records for the state-trucking authorization.
|
||||
AUTH_DOCUMENT_TYPE = "state-trucking-authorization"
|
||||
|
||||
def _request_authorization(
|
||||
self,
|
||||
*,
|
||||
order_number: str,
|
||||
service_slug: str,
|
||||
service_name: str,
|
||||
entity_name: str,
|
||||
customer_email: str,
|
||||
dot_number: str = "",
|
||||
mc_number: str = "",
|
||||
fein_last4: str = "",
|
||||
base_state: str = "",
|
||||
operating_states: list | None = None,
|
||||
signer_name: str = "",
|
||||
signer_title: str = "",
|
||||
) -> bool:
|
||||
"""Generate + upload the authorization PDF and email the signing link.
|
||||
|
||||
Returns True if a signing request was created (pipeline should pause),
|
||||
False if it could not be requested (e.g. no customer email) so the
|
||||
caller can fall back to an admin todo.
|
||||
"""
|
||||
if not customer_email:
|
||||
return False
|
||||
|
||||
# If a record already exists (pending or signed), don't duplicate.
|
||||
existing = self._authorization_status(order_number)
|
||||
if existing == "signed":
|
||||
return False # already signed — proceed to filing
|
||||
# (pending records are upserted/refreshed below)
|
||||
|
||||
try:
|
||||
from .state_trucking_authorization import build_state_trucking_authorization
|
||||
from .signature_stamper import signature_box # noqa: F401 (ensures module import)
|
||||
except Exception as exc:
|
||||
LOG.error("[%s] Authorization generator unavailable: %s", order_number, exc)
|
||||
return False
|
||||
|
||||
try:
|
||||
pdf_bytes, anchors = build_state_trucking_authorization(
|
||||
order_number=order_number,
|
||||
entity_name=entity_name,
|
||||
service_name=service_name,
|
||||
dot_number=dot_number,
|
||||
mc_number=mc_number,
|
||||
fein_last4=fein_last4,
|
||||
base_state=base_state,
|
||||
operating_states=operating_states or [],
|
||||
signer_name=signer_name,
|
||||
signer_title=signer_title,
|
||||
)
|
||||
except Exception as exc:
|
||||
LOG.error("[%s] Failed to render authorization PDF: %s", order_number, exc)
|
||||
return False
|
||||
|
||||
# Upload the unsigned authorization to MinIO.
|
||||
document_key = f"compliance/{order_number}/state_trucking_authorization.pdf"
|
||||
try:
|
||||
import tempfile
|
||||
try:
|
||||
from scripts.document_gen.minio_client import MinioStorage
|
||||
except ImportError:
|
||||
from document_gen.minio_client import MinioStorage # type: ignore
|
||||
storage = MinioStorage()
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as tf:
|
||||
tf.write(pdf_bytes)
|
||||
tf.flush()
|
||||
storage.upload(tf.name, document_key, content_type="application/pdf")
|
||||
except Exception as exc:
|
||||
LOG.error("[%s] Failed to upload authorization PDF: %s", order_number, exc)
|
||||
return False
|
||||
|
||||
# Create the esign record (with the signature anchors so the stamper can
|
||||
# place the signature on the line) and email the signing link.
|
||||
try:
|
||||
import json
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO esign_records (
|
||||
order_number, document_type, document_title, entity_name,
|
||||
document_minio_key, document_metadata, signature_anchors,
|
||||
requires_perjury, status, expires_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, FALSE, 'pending',
|
||||
NOW() + INTERVAL '14 days')
|
||||
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,
|
||||
signature_anchors = EXCLUDED.signature_anchors,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
order_number,
|
||||
self.AUTH_DOCUMENT_TYPE,
|
||||
f"Authorization to File: {service_name}",
|
||||
entity_name,
|
||||
document_key,
|
||||
json.dumps({"service_slug": service_slug, "dot_number": dot_number}),
|
||||
json.dumps(anchors),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
LOG.error("[%s] Failed to create authorization esign record: %s", order_number, exc)
|
||||
return False
|
||||
|
||||
# Email the signing link (JWT signed with CUSTOMER_JWT_SECRET).
|
||||
try:
|
||||
self._send_authorization_email(
|
||||
order_number=order_number,
|
||||
service_name=service_name,
|
||||
entity_name=entity_name,
|
||||
customer_email=customer_email,
|
||||
)
|
||||
except Exception as exc:
|
||||
LOG.warning("[%s] Authorization email failed (record exists): %s", order_number, exc)
|
||||
|
||||
return True
|
||||
|
||||
def _authorization_status(self, order_number: str) -> str | None:
|
||||
"""Return the current authorization esign status: 'signed', 'pending', or None."""
|
||||
try:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT status FROM esign_records
|
||||
WHERE order_number = %s AND document_type = %s
|
||||
AND status IN ('pending', 'signed')
|
||||
ORDER BY created_at DESC LIMIT 1""",
|
||||
(order_number, self.AUTH_DOCUMENT_TYPE),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _signed_authorization_key(self, order_number: str) -> str | None:
|
||||
"""Return the MinIO key of the stamped, signed authorization PDF if available."""
|
||||
try:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT signed_document_minio_key, document_minio_key
|
||||
FROM esign_records
|
||||
WHERE order_number = %s AND document_type = %s
|
||||
AND status = 'signed'
|
||||
ORDER BY signed_at DESC NULLS LAST LIMIT 1""",
|
||||
(order_number, self.AUTH_DOCUMENT_TYPE),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return row[0] or row[1]
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _send_authorization_email(
|
||||
self, *, order_number, service_name, entity_name, customer_email
|
||||
):
|
||||
"""Email the customer a link to review and sign the authorization."""
|
||||
import os as _os
|
||||
import smtplib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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")
|
||||
token = pyjwt.encode(
|
||||
{
|
||||
"order_id": order_number,
|
||||
"order_type": self.AUTH_DOCUMENT_TYPE,
|
||||
"email": customer_email,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(days=14),
|
||||
},
|
||||
secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
sign_url = f"https://{domain}/portal/esign?token={token}"
|
||||
body = (
|
||||
f"Hi,\n\n"
|
||||
f"To complete your {service_name} order for {entity_name}, we need your "
|
||||
f"signed authorization before we can file with the state on your behalf.\n\n"
|
||||
f"Please review and sign here:\n{sign_url}\n\n"
|
||||
f"This authorization lets Performance West prepare and submit your state "
|
||||
f"motor carrier filing, communicate with the agency, and remit government "
|
||||
f"fees you provide. Government fees, taxes, card processing fees, decals, "
|
||||
f"permits, bonds, and insurance filing costs are pass-through charges and "
|
||||
f"may be billed separately if not known at checkout.\n\n"
|
||||
f"This link expires in 14 days.\n\n"
|
||||
f"Order: {order_number}\n"
|
||||
f"Questions? Call (888) 411-0383.\n\n"
|
||||
f"Performance West Inc.\nDOT / State Motor Carrier Compliance\n"
|
||||
)
|
||||
msg = MIMEText(body)
|
||||
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:
|
||||
s.sendmail(msg["From"], [customer_email], msg.as_string())
|
||||
|
||||
def _send_status_email(self, order_number, service_name, entity_name, dot_number, customer_email):
|
||||
"""Send client a status email."""
|
||||
if not customer_email:
|
||||
return
|
||||
try:
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue