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:
parent
3e13b722f6
commit
861f2fbfd4
5 changed files with 579 additions and 0 deletions
20
api/migrations/099_gov_fee_child_orders.sql
Normal file
20
api/migrations/099_gov_fee_child_orders.sql
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
260
scripts/workers/services/gov_fee.py
Normal file
260
scripts/workers/services/gov_fee.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
176
site/public/order/pay/index.html
Normal file
176
site/public/order/pay/index.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue