From b85be726b775a8f1524f411dc3d2fc472f8c7aa3 Mon Sep 17 00:00:00 2001 From: justin Date: Tue, 2 Jun 2026 03:51:25 -0500 Subject: [PATCH] feat(fulfillment): bundle/exclusion enforcement + REQUIRED_FIELDS + intake wiring (Phases 1/1.5/2) - compliance-orders: hazmat-phmsa/state-emissions products, full REQUIRED_FIELDS table for all DOT/state/hazmat slugs, BUNDLE_COMPONENTS dedup + MUTUALLY_EXCLUSIVE enforcement on /batch (single source of truth, exported) - checkout: empty ADMIN_ASSISTED_SLUGS (state/hazmat now get intake links) - services/__init__: register HazmatPHMSAHandler + state-emissions handler - state_trucking: _summarize_intake admin-todo enrichment - Wizard: wire StateTruckingIntakeStep + step labels --- api/src/routes/checkout.ts | 14 +-- api/src/routes/compliance-orders.ts | 112 ++++++++++++++++++++- scripts/workers/services/__init__.py | 6 ++ scripts/workers/services/state_trucking.py | 93 ++++++++++++++++- site/src/components/intake/Wizard.astro | 6 +- 5 files changed, 215 insertions(+), 16 deletions(-) diff --git a/api/src/routes/checkout.ts b/api/src/routes/checkout.ts index 25d0c3a..574527e 100644 --- a/api/src/routes/checkout.ts +++ b/api/src/routes/checkout.ts @@ -2066,15 +2066,11 @@ async function sendComplianceIntakeEmail(

` : ""; - // Fully admin-assisted services — NO customer intake form (state-level filings - // collected by ops). Everything else (FCC + federal DOT: MCS-150, BOC-3, UCR, - // authority, D&A, audit, etc.) now has a customer intake page and gets a link. - const ADMIN_ASSISTED_SLUGS = new Set([ - "irp-registration", "ifta-application", "ifta-quarterly", - "or-weight-mile-tax", "ny-hut-registration", "ky-kyu-registration", - "nm-weight-distance", "ct-highway-use-fee", "ca-mcp-carb", - "state-dot-registration", "intrastate-authority", "osow-permit", - "state-trucking-bundle", + // Fully admin-assisted services with NO customer intake form. State-level + // trucking + hazmat/emissions now have a dedicated intake step, so they are + // NO LONGER in this set — customers get an intake link like other services. + const ADMIN_ASSISTED_SLUGS = new Set([ + // (reserved for any future no-intake services) ]); const dotOrders = orders.filter(o => ADMIN_ASSISTED_SLUGS.has(o.service_slug as string)); const fccOrders = orders.filter(o => !ADMIN_ASSISTED_SLUGS.has(o.service_slug as string)); diff --git a/api/src/routes/compliance-orders.ts b/api/src/routes/compliance-orders.ts index 9691a89..dfb3316 100644 --- a/api/src/routes/compliance-orders.ts +++ b/api/src/routes/compliance-orders.ts @@ -373,6 +373,20 @@ const COMPLIANCE_SERVICES: Record< erpnext_item: "STATE-TRUCKING-BUNDLE", discountable: true, }, + // ── Hazmat / Emissions ─────────────────────────────────────────────── + "hazmat-phmsa": { + name: "PHMSA Hazmat Registration", + price_cents: 14900, // $149 admin-assisted; PHMSA gov fee billed at cost + gov_fee_label: "PHMSA registration fee ($25 + $250-$3,000 processing, by business size, billed at cost)", + erpnext_item: "HAZMAT-PHMSA", + discountable: true, + }, + "state-emissions": { + name: "State Clean-Truck / Emissions Compliance", + price_cents: 19900, // $199 — NY/CO/MD/NJ/MA clean-truck / ACT advisory + registration assist + erpnext_item: "STATE-EMISSIONS", + discountable: true, + }, // ── Corporate / Entity Services ── "annual-report-filing": { @@ -555,8 +569,78 @@ const REQUIRED_FIELDS: Record = { // Foreign qualification — the target_states array is the critical input. "foreign-qualification-single": { required: ["legal_name", "home_state_code", "entity_type", "target_states"], soft: ["ein"] }, "foreign-qualification-multi": { required: ["legal_name", "home_state_code", "entity_type", "target_states"], soft: ["ein"] }, + + // ── DOT / FMCSA Motor Carrier Services ─────────────────────────────── + // All collected via the unified dot-intake step (DOTIntakeStep.astro). + "mcs150-update": { required: ["dot_number", "legal_name", "address_street", "address_city", "address_state", "address_zip", "phone", "email", "signer_name", "signer_title", "power_units", "drivers", "carrier_operation", "interstate_intrastate", "hazmat"], soft: ["mc_number", "ein", "annual_miles", "cargo_types"] }, + "ucr-registration":{ required: ["dot_number", "legal_name", "address_state", "email", "fleet_size_bracket"], soft: ["mc_number", "power_units"] }, + "boc3-filing": { required: ["dot_number", "legal_name", "address_street", "address_city", "address_state", "address_zip", "email"], soft: ["mc_number", "docket_type", "docket_number", "entity_type"] }, + "dot-registration":{ required: ["legal_name", "address_street", "address_city", "address_state", "address_zip", "phone", "email", "signer_name", "signer_title", "carrier_operation", "interstate_intrastate", "hazmat"], soft: ["ein", "power_units", "drivers"] }, + "mc-authority": { required: ["dot_number", "legal_name", "address_street", "address_city", "address_state", "address_zip", "phone", "email", "signer_name", "signer_title", "carrier_operation"], soft: ["mc_number", "ein"] }, + "dot-drug-alcohol":{ required: ["dot_number", "legal_name", "email", "cdl_drivers"], soft: ["owner_operators", "der_name", "current_da_provider"] }, + "dot-audit-prep": { required: ["dot_number", "legal_name", "email", "carrier_operation", "power_units", "drivers"], soft: ["cdl_drivers"] }, + "dot-full-compliance": { required: ["dot_number", "legal_name", "address_street", "address_city", "address_state", "address_zip", "phone", "email", "signer_name", "signer_title", "carrier_operation", "interstate_intrastate", "hazmat", "power_units", "drivers"], soft: ["mc_number", "ein", "fleet_size_bracket", "cdl_drivers"] }, + "usdot-reactivation": { required: ["dot_number", "legal_name", "address_street", "address_city", "address_state", "address_zip", "phone", "email", "signer_name", "signer_title"], soft: ["mc_number"] }, + "emergency-temporary-authority": { required: ["dot_number", "legal_name", "email", "signer_name", "signer_title", "carrier_operation"], soft: ["mc_number"] }, + "carrier-closeout": { required: ["dot_number", "legal_name", "email", "signer_name", "signer_title"], soft: ["mc_number"] }, + "entity-dissolution": { required: ["legal_name", "address_state", "email", "signer_name"], soft: ["entity_type"] }, + + // ── State-Level Trucking Compliance ────────────────────────────────── + // Collected via the state-trucking intake step (StateTruckingIntakeStep.astro). + "irp-registration": { required: ["dot_number", "legal_name", "base_state", "email", "power_units", "operating_states"], soft: ["mc_number", "fuel_type", "gross_weight_bracket"] }, + "ifta-application": { required: ["dot_number", "legal_name", "base_state", "email", "power_units", "fuel_type"], soft: ["mc_number", "operating_states"] }, + "ifta-quarterly": { required: ["dot_number", "legal_name", "base_state", "email"], soft: ["reporting_quarter"] }, + "or-weight-mile-tax": { required: ["dot_number", "legal_name", "email", "power_units"], soft: ["gross_weight_bracket"] }, + "ny-hut-registration": { required: ["dot_number", "legal_name", "email", "power_units"], soft: ["gross_weight_bracket"] }, + "ky-kyu-registration": { required: ["dot_number", "legal_name", "email", "power_units"], soft: [] }, + "nm-weight-distance": { required: ["dot_number", "legal_name", "email", "power_units"], soft: [] }, + "ct-highway-use-fee": { required: ["dot_number", "legal_name", "email", "power_units"], soft: ["gross_weight_bracket"] }, + "ca-mcp-carb": { required: ["dot_number", "legal_name", "email", "power_units"], soft: ["ca_number", "engine_model_years"] }, + "state-dot-registration":{ required: ["dot_number", "legal_name", "base_state", "email"], soft: [] }, + "intrastate-authority": { required: ["dot_number", "legal_name", "base_state", "email", "authority_type"], soft: ["insurance_carrier", "insurance_policy", "boc3_on_file"] }, + "osow-permit": { required: ["dot_number", "legal_name", "base_state", "email"], soft: ["load_dimensions", "load_weight"] }, + "state-trucking-bundle": { required: ["dot_number", "legal_name", "base_state", "email", "power_units"], soft: ["operating_states", "fuel_type"] }, + + // ── Hazmat / Emissions ─────────────────────────────────────────────── + "hazmat-phmsa": { required: ["dot_number", "legal_name", "email", "hazmat_classes"], soft: ["bulk_packaging", "small_business", "ein"] }, + "state-emissions": { required: ["dot_number", "legal_name", "base_state", "email"], soft: ["engine_model_years", "power_units"] }, }; +// ── Bundle composition + incompatibility (single source of truth) ────────── +// A bundle slug -> the individual component slugs it already covers. When a +// bundle is in the cart, its components are dropped (no double-charge / dup +// filing). Mirrors the DB service_bundles table for the DOT/FCC inline bundles. +const BUNDLE_COMPONENTS: Record = { + "dot-full-compliance": [ + "mcs150-update", "boc3-filing", "ucr-registration", + "dot-drug-alcohol", "dot-audit-prep", + ], + "state-trucking-bundle": [ + "irp-registration", "ifta-application", "or-weight-mile-tax", + "ny-hut-registration", "ky-kyu-registration", "nm-weight-distance", + "ct-highway-use-fee", "ca-mcp-carb", "state-dot-registration", + "intrastate-authority", + ], + "new-carrier-bundle": [ + "dot-registration", "mc-authority", "boc3-filing", + "mcs150-update", "dot-drug-alcohol", "ucr-registration", + ], + "fcc-full-compliance": [ + "fcc-499a", "stir-shaken", "cpni-certification", "rmd-filing", + ], + "fcc-499a-499q": ["fcc-499a", "fcc-499q"], +}; + +// Mutually-exclusive services that must never be in the same cart. +const MUTUALLY_EXCLUSIVE_GROUPS: string[][] = [ + // A carrier is either reactivating OR closing out — not both. + ["usdot-reactivation", "carrier-closeout"], + // Emergency temporary authority vs full MC authority application. + ["emergency-temporary-authority", "mc-authority"], + // Standalone 499-A vs the A+Q bundle (also handled by BUNDLE_COMPONENTS). + ["fcc-499a", "fcc-499a-zero"], +]; + // Entity-level requirements (e.g. "must have an FRN on file before this // service can run"). Checked against the linked telecom_entity. const REQUIRES_ENTITY_FRN: ReadonlySet = new Set([ @@ -1151,9 +1235,29 @@ router.post("/api/v1/compliance-orders/batch", async (req, res) => { // Deduplicate and validate service slugs let services = [...new Set(rawServices as string[])]; - // If both 499a and 499a-499q selected, drop the standalone 499a - if (services.includes("fcc-499a") && services.includes("fcc-499a-499q")) { - services = services.filter(s => s !== "fcc-499a"); + // ── Bundle / incompatibility enforcement (single source of truth) ────── + // If a bundle is selected, drop any of its individual components from the + // cart (the bundle already covers them) to avoid double-charging + duplicate + // filings. Then reject any hard-incompatible (mutually-exclusive) pairs. + const droppedComponents: string[] = []; + for (const [bundle, components] of Object.entries(BUNDLE_COMPONENTS)) { + if (services.includes(bundle)) { + const before = services.length; + services = services.filter(s => !components.includes(s)); + if (services.length < before) { + droppedComponents.push(...components.filter(c => (rawServices as string[]).includes(c))); + } + } + } + for (const group of MUTUALLY_EXCLUSIVE_GROUPS) { + const present = group.filter(s => services.includes(s)); + if (present.length > 1) { + res.status(400).json({ + error: `These services cannot be ordered together: ${present.join(", ")}. Please choose one.`, + incompatible: present, + }); + return; + } } const invalid = services.filter(s => !COMPLIANCE_SERVICES[s]); @@ -2010,5 +2114,5 @@ router.post("/api/v1/compliance-orders/:id/usac-delegation", async (req, res) => } }); -export { COMPLIANCE_SERVICES, REQUIRED_FIELDS }; +export { COMPLIANCE_SERVICES, REQUIRED_FIELDS, BUNDLE_COMPONENTS, MUTUALLY_EXCLUSIVE_GROUPS }; export default router; diff --git a/scripts/workers/services/__init__.py b/scripts/workers/services/__init__.py index 95d9168..634693f 100644 --- a/scripts/workers/services/__init__.py +++ b/scripts/workers/services/__init__.py @@ -55,6 +55,8 @@ from .ein_application import EINApplicationHandler from .mailbox_setup import MailboxSetupHandler # Carrier close-out / trucking wrap-up (shutdown) + entity dissolution from .carrier_closeout import CarrierCloseoutHandler +# PHMSA hazmat registration (admin-assisted, 49 CFR Part 107) +from .hazmat_phmsa import HazmatPHMSAHandler SERVICE_HANDLERS: dict[str, type] = { "flsa-audit": FLSAAuditHandler, @@ -134,6 +136,10 @@ SERVICE_HANDLERS: dict[str, type] = { "intrastate-authority": StateTruckingHandler, "osow-permit": StateTruckingHandler, "state-trucking-bundle": StateTruckingHandler, + # ── Hazmat / PHMSA Registration ─────────────────────────────────── + "hazmat-phmsa": HazmatPHMSAHandler, + # ── State emissions / clean-truck (admin-assisted) ──────────────── + "state-emissions": StateTruckingHandler, } # Service slugs that operate on a telecom entity — used by job_server.py diff --git a/scripts/workers/services/state_trucking.py b/scripts/workers/services/state_trucking.py index 2e43a9c..49bae8d 100644 --- a/scripts/workers/services/state_trucking.py +++ b/scripts/workers/services/state_trucking.py @@ -184,6 +184,19 @@ SERVICE_INFO = { "7. Send all registrations and confirmations to client", ], }, + "state-emissions": { + "name": "State Clean-Truck / Emissions Compliance", + "category": "emissions", + "steps": [ + "1. Identify carrier's base/operating states with emissions programs " + "(NY, CO, MD, NJ, MA, etc. — Advanced Clean Trucks / Clean Truck Check)", + "2. Review fleet engine model-years against state emissions thresholds", + "3. Register/report fleet in the applicable state emissions portal", + "4. File any required compliance certification or fee", + "5. Set up annual renewal/reporting reminders", + "6. Send compliance confirmation + next-steps to client", + ], + }, } @@ -220,6 +233,9 @@ class StateTruckingHandler: base_state = intake.get("base_state", intake.get("phy_state", "")) operating_states = intake.get("operating_states", []) + # Slug-specific intake fields collected by StateTruckingIntakeStep. + intake_summary = self._summarize_intake(service_slug, intake) + # Look up state requirements if we have a base state state_reqs = None if base_state: @@ -244,10 +260,18 @@ class StateTruckingHandler: "base_state": base_state, "operating_states": operating_states, "intake_data": intake, + "intake_summary": intake_summary, "state_requirements": state_reqs, "steps": steps, } + # Render the slug-specific intake fields into the description. + intake_lines = "" + if intake_summary: + intake_lines = "\nFiling details:\n" + "\n".join( + f" - {k}: {v}" for k, v in intake_summary.items() + ) + "\n" + try: import psycopg2 conn = psycopg2.connect(os.environ.get("DATABASE_URL", "")) @@ -269,8 +293,9 @@ class StateTruckingHandler: f"DOT: {dot_number}\n" f"Base state: {base_state}\n" f"Operating states: {', '.join(operating_states) if operating_states else 'N/A'}\n" - f"Customer: {customer_email}\n\n" - f"Steps:\n" + "\n".join(steps), + f"Customer: {customer_email}\n" + + intake_lines + + f"\nSteps:\n" + "\n".join(steps), json.dumps(todo_data), )) conn.commit() @@ -287,6 +312,70 @@ class StateTruckingHandler: return [] + # Map slug -> list of (intake_data key, human label) to surface in the todo. + _INTAKE_FIELD_MAP = { + "_common": [ + ("power_units", "Power units"), + ("mc_number", "MC/MX/FF #"), + ], + "irp-ifta": [ + ("fuel_type", "Fuel type"), + ("gross_weight_bracket", "Gross weight bracket"), + ], + "emissions": [ + ("ca_number", "CA #"), + ("engine_model_years", "Oldest engine model year"), + ], + "intrastate": [ + ("authority_type", "Authority type"), + ("boc3_on_file", "BOC-3 on file"), + ("insurance_carrier", "Insurance carrier"), + ("insurance_policy", "Insurance policy #"), + ], + "osow": [ + ("load_dimensions", "Load dimensions"), + ("load_weight", "Load weight (lbs)"), + ], + "hazmat": [ + ("hazmat_classes", "Hazmat classes"), + ("bulk_packaging", "Bulk packaging"), + ("small_business", "Small business"), + ], + } + + # Which field groups apply to each slug (mirrors the front-end ST_SECTIONS). + _SLUG_FIELD_GROUPS = { + "irp-registration": ["irp-ifta"], + "ifta-application": ["irp-ifta"], + "ifta-quarterly": ["irp-ifta"], + "or-weight-mile-tax": ["irp-ifta"], + "ny-hut-registration": ["irp-ifta"], + "ky-kyu-registration": ["irp-ifta"], + "nm-weight-distance": ["irp-ifta"], + "ct-highway-use-fee": ["irp-ifta"], + "ca-mcp-carb": ["emissions"], + "state-emissions": ["emissions"], + "state-dot-registration": [], + "intrastate-authority": ["intrastate"], + "osow-permit": ["osow"], + "state-trucking-bundle": ["irp-ifta", "emissions", "intrastate"], + "hazmat-phmsa": ["hazmat"], + } + + def _summarize_intake(self, service_slug: str, intake: dict) -> dict: + """Extract the slug-relevant intake fields into a flat label->value dict.""" + groups = ["_common"] + self._SLUG_FIELD_GROUPS.get(service_slug, []) + summary: dict = {} + for group in groups: + for key, label in self._INTAKE_FIELD_MAP.get(group, []): + val = intake.get(key) + if val in (None, "", [], {}): + continue + if isinstance(val, (list, tuple)): + val = ", ".join(str(v) for v in val) + summary[label] = val + return summary + def _resolve_slug(self, order_number: str) -> str: """Look up the service_slug from compliance_orders by order_number.""" try: diff --git a/site/src/components/intake/Wizard.astro b/site/src/components/intake/Wizard.astro index b998311..1c68470 100644 --- a/site/src/components/intake/Wizard.astro +++ b/site/src/components/intake/Wizard.astro @@ -41,6 +41,7 @@ import CDRPeriodStep from "./steps/CDRPeriodStep.astro"; import OCNStep from "./steps/OCNStep.astro"; import MCS150Step from "./steps/MCS150Step.astro"; import DOTIntakeStep from "./steps/DOTIntakeStep.astro"; +import StateTruckingIntakeStep from "./steps/StateTruckingIntakeStep.astro"; import ClassificationWizard from "./steps/ClassificationWizard.astro"; import ReviewStep from "./steps/ReviewStep.astro"; import PaymentStep from "./steps/PaymentStep.astro"; @@ -78,6 +79,8 @@ const STEP_LABELS: Record = { cpni_questions: "CPNI Details", cdr_period: "Reporting Period", ocn: "OCN Details", + "dot-intake": "Carrier Details", + "state-trucking": "Filing Details", review: "Review", payment: "Payment", }; @@ -122,6 +125,7 @@ const STEP_LABELS: Record = { {steps.includes("ocn") && } {steps.includes("mcs150") && } {steps.includes("dot-intake") && } + {steps.includes("state-trucking") && } {steps.includes("classification") && } {steps.includes("review") && } {steps.includes("payment") && } @@ -226,7 +230,7 @@ const STEP_LABELS: Record = { block6_cert: "Certifications", bdc_data: "BDC Data", stir_shaken: "STIR/SHAKEN", calea: "CALEA", foreign_carrier: "Foreign Affiliation", foreign_qual: "State Registration", dc_agent: "D.C. Agent", cpni_questions: "CPNI Details", cdr_period: "Reporting Period", - ocn: "OCN Details", review: "Review", payment: "Payment", + ocn: "OCN Details", "dot-intake": "Carrier Details", "state-trucking": "Filing Details", review: "Review", payment: "Payment", }; // Category-gated dynamic step insertion. After the user picks Line 105