feat(tariff): lab explains the sum — fee breakdown from the engine walk

"ALL 740 / 3h 2m" gave no derivation. explainFee in @parking/shared runs
the EXACT computeFee walk with an optional trace collector — one code
path, so Σ line items ≡ the amount by construction (golden V1 regression
byte-identical; instrumentation changes no fee). Items: contiguous
same-price increment runs (time window · N × unit · tier-card name),
window-package occurrences, stepped day totals (top-tier repeat
flagged), daily-cap clamps as NEGATIVE adjustments, entry grace.

/api/tariff/simulate returns `breakdown` (null when settled); the lab's
Outcome panel renders the lined table with a rounding note (raw min →
billed min at the increment — answers "why does 3h 2m bill as 4h") and
a total row. Works against active/historical versions and drafts alike,
so a night-package draft can be verified line by line before publish.
Largely delivers the wiki's open "composer price preview" item.

4 new engine tests pin the sum invariant + item shapes (97 shared green).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-06 15:41:40 +02:00
parent ab968eb25e
commit 7649b897c4
5 changed files with 370 additions and 21 deletions
+94 -8
View File
@@ -15,7 +15,9 @@ import {
} from "./api.js";
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
import { Modal } from "./ui/Modal.js";
import { formatMoney, formatDuration } from "./lib/format.js";
import { formatClock, formatDateTime, formatMoney, formatDuration } from "./lib/format.js";
import type { FeeBreakdown } from "@parking/shared";
import type { TFunction } from "i18next";
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
// live in their own mutable table (tariff_drafts), so experimenting never churns the
@@ -202,7 +204,7 @@ export function TariffLab() {
{selectedDraft
? selectedDraft.name
: selectedVersion
? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
: t("lab.activeTariff")}
</span>
{selectedDraft && (
@@ -264,14 +266,19 @@ export function TariffLab() {
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
<dd className="text-term-text">{formatDateTime(result.pricing.periodStart, t)}</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>
<dd className="text-term-text">{formatDateTime(result.pricing.graceExpiresAt, t)}</dd>
</>
)}
</dl>
{/* HOW the sum is produced — line items from the SAME engine walk
(their sum is the amount by construction). */}
{result.breakdown && (
<BreakdownTable b={result.breakdown} periodStart={result.pricing.periodStart} currency={currency} t={t} />
)}
</div>
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
@@ -316,7 +323,7 @@ export function TariffLab() {
>
<span className="block font-semibold">{d.name}</span>
<span className="block text-[0.6875rem] text-term-muted">
{d.currency} · {new Date(d.updatedAt).toLocaleString()}
{d.currency} · {formatDateTime(d.updatedAt, t)}
</span>
</button>
</li>
@@ -345,7 +352,7 @@ export function TariffLab() {
{state?.active?.name ? ` — ${state.active.name}` : ""}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{state?.active ? new Date(state.active.effectiveFrom).toLocaleString() : t("tariff.noRateCard")}
{state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")}
</span>
</button>
</li>
@@ -363,10 +370,10 @@ export function TariffLab() {
}`}
>
<span className="block font-semibold">
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
{v.name ?? formatDateTime(v.effectiveFrom, t)}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
{v.currency}
</span>
</button>
@@ -416,3 +423,82 @@ function labelMin(min: number): string {
if (min < 1440) return `${min / 60}h`;
return `${min / 1440}d`;
}
/** The fee's line items — every row states its time window / rule and its amount, so
* the operator can retrace the exact sum (caps show as negative adjustments). */
function BreakdownTable({
b,
periodStart,
currency,
t,
}: {
b: FeeBreakdown;
periodStart: string;
currency: string;
t: TFunction;
}) {
const startMs = Date.parse(periodStart);
const multiDay = b.billedMinutes > 1440;
const at = (min: number) => {
const iso = new Date(startMs + min * 60_000).toISOString();
return multiDay ? formatDateTime(iso, t) : formatClock(iso);
};
const money = (m: number) => formatMoney(m, currency);
const hours = (min: number) => (min % 60 === 0 ? `${min / 60}` : (min / 60).toFixed(1));
return (
<div className="mt-3 border-t border-term-border pt-2">
<div className="mb-1 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("lab.bd.title")}</div>
{b.billedMinutes > 0 && (
<p className="hint mb-1.5">
{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}
</p>
)}
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{b.items.map((it, i) => {
let label: string;
let amount: number;
let cls = "text-term-text";
switch (it.kind) {
case "grace":
label = t("lab.bd.grace", { min: it.minutes });
amount = 0;
cls = "text-term-green";
break;
case "band":
label = `${at(it.fromMin)}–${at(it.toMin)} · ${it.increments} × ${money(it.unitMinor)}${it.card ? ` · ${it.card}` : ""}`;
amount = it.amountMinor;
break;
case "package":
label = `${at(it.fromMin)} · ${it.card} — ${t("lab.bd.package")}`;
amount = it.amountMinor;
break;
case "step":
label = it.repeated
? t("lab.bd.stepRepeated", { day: it.day })
: t("lab.bd.step", { day: it.day, hours: hours(it.uptoMin) });
amount = it.amountMinor;
break;
case "cap":
label = t("lab.bd.cap", { day: it.day, cap: money(it.capMinor) });
amount = it.amountMinor;
cls = "text-term-red";
break;
}
return (
<tr key={i} className="border-b border-term-border/40">
<td className="py-0.5 pr-2 text-term-muted">{label}</td>
<td className={`whitespace-nowrap py-0.5 text-right ${cls}`}>{money(amount)}</td>
</tr>
);
})}
<tr>
<td className="py-1 pr-2 font-semibold text-term-text">{t("lab.bd.total")}</td>
<td className="whitespace-nowrap py-1 text-right font-semibold text-term-cyan">{money(b.totalMinor)}</td>
</tr>
</tbody>
</table>
</div>
);
}
+3
View File
@@ -762,6 +762,9 @@ export interface SimSessionPricing {
export interface SimulateResult {
currency: string | null;
pricing: SimSessionPricing;
/** Line items explaining pricing.amountMinor (same engine walk, Σ ≡ amount);
* null when the session is settled (within walk-back grace). */
breakdown: import("@parking/shared").FeeBreakdown | null;
curve: { minutes: number; amountMinor: number }[];
gracePeriodExitMin: number;
}