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.
176 lines
9.1 KiB
HTML
176 lines
9.1 KiB
HTML
<!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>
|