diff --git a/api/migrations/099_gov_fee_child_orders.sql b/api/migrations/099_gov_fee_child_orders.sql new file mode 100644 index 0000000..349ed43 --- /dev/null +++ b/api/migrations/099_gov_fee_child_orders.sql @@ -0,0 +1,20 @@ +-- 099: Government-fee child orders for at-cost compliance services. +-- +-- At-cost services (IRP, IFTA, intrastate authority, etc.) collect only our +-- SERVICE fee at checkout; the actual government/state fee is variable and +-- "billed at cost" afterward. To collect it we create a CHILD compliance_orders +-- row (service_fee_cents = 0, gov_fee_cents = the quoted state fee) that flows +-- through the EXISTING checkout/payment-picker/webhook machinery unchanged, and +-- email the customer a payment link with every payment method + correct +-- surcharges. parent_order_number links that child back to the original order so +-- the worker can resume filing once the fee is paid. +-- +-- Idempotent. + +ALTER TABLE compliance_orders + ADD COLUMN IF NOT EXISTS parent_order_number text; + +-- Look up a parent's gov-fee children quickly (and vice-versa). +CREATE INDEX IF NOT EXISTS idx_compliance_orders_parent + ON compliance_orders (parent_order_number) + WHERE parent_order_number IS NOT NULL; diff --git a/api/src/routes/checkout.ts b/api/src/routes/checkout.ts index d977c23..4360858 100644 --- a/api/src/routes/checkout.ts +++ b/api/src/routes/checkout.ts @@ -1770,6 +1770,39 @@ export async function handlePaymentComplete( // reporting year. Non-fatal — the customer still sees ingestion // counts even without a grant. if (order_type === "compliance" || order_type === "compliance_batch") { + // ── Government-fee child order paid → resume the parent's filing ────── + // At-cost services (IRP/IFTA/intrastate) bill the state fee via a child + // order (parent_order_number set). When that child is paid, re-dispatch the + // PARENT to the worker with gov_fee_paid=true so it proceeds to file. The + // child itself needs none of the normal compliance post-processing. + const parentNo = (order.parent_order_number as string) || ""; + if (parentNo) { + try { + const { rows: prows } = await pool.query( + "SELECT service_slug FROM compliance_orders WHERE order_number = $1", + [parentNo], + ); + const parentSlug = prows[0]?.service_slug || ""; + const workerUrl = process.env.WORKER_URL || "http://workers:8090"; + await fetch(`${workerUrl}/jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "process_compliance_service", + order_name: parentNo, + order_number: parentNo, + service_slug: parentSlug, + client_approved: true, // authorization already signed earlier + gov_fee_paid: true, + }), + }); + console.log(`[checkout] Gov-fee ${order_id} paid → re-dispatched parent ${parentNo} to file`); + } catch (e) { + console.error(`[checkout] Failed to resume parent after gov-fee ${order_id}:`, e); + } + return; // child order needs no further compliance processing + } + try { await grantCDRStudyAccess(order, order_id); } catch (grantErr) { diff --git a/scripts/workers/services/gov_fee.py b/scripts/workers/services/gov_fee.py new file mode 100644 index 0000000..e04c868 --- /dev/null +++ b/scripts/workers/services/gov_fee.py @@ -0,0 +1,260 @@ +"""Government (state) fee estimation + collection for at-cost trucking services. + +At-cost services (IRP, IFTA, intrastate authority, ...) only collect our service +fee at checkout. The real government fee is variable and must be collected +separately, at cost, before we file. This module: + + 1. estimate_gov_fee(slug, intake) -> GovFeeEstimate + Best-effort estimate of the state fee from the carrier's intake (base state, + operating states, power units, weight bracket). For IRP this is an + order-of-magnitude estimate ONLY (true apportioned fees are known when the + state portal computes them); for fixed/near-fixed fees (IFTA license + + decals, most intrastate authority filings) it is exact-ish. + + 2. create_gov_fee_order(...) -> child order_number + Creates a CHILD compliance_orders row (service_fee_cents = 0, gov_fee_cents + = estimate) linked to the parent via parent_order_number, so it flows + through the existing checkout/payment-picker/webhook unchanged. + + 3. send_gov_fee_payment_email(...) + Emails the customer a payment link (every method + correct surcharges). + +Design note on surcharges: the payment page itself shows ACH at 0% (Stripe ACH +is 0.8% capped $5 — absorbed), card/PayPal 3%, Klarna 6%, crypto 0%. We do not +hardcode those here; the existing /order page + create-session own that. +""" + +from __future__ import annotations + +import json +import logging +import os +import secrets +from dataclasses import dataclass, field + +LOG = logging.getLogger("workers.services.gov_fee") + +SITE = os.getenv("PUBLIC_SITE_URL", "https://performancewest.net").rstrip("/") + +# IFTA: license is typically free; decals ~$0-$6 each, 1 set (2 decals) per +# qualifying power unit. We bill a flat, transparent per-unit decal cost and note +# it is at cost. Most base states are $0-$10 for a 1-2 truck fleet. +IFTA_DECAL_FEE_PER_UNIT_CENTS = int(os.getenv("IFTA_DECAL_FEE_PER_UNIT_CENTS", "1000")) # $10/unit set, conservative + +# Intrastate authority: state filing fee, fixed per state. Conservative +# defaults; refine per state as we confirm exact amounts. Cents. +INTRASTATE_AUTHORITY_FEE_CENTS = { + "SC": 0, # SC intrastate handled via federal authority; nominal/none + "TX": 10000, # TxDMV intrastate + "CA": 0, # MCP handled separately + "FL": 5000, + "GA": 5000, + "_default": 5000, +} + +# IRP apportioned registration is genuinely variable (apportioned by fleet +# miles per jurisdiction + registered weight). We can only ESTIMATE here; the +# exact fee comes from the base-state IRP portal at filing time. Estimate model: +# per_unit_base * power_units * weight_multiplier * jurisdiction_factor +# This is intentionally conservative (errs high) so a customer is never +# surprised by a higher real fee; any overage is refunded / underage re-billed. +IRP_PER_UNIT_BASE_CENTS = int(os.getenv("IRP_PER_UNIT_BASE_CENTS", "150000")) # $1,500/truck base +IRP_WEIGHT_MULTIPLIERS = { + "under_26k": 0.6, + "26k_to_80k": 1.0, + "over_80k": 1.4, + "_default": 1.0, +} + + +@dataclass +class GovFeeEstimate: + cents: int + label: str + exact: bool = False # True when the amount is fixed/known, not an estimate + breakdown: list[str] = field(default_factory=list) + + +def _int(v, default=0) -> int: + try: + return int(str(v).strip()) + except (TypeError, ValueError): + return default + + +def estimate_gov_fee(slug: str, intake: dict) -> GovFeeEstimate: + """Estimate the government/state fee for an at-cost trucking service.""" + intake = intake or {} + power_units = max(_int(intake.get("power_units"), 1), 1) + base_state = (intake.get("base_state") or intake.get("address_state") or "").upper() + op_states = intake.get("operating_states") or [] + if isinstance(op_states, str): + try: + op_states = json.loads(op_states) + except Exception: + op_states = [s.strip() for s in op_states.split(",") if s.strip()] + weight = (intake.get("gross_weight_bracket") or "_default") + + if slug == "ifta-application": + cents = IFTA_DECAL_FEE_PER_UNIT_CENTS * power_units + return GovFeeEstimate( + cents=cents, + label=f"IFTA license + decals ({power_units} unit set(s), {base_state or 'base state'}) — at cost", + exact=False, + breakdown=[f"IFTA decals: {power_units} x ${IFTA_DECAL_FEE_PER_UNIT_CENTS/100:.2f}"], + ) + + if slug == "intrastate-authority": + cents = INTRASTATE_AUTHORITY_FEE_CENTS.get(base_state, + INTRASTATE_AUTHORITY_FEE_CENTS["_default"]) + return GovFeeEstimate( + cents=cents, + label=f"{base_state or 'State'} intrastate authority filing fee — at cost", + exact=base_state in INTRASTATE_AUTHORITY_FEE_CENTS, + breakdown=[f"{base_state or 'State'} intrastate filing fee: ${cents/100:.2f}"], + ) + + if slug == "irp-registration": + wmult = IRP_WEIGHT_MULTIPLIERS.get(weight, IRP_WEIGHT_MULTIPLIERS["_default"]) + # More operating jurisdictions => higher apportioned total. Base state + + # each additional operating state adds ~12% (rough apportionment proxy). + n_juris = max(len(set([base_state] + list(op_states)) - {""}), 1) + jfactor = 1.0 + 0.12 * (n_juris - 1) + cents = int(IRP_PER_UNIT_BASE_CENTS * power_units * wmult * jfactor) + return GovFeeEstimate( + cents=cents, + label=(f"IRP apportioned registration ESTIMATE — {power_units} unit(s), " + f"{weight.replace('_',' ')}, {n_juris} jurisdiction(s) — at cost, " + f"final fee set by {base_state or 'base state'} IRP office"), + exact=False, + breakdown=[ + f"Base: ${IRP_PER_UNIT_BASE_CENTS/100:.2f}/unit x {power_units}", + f"Weight x{wmult}", + f"Jurisdictions x{jfactor:.2f} ({n_juris})", + f"Estimated total: ${cents/100:.2f}", + ], + ) + + return GovFeeEstimate(cents=0, label="No government fee", exact=True) + + +def create_gov_fee_order(parent_order_number: str, slug: str, estimate: GovFeeEstimate, + customer_email: str, customer_name: str, + customer_phone: str = "") -> str | None: + """Create (idempotently) a child gov-fee compliance order for the parent. + + Returns the child order_number, or None on failure. The child has + service_fee_cents=0 and gov_fee_cents=estimate so it bills only the gov fee + through the normal checkout flow. Re-running updates the amount of an existing + unpaid child rather than creating duplicates. + """ + if estimate.cents <= 0: + return None + try: + import psycopg2 + conn = psycopg2.connect(os.environ.get("DATABASE_URL", "")) + with conn.cursor() as cur: + # Reuse an existing UNPAID gov-fee child for this parent if present. + cur.execute( + """SELECT order_number FROM compliance_orders + WHERE parent_order_number = %s AND service_slug = %s + AND payment_status = 'pending_payment' + ORDER BY created_at DESC LIMIT 1""", + (parent_order_number, f"{slug}-govfee"), + ) + row = cur.fetchone() + if row: + child = row[0] + cur.execute( + """UPDATE compliance_orders + SET gov_fee_cents = %s, gov_fee_label = %s, updated_at = now() + WHERE order_number = %s""", + (estimate.cents, estimate.label, child), + ) + conn.commit() + conn.close() + LOG.info("[%s] Updated existing gov-fee child %s ($%.2f)", + parent_order_number, child, estimate.cents / 100) + return child + + child = "CG-" + secrets.token_hex(4).upper() + cur.execute( + """INSERT INTO compliance_orders + (order_number, parent_order_number, service_slug, service_name, + service_fee_cents, gov_fee_cents, gov_fee_label, + customer_email, customer_name, customer_phone, + payment_status, intake_data, intake_data_validated, fulfillment_status) + VALUES (%s, %s, %s, %s, 0, %s, %s, %s, %s, %s, + 'pending_payment', %s, TRUE, NULL)""", + ( + child, parent_order_number, f"{slug}-govfee", + f"Government fee — {slug}", + estimate.cents, estimate.label, + customer_email, customer_name, customer_phone, + json.dumps({"source": "gov-fee", "parent": parent_order_number, + "breakdown": estimate.breakdown, "exact": estimate.exact}), + ), + ) + conn.commit() + conn.close() + LOG.info("[%s] Created gov-fee child %s ($%.2f)", + parent_order_number, child, estimate.cents / 100) + return child + except Exception as exc: # noqa: BLE001 + LOG.error("[%s] Failed to create gov-fee child order: %s", parent_order_number, exc) + return None + + +def gov_fee_payment_url(child_order_number: str) -> str: + """Public payment page for a gov-fee child order. Reuses the standard order + page, which renders every payment method + surcharge and calls + /api/v1/checkout/create-session with order_type=compliance.""" + return f"{SITE}/order/pay?order={child_order_number}" + + +def send_gov_fee_payment_email(customer_email: str, customer_name: str, + service_label: str, entity_name: str, + estimate: GovFeeEstimate, child_order_number: str) -> bool: + """Email the customer a payment link for the government fee.""" + if not customer_email: + return False + url = gov_fee_payment_url(child_order_number) + amt = f"${estimate.cents / 100:,.2f}" + qualifier = "" if estimate.exact else ( + " This is an estimate billed at cost; if the state's final fee differs we " + "refund any overage or bill the small difference.") + try: + import smtplib + from email.mime.text import MIMEText + body = ( + f"Hi {customer_name or 'there'},\n\n" + f"Your {service_label} for {entity_name} is ready to file. The only " + f"remaining step is the government/state fee, which we collect at cost:\n\n" + f" {service_label}\n" + f" Government fee: {amt}\n" + f" {estimate.label}\n\n" + f"Pay securely here (choose your preferred method — bank transfer/ACH " + f"has no processing fee):\n{url}\n\n" + f"{qualifier}\n\n" + f"As soon as the fee is paid we file with the state and send your " + f"confirmation.\n\n" + f"Order: {child_order_number}\n" + f"Questions? Reply here or call (888) 411-0383.\n\n" + f"Performance West Inc.\nDOT / State Motor Carrier Compliance\n" + ) + msg = MIMEText(body) + msg["Subject"] = f"Action needed: government fee for your {service_label} ({amt})" + msg["From"] = os.getenv("SMTP_FROM", "Performance West ") + msg["To"] = customer_email + with smtplib.SMTP(os.getenv("SMTP_HOST", "co.carrierone.com"), + int(os.getenv("SMTP_PORT", "587")), timeout=30) as s: + s.starttls() + u, p = os.getenv("SMTP_USER", ""), os.getenv("SMTP_PASS", "") + if u and p: + s.login(u, p) + s.sendmail("noreply@performancewest.net", [customer_email], msg.as_string()) + LOG.info("Gov-fee payment email sent to %s for %s (%s)", customer_email, child_order_number, amt) + return True + except Exception as exc: # noqa: BLE001 + LOG.warning("Failed to send gov-fee payment email to %s: %s", customer_email, exc) + return False diff --git a/scripts/workers/services/state_trucking.py b/scripts/workers/services/state_trucking.py index f514b7c..f712ef0 100644 --- a/scripts/workers/services/state_trucking.py +++ b/scripts/workers/services/state_trucking.py @@ -221,6 +221,14 @@ class StateTruckingHandler: SERVICE_SLUG = "state-trucking" SERVICE_NAME = "State Trucking Compliance" + # At-cost services that collect the government/state fee from the customer + # after authorization (separate from our service fee). See gov_fee.py. + GOV_FEE_SERVICES = frozenset({ + "irp-registration", + "ifta-application", + "intrastate-authority", + }) + 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", "")) @@ -245,6 +253,7 @@ class StateTruckingHandler: dot_number = intake.get("dot_number", "") entity_name = intake.get("entity_name", order_data.get("customer_name", "")) customer_email = order_data.get("customer_email", "") + customer_phone = order_data.get("customer_phone", "") or intake.get("phone", "") base_state = intake.get("base_state", intake.get("phy_state", "")) operating_states = intake.get("operating_states", []) @@ -290,6 +299,24 @@ class StateTruckingHandler: self._set_fulfillment_status(order_number, FULFILLMENT_AUTHORIZATION_SIGNED) LOG.info("[%s] Authorization signed (%s) — proceeding to filing", order_number, signed_auth_key) + # ── Government fee gate ────────────────────────────────────────────── + # At-cost services (IRP, IFTA, intrastate) collect only our service fee + # at checkout; the state fee is variable and billed at cost. Once + # authorization is signed, auto-quote the gov fee, create a child payment + # order, email the customer a payment link (all methods + surcharges), + # and HOLD at awaiting_government_fee_approval until they pay. The + # gov-fee child's payment webhook re-dispatches us with + # gov_fee_paid=True, at which point we fall through to filing. + if (service_slug in self.GOV_FEE_SERVICES + and not order_data.get("gov_fee_paid") + and not self._gov_fee_settled(order_number)): + if self._request_gov_fee_payment(order_number, service_slug, service_name, + entity_name, customer_email, customer_phone, intake): + self._set_fulfillment_status(order_number, FULFILLMENT_AWAITING_FEE_APPROVAL) + LOG.info("[%s] Gov fee quoted — held pending customer payment", order_number) + return [] + LOG.warning("[%s] Could not set up gov-fee payment — proceeding to manual todo", order_number) + # Slug-specific intake fields collected by StateTruckingIntakeStep. intake_summary = self._summarize_intake(service_slug, intake) @@ -665,6 +692,69 @@ class StateTruckingHandler: except Exception as exc: LOG.warning("[%s] Could not set fulfillment_status=%s: %s", order_number, status, exc) + def _gov_fee_settled(self, order_number: str) -> bool: + """True if a gov-fee child order for this parent is already paid.""" + try: + import psycopg2 + conn = psycopg2.connect(os.environ.get("DATABASE_URL", "")) + try: + with conn.cursor() as cur: + cur.execute( + """SELECT 1 FROM compliance_orders + WHERE parent_order_number = %s AND payment_status = 'paid' + LIMIT 1""", + (order_number,), + ) + return cur.fetchone() is not None + finally: + conn.close() + except Exception as exc: # noqa: BLE001 + LOG.warning("[%s] Could not check gov-fee settlement: %s", order_number, exc) + return False + + def _request_gov_fee_payment(self, order_number, service_slug, service_name, + entity_name, customer_email, customer_phone, intake) -> bool: + """Quote the government fee, create a child payment order, and email the + customer a payment link. Returns True if the customer was asked to pay.""" + try: + from scripts.workers.services.gov_fee import ( + estimate_gov_fee, create_gov_fee_order, send_gov_fee_payment_email, + ) + except Exception as exc: # noqa: BLE001 + LOG.error("[%s] gov_fee import failed: %s", order_number, exc) + return False + + est = estimate_gov_fee(service_slug, intake) + if est.cents <= 0: + LOG.info("[%s] No government fee for %s — skipping fee gate", order_number, service_slug) + return False + + child = create_gov_fee_order( + order_number, service_slug, est, + customer_email, entity_name, customer_phone, + ) + if not child: + return False + + # Notify the customer + ops. + send_gov_fee_payment_email(customer_email, entity_name, service_name, + entity_name, est, child) + try: + notify_fulfillment_todo( + title=f"{service_name} — gov fee billed ({'${:,.2f}'.format(est.cents/100)}) — {entity_name}", + order_number=order_number, + service_slug=service_slug, + priority="normal", + description=(f"Auto-quoted government fee for {service_name}.\n" + f"Amount: ${est.cents/100:,.2f} ({'exact' if est.exact else 'estimate, at cost'})\n" + f"Payment order: {child}\n" + f"Customer: {customer_email}\n" + f"Held at awaiting_government_fee_approval until paid; then auto-files."), + ) + except Exception: + pass + return True + def _authorization_status(self, order_number: str) -> str | None: """Return the current authorization esign status: 'signed', 'pending', or None.""" try: diff --git a/site/public/order/pay/index.html b/site/public/order/pay/index.html new file mode 100644 index 0000000..b28890a --- /dev/null +++ b/site/public/order/pay/index.html @@ -0,0 +1,176 @@ + + + + + + + Complete Payment | Performance West + + + + + + +
+
+
+ +

Complete your payment

+

Loading your order…

+
+
+
Loading…
+ + +
+
+
+ + + +