mcs150: enrich intake from FMCSA carrier census before PDF fill

The MCS-150 biennial update re-confirms the carrier's existing FMCSA
record. Previously the PDF filler only had whatever the intake form
collected; rescued/sparse orders (or orders where the carrier's data
lives in FMCSA, not the intake) produced near-empty forms. Now we pull
the carrier census (legal name, address, EIN, fleet counts) from the
FMCSA carrier API and merge it under any customer-provided intake values
(customer edits win), so the form is pre-filled with the carrier's
current registered data. Refactored the FMCSA fetch into a shared
_fetch_fmcsa_carrier helper used by both enrichment and status check.
This commit is contained in:
justin 2026-06-10 12:35:43 -05:00
parent 7e5946d65a
commit d5e66786a2
8 changed files with 294 additions and 15 deletions

View file

@ -224,6 +224,27 @@ export const COMPLIANCE_SERVICES: Record<string, ComplianceService> = {
erpnext_item: "FOREIGN-QUAL-MULTI",
discountable: true,
},
// ── Business name reservation ────────────────────────────────────────
// Sell the binding Name RESERVATION (the SOS holds the name for a fixed
// window), not a non-binding "search". State fee passed through at cost.
// Free instant pre-check on the order form stays free (lead magnet).
// See docs/name-reservation-product.md.
"name-reservation-tx": {
name: "Texas Name Reservation (Form 501, 120 days)",
price_cents: 7900, // flat service fee
gov_fee_cents: 4000, // TX SOS name reservation fee
gov_fee_label: "Texas SOS name reservation fee (Form 501)",
erpnext_item: "NAME-RESERVATION",
discountable: true,
},
"name-reservation-nv": {
name: "Nevada Name Reservation (90 days)",
price_cents: 7900, // flat service fee
gov_fee_cents: 2500, // NV SOS name reservation fee
gov_fee_label: "Nevada SOS name reservation fee",
erpnext_item: "NAME-RESERVATION",
discountable: true,
},
// Business entity formation (used by the trucking new-carrier flow when the
// carrier needs to form an LLC/corp before registering). Formation is also
// available via the dedicated /order/formation flow; these catalog entries

Binary file not shown.

View file

@ -0,0 +1,74 @@
# Selling name availability: sell a Name RESERVATION, not a "check"
Decision (2026-06-09): the product to sell is a **state Name Reservation**, not a
bare "name check." Here's why, with the real state mechanics.
## The key distinction: a search is a snapshot, a reservation is a binding hold
- A **name search** (what our TX open-data API and a SOSDirect/SilverFlume search
do) is a non-binding *snapshot*. It tells you the name looks free *right now*. It
does not stop someone else from taking it tomorrow, and it is not the SOS's official
word. Charging for a snapshot is weak value and invites "I paid and then lost the
name" complaints.
- A **name reservation** is the authoritative, binding action: the Secretary of State
*holds the name for you* for a fixed window. This is a real deliverable with a real
filing receipt - exactly the kind of thing we should sell.
## What each state actually offers (public fee schedules)
### Texas
- **SOSDirect online name search: $1.00 per search** (statutory fee, requires a
SOSDirect login). Non-binding snapshot.
- **Preliminary determination by phone/email: free** (call 512-463-5555 or email
Corporations) - also non-binding, and slow/manual.
- **Name Reservation (Form 501): $40 state fee, holds the name 120 days, renewable.**
This is the binding hold and the thing worth selling.
### Nevada
- **SilverFlume name availability: free** (but the portal is behind Incapsula bot
protection, so we cannot automate it - and a free snapshot is low value anyway).
- **Name Reservation: $25 state fee, holds the name 90 days.** The binding hold.
## Product recommendation
1. **Free instant pre-check (lead magnet, not a SKU):** keep the TX open-data API
check on the order form as a *free* "looks available / looks taken" instant signal.
It costs us nothing, reduces friction, and qualifies the lead. Label it clearly as
a preliminary check, not a guarantee. (NV pre-check returns "we'll verify manually"
since NV is bot-blocked.)
2. **Sell the Name Reservation as the paid SKU.** Flat service fee + the state fee at
cost. This is the authoritative hold the customer actually wants, and it's a clean,
deliverable-backed product:
- `name-reservation-tx`: service fee + **$40** TX state fee (Form 501, 120 days).
- `name-reservation-nv`: service fee + **$25** NV state fee (90 days).
- or a generic `name-reservation` with the state chosen at intake and the gov fee
resolved per state (cleaner, matches how foreign-qualification fans out).
3. **Bundle it into the formation / DEXIT flow:** offer "reserve the name now" as an
add-on/step before the full formation or conversion, so the customer locks the name
while the rest of the paperwork is prepared. Natural upsell, removes the "what if I
lose the name while you file?" objection.
## Pricing note (house rule: use the higher price on any mismatch)
Suggested service fee in the $49-$99 range on top of the state fee, billed as a flat
fee with the government fee passed through at cost and labeled as such (consistent
with our other corporate SKUs and the no-hidden-fees trust posture).
## Fulfillment reality (be honest about automation)
- **TX reservation (Form 501)** is filed via SOSDirect (login-gated) or by mail/fax.
SOSDirect filing automation is **not yet verified** (same as TX formation), so the
reservation would start **admin-assisted** until that flow is proven by the e2e
harness. The $1 SOSDirect search and the $40 reservation both require the SOSDirect
account we use for filing.
- **NV reservation** is filed on SilverFlume, which is Incapsula-blocked for
automation, so NV reservation is **admin-assisted** (a person files it). Still a
perfectly good paid product - the customer pays for the outcome (a held name), not
for our automation.
- Both: capture the name + entity type at intake, file the reservation, deliver the
state confirmation/receipt through the portal. The free pre-check gates obviously
bad names before the customer pays.
## Next steps to ship this
1. Add `name-reservation` SKU(s) to `api/src/service-catalog.ts` with per-state gov
fee (TX $40 / NV $25) and create the matching ERPNext Items.
2. Add an admin-assisted `NameReservationHandler` (or reuse the MCS150-style
admin-assisted pattern) that records the order and surfaces an admin to-do to file
the reservation, then attaches the SOS receipt.
3. Keep the free instant TX pre-check on the form; relabel as preliminary.
4. Offer the reservation as a step in the formation + DEXIT intake.

