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
+170 -12
View File
@@ -764,6 +764,115 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
return Array.isArray(s.steps) && s.steps.length > 0;
}
// --- Fee breakdown (explainability) -------------------------------------------
// One line item per priced "reason": a run of same-priced increments, a window
// package occurrence, a stepped day total, a daily-cap clamp, or the entry grace.
// Produced by the SAME walk computeFee runs (an optional trace collector inside
// computeFeeV1/V2), so Σ item amounts ≡ the fee by construction — the breakdown can
// never tell a different story than the bill. Built for the Tariff Lab's "how is
// this sum produced" view (2026-07-06). Minutes are offsets from the priced
// period's start.
export type FeeBreakdownItem =
/** The whole stay fit inside the free entry-grace window (fee 0). */
| { readonly kind: "grace"; readonly minutes: number }
/** A contiguous run of increments billed at one unit price by one card.
* `card` is the windowed card's name, or null for the base/default rate. */
| {
readonly kind: "band";
readonly card: string | null;
readonly fromMin: number;
readonly toMin: number;
readonly increments: number;
readonly unitMinor: number;
readonly amountMinor: number;
}
/** One window-package occurrence (charged once per contiguous run the card wins). */
| { readonly kind: "package"; readonly card: string; readonly fromMin: number; readonly amountMinor: number }
/** A stepped ("up-to") day total: day N used `dayMinutes`, priced by the tier at
* `uptoMin` (`repeated` = past the top tier, so the top total repeats as a cap). */
| {
readonly kind: "step";
readonly day: number;
readonly dayMinutes: number;
readonly uptoMin: number;
readonly amountMinor: number;
readonly repeated: boolean;
}
/** The daily cap clamped day N: amountMinor is the (negative) adjustment. */
| { readonly kind: "cap"; readonly day: number; readonly capMinor: number; readonly amountMinor: number };
export interface FeeBreakdown {
/** Actual stay length in whole minutes (before increment rounding). */
readonly rawMinutes: number;
/** Minutes billed after rounding UP to the increment (0 within grace). */
readonly billedMinutes: number;
readonly incrementMin: number;
readonly items: FeeBreakdownItem[];
/** Σ item amounts — always equals computeFee for the same arguments. */
readonly totalMinor: number;
}
/**
* Explain a fee: run the exact computeFee walk with a trace collector and return
* the line items plus the total. Same arguments as computeFee; the total returned
* here IS computeFee's answer (one code path, not a parallel calculation).
*/
export function explainFee(
enteredAt: string,
asOf: string,
tariff: TariffStructure,
category?: string,
): FeeBreakdown {
const items: FeeBreakdownItem[] = [];
const totalMinor = isTariffV2(tariff)
? computeFeeV2(enteredAt, asOf, tariff, category, items)
: computeFeeV1(enteredAt, asOf, tariff, items);
const ms = Date.parse(asOf) - Date.parse(enteredAt);
const rawMinutes = Number.isFinite(ms) && ms > 0 ? Math.round(ms / 60_000) : 0;
const inc = Math.max(1, tariff.incrementMin);
const inGrace = items.length === 1 && items[0]!.kind === "grace";
const billedMinutes =
inGrace || rawMinutes === 0 || ms / 60_000 <= tariff.gracePeriodEntryMin
? 0
: Math.ceil(ms / 60_000 / inc) * inc;
return { rawMinutes, billedMinutes, incrementMin: inc, items, totalMinor };
}
/** Band-merging helper for the trace: accumulate consecutive increments that share
* a (card, unit price) and flush them as one `band` item. */
class BandTracer {
#card: string | null = null;
#unit = 0;
#from = 0;
#count = 0;
constructor(private readonly items: FeeBreakdownItem[], private readonly inc: number) {}
add(card: string | null, unitMinor: number, atMin: number): void {
if (this.#count > 0 && this.#card === card && this.#unit === unitMinor) {
this.#count++;
return;
}
this.flush();
this.#card = card;
this.#unit = unitMinor;
this.#from = atMin;
this.#count = 1;
}
flush(): void {
if (this.#count === 0) return;
this.items.push({
kind: "band",
card: this.#card,
fromMin: this.#from,
toMin: this.#from + this.#count * this.inc,
increments: this.#count,
unitMinor: this.#unit,
amountMinor: this.#count * this.#unit,
});
this.#count = 0;
}
}
/**
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
@@ -771,7 +880,7 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
*/
function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
function steppedFee(minutes: number, steps: readonly TariffStep[], trace?: FeeBreakdownItem[]): number {
if (minutes <= 0 || steps.length === 0) return 0;
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
const top = sorted[sorted.length - 1]!;
@@ -780,8 +889,17 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
const found = sorted.find((s) => dayMin <= s.uptoMin);
const tier = found ?? top;
total += tier.totalMinor;
trace?.push({
kind: "step",
day: dayStart / DAY + 1,
dayMinutes: dayMin,
uptoMin: tier.uptoMin,
amountMinor: tier.totalMinor,
repeated: found == null,
});
}
return total;
}
@@ -791,30 +909,50 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
* identically. Do not "unify" this into the V2 path: a rounding divergence would
* corrupt repricing of already-signed sessions. A `steps` table (when present)
* REPLACES the ladder via {@link steppedFee}. */
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
function computeFeeV1(
enteredAt: string,
asOf: string,
tariff: TariffStructureV1,
trace?: FeeBreakdownItem[],
): number {
const ms = Date.parse(asOf) - Date.parse(enteredAt);
if (!Number.isFinite(ms) || ms <= 0) return 0;
const rawMinutes = ms / 60_000;
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
// 60 min — otherwise rounding-up would defeat the grace window).
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
if (rawMinutes <= tariff.gracePeriodEntryMin) {
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
return 0;
}
const inc = Math.max(1, tariff.incrementMin);
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!);
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!, trace);
const DAY = 24 * 60;
let total = 0;
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0;
const bands = trace ? new BandTracer(trace, inc) : null;
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
for (let within = 0; segStart + within < segEnd; within += inc) {
segFee += rateAt(tariff.blocks, within);
const unit = rateAt(tariff.blocks, within);
segFee += unit;
bands?.add(null, unit, segStart + within);
}
bands?.flush();
if (tariff.dailyCapMinor != null && segFee > tariff.dailyCapMinor) {
trace?.push({
kind: "cap",
day: segStart / DAY + 1,
capMinor: tariff.dailyCapMinor,
amountMinor: tariff.dailyCapMinor - segFee,
});
segFee = tariff.dailyCapMinor;
}
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
total += segFee;
}
return total;
@@ -837,12 +975,16 @@ function computeFeeV2(
asOf: string,
tariff: TariffStructureV2,
category?: string,
trace?: FeeBreakdownItem[],
): number {
const enteredMs = Date.parse(enteredAt);
const ms = Date.parse(asOf) - enteredMs;
if (!Number.isFinite(ms) || ms <= 0) return 0;
const rawMinutes = ms / 60_000;
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
if (rawMinutes <= tariff.gracePeriodEntryMin) {
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
return 0; // grace on RAW duration (V1 rule)
}
const inc = Math.max(1, tariff.incrementMin);
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
@@ -862,7 +1004,11 @@ function computeFeeV2(
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
// windowed cards (they have nothing to override at the increment level). This is the
// only sound place for steps in V2. See wiki/concepts/tariff.md.
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!, trace);
// Trace labels: the defaultCard reads as the base rate (null), a windowed card by
// its name.
const traceName = (card: TariffCard): string | null => (card === tariff.defaultCard ? null : card.name);
let total = 0;
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
@@ -876,21 +1022,33 @@ function computeFeeV2(
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0;
const bands = trace ? new BandTracer(trace, inc) : null;
for (let within = segStart; within < segEnd; within += inc) {
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
const card = selectCard(cards, wall);
if (card.packageMinor != null) {
// First increment of a new occurrence pays the package; the rest ride free.
if (prevWinner !== card) segFee += card.packageMinor;
if (prevWinner !== card) {
segFee += card.packageMinor;
bands?.flush();
trace?.push({ kind: "package", card: card.name, fromMin: within, amountMinor: card.packageMinor });
}
} else if (card.flatMinor != null) {
segFee += card.flatMinor;
bands?.add(traceName(card), card.flatMinor, within);
} else {
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
segFee += rateAt(card.blocks ?? [], within - segStart);
const unit = rateAt(card.blocks ?? [], within - segStart);
segFee += unit;
bands?.add(traceName(card), unit, within);
}
prevWinner = card;
}
if (dayCap != null) segFee = Math.min(segFee, dayCap);
bands?.flush();
if (dayCap != null && segFee > dayCap) {
trace?.push({ kind: "cap", day: segStart / DAY + 1, capMinor: dayCap, amountMinor: dayCap - segFee });
segFee = dayCap;
}
total += segFee;
}
return total;
+95
View File
@@ -1,8 +1,11 @@
import { describe, it, expect } from "vitest";
import {
computeFee,
explainFee,
priceSession,
validateTariffStructure,
type FeeBreakdownItem,
type TariffStructure,
type TariffStructureV1,
type TariffStructureV2,
type TariffCard,
@@ -437,3 +440,95 @@ describe("V2 window package (whole-window total)", () => {
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
});
});
describe("explainFee — the breakdown IS the fee (2026-07-06)", () => {
const v1: TariffStructure = {
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
blocks: [
{ uptoMin: 120, priceMinorPerIncrement: 200 },
{ uptoMin: null, priceMinorPerIncrement: 100 },
],
dailyCapMinor: 500,
};
const sum = (b: ReturnType<typeof explainFee>) => b.items.reduce((a, i) => a + ("amountMinor" in i ? i.amountMinor : 0), 0);
it("V1 ladder: bands merge per rate, the cap shows as a negative line, sum == fee", () => {
const from = "2026-07-06T08:00:00.000Z";
const to = "2026-07-06T15:02:00.000Z"; // 7h2m → 8 increments: 2×200 + 6×100 = 1000 → cap 500
const b = explainFee(from, to, v1);
expect(b.totalMinor).toBe(computeFee(from, to, v1));
expect(b.totalMinor).toBe(500);
expect(sum(b)).toBe(b.totalMinor);
expect(b.items.map((i) => i.kind)).toEqual(["band", "band", "cap"]);
const [first, second, cap] = b.items as [
Extract<FeeBreakdownItem, { kind: "band" }>,
Extract<FeeBreakdownItem, { kind: "band" }>,
Extract<FeeBreakdownItem, { kind: "cap" }>,
];
expect([first.increments, first.unitMinor, first.amountMinor]).toEqual([2, 200, 400]);
expect([second.increments, second.unitMinor, second.amountMinor]).toEqual([6, 100, 600]);
expect(cap.amountMinor).toBe(-500);
expect(b.billedMinutes).toBe(480);
expect(b.rawMinutes).toBe(422);
});
it("grace: one zero line, billed 0", () => {
const b = explainFee("2026-07-06T08:00:00.000Z", "2026-07-06T08:04:00.000Z", v1);
expect(b.items).toEqual([{ kind: "grace", minutes: 4 }]);
expect(b.totalMinor).toBe(0);
expect(b.billedMinutes).toBe(0);
});
it("stepped: one line per rolling day, top tier repeats flagged", () => {
const stepped: TariffStructure = {
...v1,
blocks: [],
dailyCapMinor: null,
steps: [
{ uptoMin: 180, totalMinor: 500 },
{ uptoMin: 1440, totalMinor: 1000 },
],
};
const from = "2026-07-04T08:00:00.000Z";
const to = "2026-07-05T10:00:00.000Z"; // 26h → day1 top(1000) + day2 ≤180 (500)
const b = explainFee(from, to, stepped);
expect(b.totalMinor).toBe(computeFee(from, to, stepped));
expect(sum(b)).toBe(b.totalMinor);
expect(b.items).toEqual([
{ kind: "step", day: 1, dayMinutes: 1440, uptoMin: 1440, amountMinor: 1000, repeated: false },
{ kind: "step", day: 2, dayMinutes: 120, uptoMin: 180, amountMinor: 500, repeated: false },
]);
});
it("V2 night package + base ladder: package is one line, bands name the card, sum == fee", () => {
const v2: TariffStructure = {
version: 2,
tz: "Europe/Tirane",
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 10000 }], dailyCapMinor: null },
windowedCards: [
{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 },
],
};
// 18:00 → 22:30 local (16:00Z→20:30Z in July, UTC+2): 2 base hours + the night package.
const from = "2026-07-06T16:00:00.000Z";
const to = "2026-07-06T20:30:00.000Z";
const b = explainFee(from, to, v2);
expect(b.totalMinor).toBe(computeFee(from, to, v2));
expect(sum(b)).toBe(b.totalMinor);
expect(b.totalMinor).toBe(2 * 10000 + 40000);
expect(b.items).toEqual([
{ kind: "band", card: null, fromMin: 0, toMin: 120, increments: 2, unitMinor: 10000, amountMinor: 20000 },
{ kind: "package", card: "night", fromMin: 120, amountMinor: 40000 },
]);
});
});