diff --git a/api/src/service-catalog.ts b/api/src/service-catalog.ts index c38984a..0027e58 100644 --- a/api/src/service-catalog.ts +++ b/api/src/service-catalog.ts @@ -224,6 +224,27 @@ export const COMPLIANCE_SERVICES: Record = { 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 diff --git a/docs/MCR Revalidaton Acceptance Letter_05 01 2026.pdf b/docs/MCR Revalidaton Acceptance Letter_05 01 2026.pdf new file mode 100644 index 0000000..dc38280 Binary files /dev/null and b/docs/MCR Revalidaton Acceptance Letter_05 01 2026.pdf differ diff --git a/docs/name-reservation-product.md b/docs/name-reservation-product.md new file mode 100644 index 0000000..878af9b --- /dev/null +++ b/docs/name-reservation-product.md @@ -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. diff --git a/scripts/document_gen/templates/diag_mcs150.py b/scripts/document_gen/templates/diag_mcs150.py new file mode 100644 index 0000000..22d4f00 --- /dev/null +++ b/scripts/document_gen/templates/diag_mcs150.py @@ -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") diff --git a/scripts/workers/services/__init__.py b/scripts/workers/services/__init__.py index 1ba8f3c..6c574e0 100644 --- a/scripts/workers/services/__init__.py +++ b/scripts/workers/services/__init__.py @@ -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) ──────────── diff --git a/scripts/workers/services/ink_signature_plotter.py b/scripts/workers/services/ink_signature_plotter.py index 2785842..01297d9 100644 --- a/scripts/workers/services/ink_signature_plotter.py +++ b/scripts/workers/services/ink_signature_plotter.py @@ -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) diff --git a/scripts/workers/services/mcs150_update.py b/scripts/workers/services/mcs150_update.py index ce7c7af..7c08735 100644 --- a/scripts/workers/services/mcs150_update.py +++ b/scripts/workers/services/mcs150_update.py @@ -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): diff --git a/site/src/lib/service-catalog.generated.ts b/site/src/lib/service-catalog.generated.ts index c21acba..3090779 100644 --- a/site/src/lib/service-catalog.generated.ts +++ b/site/src/lib/service-catalog.generated.ts @@ -63,6 +63,8 @@ export const SERVICE_META: Record = { "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 },