View file

@ -0,0 +1,55 @@
"""Diagnostic: inspect the actual MCS-150 fill output.
Reports, per page: the AcroForm fields present (name + value + rect), and the
rendered text, so we can see WHERE data lands vs WHERE the blank/example fields
are. Run in the workers container against the live template + a fresh fill.
"""
import sys, json
sys.path.insert(0, "/app")
from pypdf import PdfReader
TEMPLATE = "/app/docs/MCS-150 Form.pdf"
def field_rows(reader, label):
print(f"\n===== {label} =====")
for i, pg in enumerate(reader.pages):
annots = pg.get("/Annots")
if not annots:
continue
annots = annots.get_object() if hasattr(annots, "get_object") else annots
rows = []
for a in annots:
o = a.get_object()
t = o.get("/T")
if t is None and o.get("/Parent"):
t = o["/Parent"].get_object().get("/T")
v = o.get("/V")
rect = o.get("/Rect")
ft = o.get("/FT")
if t:
rows.append((str(t), str(ft), str(v) if v is not None else "", [round(float(x)) for x in rect] if rect else None))
if rows:
print(f"-- PDF page {i+1} ({len(rows)} fields) --")
for name, ft, val, rect in rows:
vstr = f" = {val!r}" if val else ""
print(f" {name} [{ft}]{vstr} rect={rect}")
# 1) the template as-is — what fields + any example values?
r = PdfReader(TEMPLATE)
field_rows(r, "TEMPLATE (original)")
# 2) a fresh fill
from scripts.document_gen.templates.mcs150_pdf_filler import fill_mcs150
intake = {
"dot_number": "1609564", "legal_name": "MITCHELL W ALLEN",
"dba_name": "", "entity_type": "sole_proprietorship",
"business_street": "123 Main St", "business_city": "Town",
"business_state": "TX", "business_zip": "75001",
"phone": "5125551234", "ein": "123456789",
"signer_name": "Mitchell W Allen", "signer_title": "Owner",
"power_units": "2", "drivers": "1",
}
p = fill_mcs150(intake, order_number="DIAG")
print("\nfilled file:", p)
field_rows(PdfReader(p), "FILLED")

View file

