govfee: itemize the estimate in the email + add a 'fix my fee' dispute path

The gov-fee email now lists exactly what the amount covers (full breakdown) so
the customer can check it for accuracy, with two clear actions: a  pay link and
a  'something looks wrong' link to /order/dispute.

New /order/dispute page shows the fee breakdown and lets the customer describe
what's wrong; it opens an 'issue' support ticket pre-tagged with the order
(amount + label + their note) via /api/v1/tickets, so ops corrects the fee
before any payment is taken. The /order/pay page also shows the itemized
breakdown and a dispute link.
This commit is contained in:
justin 2026-06-16 05:00:31 -05:00
parent ea695d6828
commit 1d6693adb9
3 changed files with 183 additions and 9 deletions

View file

@ -0,0 +1,149 @@
<!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>Review your fee | 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:560px; 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:6px 0 0; font-size:18px; font-weight:700; }
.head p { margin:4px 0 0; font-size:13px; color:#b8cde5; }
.logo { font-weight:700; color:#fff; font-size:15px; }
.body { padding:24px; }
.row { display:flex; justify-content:space-between; padding:7px 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; }
.muted { color:var(--g500); font-size:12px; }
h2 { font-size:13px; text-transform:uppercase; letter-spacing:.05em; color:var(--g500); margin:22px 0 8px; }
label { display:block; font-size:13px; font-weight:500; color:var(--g700); margin:12px 0 4px; }
textarea, input { width:100%; padding:10px 12px; border:1px solid var(--g300); border-radius:8px; font-family:inherit; font-size:14px; }
textarea:focus, input:focus { outline:2px solid var(--pw600); border-color:var(--pw600); }
.btn { width:100%; margin-top:18px; padding:13px; 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; }
.btn-secondary { display:block; text-align:center; margin-top:10px; padding:11px; border-radius:10px; background:#fff; border:1px solid var(--g300); color:var(--g700); text-decoration:none; font-size:14px; font-weight:500; }
.err { margin-top:12px; font-size:13px; color:#b91c1c; background:#fef2f2; border:1px solid #fecaca; border-radius:8px; padding:10px 12px; }
.ok { margin-top:12px; font-size:14px; color:#166534; background:#dcfce7; border:1px solid #bbf7d0; border-radius:8px; padding:12px; }
pre { background:var(--g100); border-radius:8px; padding:10px 12px; font-size:12px; white-space:pre-wrap; margin:0; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="head">
<div class="logo">Performance West</div>
<h1>Review your government fee</h1>
<p id="subtitle">Loading…</p>
</div>
<div class="body">
<div id="loading" class="muted">Loading…</div>
<div id="content" style="display:none;">
<p class="muted">Here is exactly what your fee covers. If anything looks wrong
(fleet size, operating states, weight, or the amount), tell us below and we'll
correct it before you pay.</p>
<h2>Fee breakdown</h2>
<div id="breakdown"></div>
<div class="row" style="border-bottom:none;margin-top:6px;">
<span class="lbl" style="font-weight:700;color:var(--pw900);">Total</span>
<span class="val" id="total" style="font-weight:700;color:var(--pw900);"></span>
</div>
<h2>What looks wrong?</h2>
<form id="form">
<label for="name">Your name</label>
<input id="name" type="text" autocomplete="name" />
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email" required />
<label for="msg">Tell us what to fix</label>
<textarea id="msg" rows="5" required placeholder="e.g. We only run in SC, not NC — please recalculate."></textarea>
<div id="err" class="err" style="display:none;"></div>
<button id="submit" class="btn" type="submit">Submit correction request</button>
</form>
<a id="payok" class="btn-secondary" href="#">Actually, the fee looks right — pay now →</a>
<div id="done" class="ok" style="display:none;"></div>
</div>
<div id="notfound" class="muted" style="display:none;">This link is invalid or the order was already handled.</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 orderId = new URLSearchParams(location.search).get("order") || "";
let order = null;
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("nf");
order = await r.json();
const gov = Number(order.gov_fee_cents || 0);
const intake = (order.intake_data && typeof order.intake_data === "object") ? order.intake_data : {};
const breakdown = Array.isArray(intake.breakdown) ? intake.breakdown : [];
$("subtitle").textContent = `${order.customer_name || ""} · ${order.order_number}`;
$("breakdown").innerHTML =
`<div class="row"><span class="lbl">${order.gov_fee_label || "Government fee"}</span><span class="val"></span></div>`
+ (breakdown.length
? breakdown.map((b) => `<div class="row"><span class="lbl" style="font-size:13px;">${b.replace(/</g,"&lt;")}</span><span class="val"></span></div>`).join("")
: "");
$("total").textContent = money(gov);
$("payok").href = "/order/pay?order=" + encodeURIComponent(orderId);
$("loading").style.display = "none";
$("content").style.display = "block";
} catch (e) {
$("loading").style.display = "none"; $("notfound").style.display = "block";
}
}
$("form").addEventListener("submit", async (e) => {
e.preventDefault();
$("err").style.display = "none";
$("submit").disabled = true; $("submit").textContent = "Submitting…";
try {
const r = await fetch(API + "/api/v1/tickets", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category: "issue",
subject: `Government fee correction — ${order.order_number} (${order.gov_fee_label || "gov fee"})`,
message: `Customer requests a correction to the government fee on order ${order.order_number}.\n\n`
+ `Current amount: ${money(order.gov_fee_cents)}\n`
+ `Current label: ${order.gov_fee_label || ""}\n\n`
+ `Customer says:\n${$("msg").value.trim()}`,
email: $("email").value.trim(),
name: $("name").value.trim(),
page: location.href,
}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Could not submit");
$("form").style.display = "none"; $("payok").style.display = "none";
$("done").style.display = "block";
$("done").textContent = "Thanks — we got your correction request and will review it before any payment is taken. We'll email you a corrected fee shortly.";
} catch (e) {
$("err").textContent = e.message; $("err").style.display = "block";
$("submit").disabled = false; $("submit").textContent = "Submit correction request";
}
});
load();
</script>
</body>
</html>

View file

@ -140,8 +140,17 @@
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>`);
// Itemized gov-fee breakdown (so the customer can check it for accuracy).
const intake = (o.intake_data && typeof o.intake_data === "object") ? o.intake_data : {};
if (Array.isArray(intake.breakdown)) {
intake.breakdown.forEach((b) => rows.push(`<div class="row" style="border-bottom:none;padding:2px 0;"><span class="lbl" style="font-size:12px;color:#9ca3af;">&nbsp;&nbsp;${String(b).replace(/</g,"&lt;")}</span><span class="val"></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("");
// Dispute link for gov-fee orders.
if (gov > 0) {
$("summary").innerHTML += `<div style="margin-top:8px;font-size:12px;"><a href="/order/dispute?order=${encodeURIComponent(orderId)}" style="color:#6b7280;">Something look wrong with this fee? Tell us before paying →</a></div>`;
}
$("subtitle").textContent = `${o.customer_name || ""} · ${o.order_number}`;
$("loading").style.display = "none";
$("content").style.display = "block";