feat(govfee): auto-quote + collect state fees for at-cost trucking services

At-cost services (IRP/IFTA/intrastate) only collected our service fee at
checkout; the variable state fee was never billed, so orders stalled at
authorization_signed and the filing card would have had to front large IRP fees.

New end-to-end, hands-off flow (you only approve the final filing):
  1. After authorization is signed, state_trucking auto-estimates the gov fee
     from intake (base/op states, power units, weight) via gov_fee.estimate_gov_fee.
  2. Creates a CHILD compliance order (CG-..., service_fee=0, gov_fee=estimate,
     parent_order_number set, migration 099) that flows through the EXISTING
     checkout/payment/webhook machinery.
  3. Emails the customer a payment link to /order/pay (new self-contained page)
     showing every method with correct surcharges — ACH 0% (Stripe 0.8%/ cap
     absorbed, no GoCardless needed), card/PayPal 3%, Klarna 6%, crypto 0%.
  4. Order holds at awaiting_government_fee_approval until paid.
  5. On payment, handlePaymentComplete detects the child (parent_order_number)
     and re-dispatches the PARENT with gov_fee_paid=true, which proceeds to
     prepare + queue the filing and stops at ready_to_file for your approval.

IRP fees are estimates billed at cost (refund overage / rebill shortfall); IFTA
decals + most intrastate fees are near-exact. Tunable via env.
This commit is contained in:
justin 2026-06-16 04:35:45 -05:00
parent 3e13b722f6
commit 861f2fbfd4
5 changed files with 579 additions and 0 deletions

View file

@ -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;

View file

@ -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) {

View file

@ -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 <noreply@performancewest.net>")
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

View file

@ -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:

View file

@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>Complete Payment | Performance West</title>
<script>
window.__PW_API = (function () {
var h = window.location.hostname;
if (h === "localhost" || h === "127.0.0.1") return "http://" + h + ":3001";
if (h === "dev.performancewest.net") return "https://api.dev.performancewest.net";
return "https://api.performancewest.net";
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root { --pw600:#3a6192; --pw700:#2d4e78; --pw800:#213b5c; --pw900:#182c45;
--g100:#f3f4f6; --g200:#e5e7eb; --g300:#d1d5db; --g500:#6b7280; --g700:#374151; --g900:#111827; }
* { box-sizing: border-box; }
body { margin:0; font-family:'Inter',system-ui,sans-serif; background:#f7f8fa; color:var(--g700); }
.wrap { max-width:520px; margin:0 auto; padding:32px 16px; }
.card { background:#fff; border:1px solid var(--g200); border-radius:14px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,.06); }
.head { background:var(--pw900); color:#fff; padding:20px 24px; }
.head h1 { margin:0; font-size:18px; font-weight:700; }
.head p { margin:4px 0 0; font-size:13px; color:#b8cde5; }
.body { padding:24px; }
.row { display:flex; justify-content:space-between; padding:8px 0; font-size:14px; border-bottom:1px solid var(--g100); }
.row .lbl { color:var(--g500); } .row .val { color:var(--g900); font-weight:500; text-align:right; }
.total { font-size:20px; font-weight:700; color:var(--pw900); }
.muted { color:var(--g500); font-size:12px; }
h2 { font-size:13px; text-transform:uppercase; letter-spacing:.05em; color:var(--g500); margin:24px 0 8px; }
.methods { display:flex; flex-direction:column; gap:8px; }
.method { display:flex; align-items:center; gap:10px; border:1px solid var(--g300); border-radius:10px; padding:12px 14px; cursor:pointer; transition:border-color .15s, background .15s; }
.method:hover { border-color:var(--pw600); }
.method.sel { border-color:var(--pw600); background:#f0f5fa; box-shadow:inset 0 0 0 1px var(--pw600); }
.method input { accent-color:var(--pw700); }
.method .name { font-weight:600; color:var(--g900); font-size:14px; }
.method .sub { font-size:12px; color:var(--g500); }
.method .right { margin-left:auto; text-align:right; font-size:13px; font-weight:600; color:var(--g900); }
.badge { display:inline-block; font-size:11px; font-weight:700; color:#166534; background:#dcfce7; border-radius:999px; padding:1px 8px; margin-left:6px; }
.btn { width:100%; margin-top:20px; padding:14px; border:none; border-radius:10px; background:var(--pw700); color:#fff; font-size:15px; font-weight:600; cursor:pointer; }
.btn:hover { background:var(--pw800); } .btn:disabled { opacity:.55; cursor:not-allowed; }
.err { margin-top:12px; font-size:13px; color:#b91c1c; background:#fef2f2; border:1px solid #fecaca; border-radius:8px; padding:10px 12px; }
.center { text-align:center; }
.logo { font-weight:700; color:#fff; font-size:15px; }
.secure { margin-top:14px; font-size:11px; color:var(--g500); text-align:center; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="head">
<div class="logo">Performance West</div>
<h1 id="title" style="margin-top:6px;">Complete your payment</h1>
<p id="subtitle">Loading your order…</p>
</div>
<div class="body">
<div id="loading" class="center muted">Loading…</div>
<div id="content" style="display:none;">
<div id="summary"></div>
<h2>Choose how to pay</h2>
<div id="methods" class="methods"></div>
<div id="grand" class="row" style="border-bottom:none;margin-top:8px;">
<span class="lbl total">Total</span><span class="val total" id="grand-total"></span>
</div>
<button id="pay" class="btn">Continue to secure payment →</button>
<div id="err" class="err" style="display:none;"></div>
<div class="secure">🔒 Payments processed securely. We never store your card details.</div>
</div>
<div id="notfound" style="display:none;" class="center muted">
This payment link is invalid or has already been paid.
</div>
</div>
</div>
</div>
<script>
const API = window.__PW_API;
const $ = (id) => document.getElementById(id);
const money = (c) => "$" + (Number(c || 0) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const params = new URLSearchParams(location.search);
const orderId = params.get("order") || "";
// Mirror api GATEWAY_SURCHARGES. ACH = 0% (Stripe 0.8% capped $5, absorbed).
const METHODS = [
{ id: "ach", name: "Bank transfer (ACH)", sub: "No processing fee", pct: 0 },
{ id: "card", name: "Credit / debit card", sub: "Visa, Mastercard, Amex", pct: 3 },
{ id: "paypal", name: "PayPal", sub: "Pay with your PayPal balance or card", pct: 3 },
{ id: "klarna", name: "Klarna", sub: "Pay over time", pct: 6 },
];
let base = 0; // pre-surcharge total (service + gov fee - discount), cents
let selected = "ach";
function renderMethods() {
$("methods").innerHTML = METHODS.map((m) => {
const sc = Math.round((base * m.pct) / 100);
const total = base + sc;
const feeText = m.pct === 0
? `<span class="badge">no fee</span>`
: `+${m.pct}% (${money(sc)})`;
return `<label class="method ${m.id === selected ? "sel" : ""}" data-m="${m.id}">
<input type="radio" name="pm" value="${m.id}" ${m.id === selected ? "checked" : ""} />
<div>
<div class="name">${m.name} ${m.pct === 0 ? '<span class="badge">no fee</span>' : ""}</div>
<div class="sub">${m.sub}${m.pct > 0 ? " · " + feeText : ""}</div>
</div>
<div class="right">${money(total)}</div>
</label>`;
}).join("");
document.querySelectorAll(".method").forEach((el) => {
el.addEventListener("click", () => { selected = el.getAttribute("data-m"); renderMethods(); renderTotal(); });
});
}
function renderTotal() {
const m = METHODS.find((x) => x.id === selected);
const sc = Math.round((base * m.pct) / 100);
$("grand-total").textContent = money(base + sc);
}
async function load() {
if (!orderId) { $("loading").style.display = "none"; $("notfound").style.display = "block"; return; }
try {
const r = await fetch(API + "/api/v1/compliance-orders/" + encodeURIComponent(orderId));
if (!r.ok) throw new Error("not found");
const o = await r.json();
if (o.payment_status && o.payment_status !== "pending_payment") {
$("loading").style.display = "none";
$("subtitle").textContent = "";
$("notfound").style.display = "block";
$("notfound").textContent = "This order has already been paid. Thank you!";
return;
}
const svc = Number(o.service_fee_cents || 0);
const gov = Number(o.gov_fee_cents || 0);
const disc = Number(o.discount_cents || 0);
base = svc + gov - disc;
const rows = [];
if (svc > 0) rows.push(`<div class="row"><span class="lbl">${o.service_name || "Service"}</span><span class="val">${money(svc)}</span></div>`);
if (gov > 0) rows.push(`<div class="row"><span class="lbl">${o.gov_fee_label || "Government fee"}</span><span class="val">${money(gov)}</span></div>`);
if (disc > 0) rows.push(`<div class="row"><span class="lbl">Discount</span><span class="val">-${money(disc)}</span></div>`);
$("summary").innerHTML = rows.join("");
$("subtitle").textContent = `${o.customer_name || ""} · ${o.order_number}`;
$("loading").style.display = "none";
$("content").style.display = "block";
renderMethods();
renderTotal();
} catch (e) {
$("loading").style.display = "none";
$("notfound").style.display = "block";
}
}
$("pay").addEventListener("click", async () => {
$("err").style.display = "none";
$("pay").disabled = true; $("pay").textContent = "Starting secure checkout…";
try {
const r = await fetch(API + "/api/v1/checkout/create-session", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId, order_type: "compliance", payment_method: selected }),
});
const d = await r.json();
if (d.checkout_url) { location.href = d.checkout_url; return; }
throw new Error(d.error || "Could not start checkout");
} catch (e) {
$("err").textContent = e.message; $("err").style.display = "block";
$("pay").disabled = false; $("pay").textContent = "Continue to secure payment →";
}
});
load();
</script>
</body>
</html>