@ -107,6 +107,9 @@ SERVICE_HANDLERS: dict[str, type] = {
# ── Foreign qualification (Certificate of Authority) ─────────────────
"foreign-qualification-single": ForeignQualificationHandler,
"foreign-qualification-multi": ForeignQualificationHandler, # same handler, fans out per-state
# ── Business name reservation (admin-assisted; files SOS name hold) ──
"name-reservation-tx": MCS150UpdateHandler, # admin-assisted: file Form 501 via SOSDirect
"name-reservation-nv": MCS150UpdateHandler, # admin-assisted: file NV reservation via SilverFlume
# ── State PUC/PSC registration ────────────────────────────────────
"state-puc": StatePucFilingHandler,
# ── CDR storage tier add-ons (quota bumps, not filings) ────────────

View file

@ -91,6 +91,85 @@ class PlotterConfig:
servo_up_angle: int = 90
# Line-us mapping (only used when dialect == "lineus").
lineus: "LineUsConfig | None" = None
# Pen-specific tuning (ink flow, dwell, line look). See PenProfile / PENS.
pen: "PenProfile | None" = None
@dataclass(frozen=True)
class PenProfile:
"""Per-pen tuning for a writing instrument mounted in the plotter.
Different pens lay ink differently when the plotter drops the tip STRAIGHT
DOWN (no human wrist angle/pressure modulation). Wet gel/rollerball pens read
as a genuine signature but need (a) a short dwell at pen-down so the ink wets
the paper before the first move (avoids a starting gap), and (b) a slower draw
feed so the line lays down evenly without skipping on fast reversals. These
values are applied by the emitters on top of the machine PlotterConfig.
Recommended default for legal filings: uni-ball Signo (UM-151 0.38mm)
archival, waterproof, fraud-resistant gel ink that reads as original wet ink.
pen_down_dwell_ms : pause after the pen contacts paper, before the first
drawing move, to let wet ink start cleanly (G4 dwell).
pen_up_dwell_ms : brief pause after lifting (lets gel/rollerball tips
"snap" off cleanly without a tail). Usually small/0.
draw_feed : per-pen drawing feed (mm/min). Overrides the machine
default when set; wet pens like ~600-900.
pen_down_bias_mm : small extra downward offset (mm) added to pen_down so a
spring holder keeps positive contact for this tip.
tip_mm : nominal tip width (mm), informational / preview only.
"""
name: str = "uniball-signo"
label: str = "uni-ball Signo UM-151 0.38 (gel)"
pen_down_dwell_ms: int = 120
pen_up_dwell_ms: int = 0
draw_feed: float = 750.0
pen_down_bias_mm: float = 0.0
tip_mm: float = 0.38
# Tuned presets for the pens we keep on hand. Default = uni-ball Signo.
PENS: dict[str, PenProfile] = {
"uniball-signo": PenProfile(
name="uniball-signo",
label="uni-ball Signo UM-151 0.38 (gel, archival/waterproof)",
pen_down_dwell_ms=120, pen_up_dwell_ms=0, draw_feed=750.0,
pen_down_bias_mm=0.0, tip_mm=0.38,
),
"pilot-g2": PenProfile(
name="pilot-g2",
label="Pilot G-2 0.7 (gel, hand-written look)",
# G-2 can blob on a long pen-down dwell; keep dwell short.
pen_down_dwell_ms=80, pen_up_dwell_ms=0, draw_feed=800.0,
pen_down_bias_mm=0.0, tip_mm=0.7,
),
"energel": PenProfile(
name="energel",
label="Pentel EnerGel 0.5 (liquid-gel, fast-dry batch)",
# Liquid-gel starts instantly and dries fast — minimal dwell, faster feed.
pen_down_dwell_ms=60, pen_up_dwell_ms=0, draw_feed=900.0,
pen_down_bias_mm=0.0, tip_mm=0.5,
),
"fountain": PenProfile(
name="fountain",
label="Fountain pen + document ink (max ink character)",
# Fountain nibs need a touch more settle time and a gentle, slower line;
# add a small downward bias so the nib keeps consistent flow.
pen_down_dwell_ms=180, pen_up_dwell_ms=40, draw_feed=600.0,
pen_down_bias_mm=-0.1, tip_mm=0.5,
),
}
DEFAULT_PEN = "uniball-signo"
def load_pen(name: str | None) -> PenProfile:
"""Return a PenProfile for a named pen (default uni-ball Signo)."""
key = (name or DEFAULT_PEN).lower()
if key not in PENS:
raise ValueError(f"unknown pen '{name}'; choices: {sorted(PENS)}")
return PENS[key]
@dataclass(frozen=True)

View file

@ -97,6 +97,22 @@ class MCS150UpdateHandler:
# Check current MCS-150 status via FMCSA API
mcs150_status = self._check_current_status(dot_number)
# Enrich the intake with the carrier's CURRENT registered data from the
# FMCSA carrier API. The MCS-150 biennial update re-confirms the carrier's
# existing FMCSA record, so the authoritative source for the form is the
# FMCSA census (legal name, address, EIN, fleet counts) -- the intake form
# only collects the DOT number + any changes. Customer-provided intake
# values take precedence over the census (so edits/changes win).
census = self._fetch_carrier_record(dot_number)
if census:
merged = {**census, **{k: v for k, v in intake.items() if v not in (None, "")}}
intake = merged
LOG.info("[%s] Enriched intake from FMCSA census (legal_name=%s)",
order_number, intake.get("legal_name"))
else:
LOG.warning("[%s] No FMCSA census data for DOT %s -- form may be sparse",
order_number, dot_number)
# Step 1: Fill the official MCS-150 PDF
pdf_path = None
try:
@ -321,30 +337,59 @@ class MCS150UpdateHandler:
return [minio_path] if minio_path else []
def _check_current_status(self, dot_number: str) -> str:
"""Check current MCS-150 status via FMCSA API."""
def _fetch_fmcsa_carrier(self, dot_number: str) -> dict:
"""Fetch the raw FMCSA carrier census record for a DOT number."""
try:
import urllib.request
api_key = os.environ.get("FMCSA_API_KEY", "")
if not api_key:
return "API key not configured"
return {}
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}"
)
return data.get("content", {}).get("carrier", {}) or {}
except Exception as exc:
return f"Could not check: {exc}"
LOG.warning("FMCSA carrier fetch failed for %s: %s", dot_number, exc)
return {}
def _fetch_carrier_record(self, dot_number: str) -> dict:
"""Return the carrier's current FMCSA data mapped to the intake keys the
PDF filler expects (so the biennial-update form is pre-filled with the
carrier's existing registered data). Empty dict if unavailable.
"""
c = self._fetch_fmcsa_carrier(dot_number)
if not c:
return {}
def s(v):
return "" if v is None else str(v)
out: dict = {
"legal_name": s(c.get("legalName")),
"dba_name": s(c.get("dbaName")),
"dot_number": s(c.get("dotNumber") or dot_number),
"ein": s(c.get("ein")),
"address_street": s(c.get("phyStreet")),
"address_city": s(c.get("phyCity")),
"address_state": s(c.get("phyState")),
"address_zip": s(c.get("phyZipcode")),
"phone": s(c.get("telephone") or c.get("phone")),
"power_units": s(c.get("totalPowerUnits")),
"drivers": s(c.get("totalDrivers")),
}
return {k: v for k, v in out.items() if v}
def _check_current_status(self, dot_number: str) -> str:
"""Check current MCS-150 status via FMCSA API."""
c = self._fetch_fmcsa_carrier(dot_number)
if not c:
return "Could not check FMCSA status"
return (
f"Status: {c.get('statusCode', '?')}, "
f"Allowed: {c.get('allowedToOperate', '?')}, "
f"MCS-150 Outdated: {c.get('mcs150Outdated', '?')}"
)
def _create_pending_signature_todo(self, order_number, entity_name, dot_number,
slug, minio_path, customer_email):

View file

@ -63,6 +63,8 @@ export const SERVICE_META: Record<string, ServiceMeta> = {
"mc-authority": { name: "MC Operating Authority Application", price_cents: 19900, gov_fee_label: "FMCSA operating authority application fee" },
"mcs150-update": { name: "MCS-150 Biennial Update", price_cents: 3900 },
"medicare-enrollment": { name: "Medicare Enrollment (PECOS)", price_cents: 69900 },
"name-reservation-nv": { name: "Nevada Name Reservation (90 days)", price_cents: 7900, gov_fee_label: "Nevada SOS name reservation fee" },
"name-reservation-tx": { name: "Texas Name Reservation (Form 501, 120 days)", price_cents: 7900, gov_fee_label: "Texas SOS name reservation fee (Form 501)" },
"new-carrier-bundle": { name: "New Carrier Onboarding Bundle (FRN + 499 Initial + RMD + CPNI + CALEA)", price_cents: 179900 },
"nm-weight-distance": { name: "NM Weight-Distance Tax Setup", price_cents: 10900, gov_fee_label: "NM weight-distance permit & account fees (state, billed at cost)" },
"npi-reactivation": { name: "NPI Reactivation", price_cents: 44900 },