From 7649b897c4ecfebcd578e74feaaa336be1793f14 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 6 Jul 2026 15:41:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(tariff):=20lab=20explains=20the=20sum=20?= =?UTF-8?q?=E2=80=94=20fee=20breakdown=20from=20the=20engine=20walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 --- apps/server/src/routes/tariffs.ts | 9 +- apps/web/src/TariffLab.tsx | 102 ++++++++++++++-- apps/web/src/api.ts | 3 + packages/shared/src/index.ts | 182 +++++++++++++++++++++++++++-- packages/shared/src/tariff.test.ts | 95 +++++++++++++++ 5 files changed, 370 insertions(+), 21 deletions(-) diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts index 30ac935..bfb09d4 100644 --- a/apps/server/src/routes/tariffs.ts +++ b/apps/server/src/routes/tariffs.ts @@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify"; import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db"; import { computeFee, + explainFee, isTariffV2, priceSession, validateTariffStructure, @@ -188,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise const payments = Array.isArray(b.payments) ? b.payments : []; const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category); + // HOW the amount is produced — the same engine walk with a trace collector + // (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed). + const breakdown = pricing.withinGrace + ? null + : explainFee(pricing.periodStart, b.asOf, structure, b.category); + // A duration curve from entry: handy to SEE where the cap flattens / windows shift. const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320]; const enteredMs = Date.parse(b.enteredAt); @@ -196,7 +203,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category), })); - return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin }; + return { currency, pricing, breakdown, curve, gracePeriodExitMin: structure.gracePeriodExitMin }; }); // Prefill the lab from a REAL session: fold its ledger into entry + payments so the diff --git a/apps/web/src/TariffLab.tsx b/apps/web/src/TariffLab.tsx index 9e72c96..862dfc0 100644 --- a/apps/web/src/TariffLab.tsx +++ b/apps/web/src/TariffLab.tsx @@ -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")} {selectedDraft && ( @@ -264,14 +266,19 @@ export function TariffLab() { )}
{t("lab.periodStart")}
-
{new Date(result.pricing.periodStart).toLocaleString()}
+
{formatDateTime(result.pricing.periodStart, t)}
{result.pricing.graceExpiresAt && ( <>
{t("lab.graceExpires")}
-
{new Date(result.pricing.graceExpiresAt).toLocaleString()}
+
{formatDateTime(result.pricing.graceExpiresAt, t)}
)} + {/* HOW the sum is produced — line items from the SAME engine walk + (their sum is the amount by construction). */} + {result.breakdown && ( + + )} {/* Duration curve from entry — see where the cap flattens / windows shift. */} @@ -316,7 +323,7 @@ export function TariffLab() { > {d.name} - {d.currency} · {new Date(d.updatedAt).toLocaleString()} + {d.currency} · {formatDateTime(d.updatedAt, t)} @@ -345,7 +352,7 @@ export function TariffLab() { {state?.active?.name ? ` — ${state.active.name}` : ""} - {state?.active ? new Date(state.active.effectiveFrom).toLocaleString() : t("tariff.noRateCard")} + {state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")} @@ -363,10 +370,10 @@ export function TariffLab() { }`} > - {v.name ?? new Date(v.effectiveFrom).toLocaleString()} + {v.name ?? formatDateTime(v.effectiveFrom, t)} - {v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""} + {v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""} {v.currency} @@ -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 ( +
+
{t("lab.bd.title")}
+ {b.billedMinutes > 0 && ( +

+ {t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })} +

+ )} + + + {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 ( + + + + + ); + })} + + + + + +
{label}{money(amount)}
{t("lab.bd.total")}{money(b.totalMinor)}
+
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 4eabc75..f569ae9 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a1092bb..10ab2be 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; diff --git a/packages/shared/src/tariff.test.ts b/packages/shared/src/tariff.test.ts index 3cc7de3..3b5971f 100644 --- a/packages/shared/src/tariff.test.ts +++ b/packages/shared/src/tariff.test.ts @@ -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) => 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, + Extract, + Extract, + ]; + 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 }, + ]); + }); +});