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:
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user