3d02134711
Test rates "in time" (overnight windows, daily caps, overstay) in seconds against any tariff version, instead of waiting hours/days. No real ledger writes. - Extract priceSession() into @parking/shared: the grace/overstay wrapper over computeFee (unpaid -> entry..now; within-grace -> settled 0; grace-expired -> overstay, a fresh period from grace-expiry). PayStation.quote() now calls it so the booth and the lab can never diverge. - API (tariffs.ts, tariff:read, read-only): POST /api/tariff/simulate prices a hypothetical session (active/any version/inline structure) and returns the priceSession outcome + a 30m..3d duration curve (see where the daily cap flattens); GET /api/tariff/simulate/session/:identity prefills from a real ledger session. - UI TariffLab.tsx at Setup -> "Tariff Lab": version picker, entry/asOf times, optional payment+grace, category, and load-a-real-ticket. Admin-gated, available on-site (useful to quote a dispute). - 4 new priceSession unit tests incl. the ticket-1245791632490 overstay-not-zero regression (40 pass). i18n lab.* + nav.tariffLab (sq+en). Verified live via the UI. Wiki: tariff (priceSession + Tariff Lab as-built), log. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
fetchTariff,
|
|
loadSimSession,
|
|
simulateTariff,
|
|
type SimulateResult,
|
|
type SimPayment,
|
|
type TariffState,
|
|
} from "./api.js";
|
|
import { formatMoney, formatDuration } from "./lib/format.js";
|
|
|
|
// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight
|
|
// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY
|
|
// published tariff version, with no real ledger writes. Build a hypothetical session
|
|
// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any
|
|
// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and
|
|
// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md.
|
|
|
|
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
|
|
function toLocalInput(iso: string): string {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
/** A local datetime-local value → ISO-8601 (treats the value as local wall-clock). */
|
|
function fromLocalInput(v: string): string {
|
|
const d = new Date(v);
|
|
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
|
|
}
|
|
function nowLocal(): string {
|
|
return toLocalInput(new Date().toISOString());
|
|
}
|
|
|
|
export function TariffLab() {
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<TariffState | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
// Inputs (datetime-local strings, local wall-clock).
|
|
const [entered, setEntered] = useState<string>(() => {
|
|
const d = new Date();
|
|
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
|
|
return toLocalInput(d.toISOString());
|
|
});
|
|
const [asOf, setAsOf] = useState<string>(nowLocal);
|
|
const [category, setCategory] = useState("");
|
|
const [versionId, setVersionId] = useState<string>(""); // "" = active
|
|
// Optional single hypothetical payment (the latest grants the walk-back grace).
|
|
const [paid, setPaid] = useState(false);
|
|
const [paidAt, setPaidAt] = useState<string>(nowLocal);
|
|
const [graceMin, setGraceMin] = useState<string>("5");
|
|
// Load-a-real-ticket.
|
|
const [ticket, setTicket] = useState("");
|
|
const [loadMsg, setLoadMsg] = useState<string | null>(null);
|
|
|
|
const [result, setResult] = useState<SimulateResult | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchTariff()
|
|
.then(setState)
|
|
.catch((e) => setErr((e as Error).message));
|
|
}, []);
|
|
|
|
async function run() {
|
|
setErr(null);
|
|
setBusy(true);
|
|
try {
|
|
const payments: SimPayment[] = paid
|
|
? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
|
|
: [];
|
|
const r = await simulateTariff({
|
|
enteredAt: fromLocalInput(entered),
|
|
asOf: fromLocalInput(asOf),
|
|
payments,
|
|
category: category.trim() || undefined,
|
|
tariffVersionId: versionId || undefined,
|
|
});
|
|
setResult(r);
|
|
} catch (e) {
|
|
setErr((e as Error).message);
|
|
setResult(null);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function loadTicket() {
|
|
setLoadMsg(null);
|
|
setErr(null);
|
|
try {
|
|
const s = await loadSimSession(ticket.trim());
|
|
setEntered(toLocalInput(s.enteredAt));
|
|
setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal());
|
|
setCategory(s.category ?? "");
|
|
setVersionId(s.tariffVersionId ?? "");
|
|
const last = s.payments.at(-1);
|
|
if (last) {
|
|
setPaid(true);
|
|
setPaidAt(toLocalInput(last.paidAt));
|
|
setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : "");
|
|
} else {
|
|
setPaid(false);
|
|
}
|
|
setLoadMsg(t("lab.loaded", { id: s.identity }));
|
|
} catch (e) {
|
|
setErr((e as Error).message);
|
|
}
|
|
}
|
|
|
|
const currency = result?.currency ?? state?.active?.currency ?? "ALL";
|
|
|
|
return (
|
|
<section className="mx-auto max-w-3xl px-4 py-6">
|
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
|
|
<p className="hint mb-4">{t("lab.intro")}</p>
|
|
|
|
{/* Load a real ticket */}
|
|
<div className="card card-body mb-4 flex flex-wrap items-end gap-2">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="label">{t("lab.loadTicket")}</label>
|
|
<input
|
|
className="input w-56"
|
|
value={ticket}
|
|
onChange={(e) => setTicket(e.target.value)}
|
|
placeholder={t("lab.loadTicketPh")}
|
|
/>
|
|
</div>
|
|
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
|
{t("lab.load")}
|
|
</button>
|
|
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>}
|
|
</div>
|
|
|
|
{/* Hypothetical session inputs */}
|
|
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
|
<label className="label">{t("lab.tariffVersion")}</label>
|
|
<select className="input w-full max-w-md" value={versionId} onChange={(e) => setVersionId(e.target.value)}>
|
|
<option value="">{t("lab.activeVersion")}</option>
|
|
{state?.versions.map((v) => (
|
|
<option key={v.id} value={v.id}>
|
|
{new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
<label className="label">{t("lab.entered")}</label>
|
|
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
|
|
|
|
<label className="label">{t("lab.asOf")}</label>
|
|
<span className="flex items-center gap-2">
|
|
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
|
|
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}>
|
|
{t("lab.now")}
|
|
</button>
|
|
</span>
|
|
|
|
<label className="label">{t("lab.category")}</label>
|
|
<input
|
|
className="input w-40"
|
|
value={category}
|
|
onChange={(e) => setCategory(e.target.value)}
|
|
placeholder={t("lab.categoryPh")}
|
|
/>
|
|
|
|
<label className="label">{t("lab.payment")}</label>
|
|
<span className="flex flex-wrap items-center gap-2">
|
|
<label className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
|
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
|
|
{t("lab.paid")}
|
|
</label>
|
|
{paid && (
|
|
<>
|
|
<input
|
|
type="datetime-local"
|
|
className="input w-64"
|
|
value={paidAt}
|
|
onChange={(e) => setPaidAt(e.target.value)}
|
|
/>
|
|
<span className="text-term-muted">{t("lab.graceMin")}</span>
|
|
<input className="input w-20" value={graceMin} onChange={(e) => setGraceMin(e.target.value)} />
|
|
</>
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-center gap-3">
|
|
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
|
{busy ? t("lab.pricing") : t("lab.price")}
|
|
</button>
|
|
{err && <span className="text-[12px] text-term-red">{err}</span>}
|
|
</div>
|
|
|
|
{result && (
|
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
|
{/* Outcome */}
|
|
<div className="card card-body">
|
|
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
|
|
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[13px]">
|
|
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
|
|
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
|
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
|
<dd className="text-term-text">
|
|
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
|
|
{result.pricing.overstay && (
|
|
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[10px] uppercase text-term-red">
|
|
{t("lab.overstay")}
|
|
</span>
|
|
)}
|
|
{result.pricing.withinGrace && (
|
|
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[10px] uppercase text-term-green">
|
|
{t("lab.settled")}
|
|
</span>
|
|
)}
|
|
</dd>
|
|
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
|
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
|
|
{result.pricing.graceExpiresAt && (
|
|
<>
|
|
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
|
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
|
|
</>
|
|
)}
|
|
</dl>
|
|
</div>
|
|
|
|
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
|
<div className="card card-body">
|
|
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
|
|
<p className="hint mb-2">{t("lab.curveHint")}</p>
|
|
<table className="w-full text-[12px] tabular-nums">
|
|
<tbody>
|
|
{result.curve.map((c) => (
|
|
<tr key={c.minutes} className="border-b border-term-border/40">
|
|
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
|
|
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function labelMin(min: number): string {
|
|
if (min < 60) return `${min}m`;
|
|
if (min < 1440) return `${min / 60}h`;
|
|
return `${min / 1440}d`;
|
|
}
|