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:
parent
ea695d6828
commit
1d6693adb9
3 changed files with 183 additions and 9 deletions
|
|
@ -243,16 +243,28 @@ def gov_fee_payment_url(child_order_number: str) -> str:
|
|||
return f"{SITE}/order/pay?order={child_order_number}"
|
||||
|
||||
|
||||
def gov_fee_dispute_url(child_order_number: str) -> str:
|
||||
"""Public 'this estimate looks wrong' page that opens a support ticket
|
||||
pre-tagged with the order so ops can correct the fee."""
|
||||
return f"{SITE}/order/dispute?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."""
|
||||
"""Email the customer an itemized government-fee bill + payment link, plus a
|
||||
link to dispute the amount if it looks wrong."""
|
||||
if not customer_email:
|
||||
return False
|
||||
url = gov_fee_payment_url(child_order_number)
|
||||
dispute = gov_fee_dispute_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 "
|
||||
# Itemized breakdown so the customer can check it for accuracy.
|
||||
items = "\n".join(f" - {line}" for line in (estimate.breakdown or [estimate.label]))
|
||||
qualifier = (
|
||||
"This amount is the state's confirmed fee."
|
||||
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
|
||||
|
|
@ -260,13 +272,17 @@ def send_gov_fee_payment_email(customer_email: str, customer_name: str,
|
|||
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"remaining step is the government/state fee, which we collect at cost.\n\n"
|
||||
f"Here is exactly what the {amt} covers — please review it for accuracy:\n\n"
|
||||
f" {estimate.label}\n"
|
||||
f"{items}\n"
|
||||
f" ----------------------------------------\n"
|
||||
f" Total government fee: {amt}\n\n"
|
||||
f"{qualifier}\n\n"
|
||||
f"✅ If this looks right, pay securely here (choose your method — "
|
||||
f"bank transfer/ACH has no processing fee):\n{url}\n\n"
|
||||
f"❓ If something looks wrong (wrong fleet size, states, weight, or "
|
||||
f"amount), tell us here and we'll fix it before you pay:\n{dispute}\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"
|
||||
|
|
|
|||
149
site/public/order/dispute/index.html
Normal file
149
site/public/order/dispute/index.html
Normal 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,"<")}</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>
|
||||
|
|
@ -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;"> ${String(b).replace(/</g,"<")}</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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue