import { describe, it, expect } from "vitest"; import { computeFee, priceSession, validateTariffStructure, type TariffStructureV1, type TariffStructureV2, type TariffCard, } from "./index.js"; const entered = "2026-06-18T00:00:00.000Z"; const at = (min: number) => new Date(Date.parse(entered) + min * 60_000).toISOString(); // --------------------------------------------------------------------------- // (a) GOLDEN V1 regression — the live production structure must reprice to these // exact integers. Captured from the pre-V2 engine. This is the most important // test: it proves a signed historical session reprices identically. // --------------------------------------------------------------------------- const liveV1: TariffStructureV1 = { gracePeriodEntryMin: 5, incrementMin: 60, blocks: [ { uptoMin: 60, priceMinorPerIncrement: 20000 }, { uptoMin: 180, priceMinorPerIncrement: 10000 }, ], dailyCapMinor: 100000, lostTicketMinor: 100000, gracePeriodExitMin: 5, overstay: "reprice", }; describe("V1 golden regression", () => { const golden: Record = { 3: 0, 30: 20000, 60: 20000, 61: 30000, 120: 30000, 180: 40000, 181: 50000, 240: 50000, 1440: 100000, 1500: 120000, 2880: 200000, }; for (const [min, want] of Object.entries(golden)) { it(`${min} min → ${want}`, () => { expect(computeFee(entered, at(Number(min)), liveV1)).toBe(want); }); } it("a V1 structure ignores the category argument", () => { expect(computeFee(entered, at(120), liveV1, "bus")).toBe(30000); }); }); // --------------------------------------------------------------------------- // V2 building blocks // --------------------------------------------------------------------------- const ladder = (open: number, first?: { uptoMin: number; rate: number }) => first ? [{ uptoMin: first.uptoMin, priceMinorPerIncrement: first.rate }, { uptoMin: null, priceMinorPerIncrement: open }] : [{ uptoMin: null, priceMinorPerIncrement: open }]; const defaultCard: TariffCard = { name: "default", priority: 0, blocks: ladder(20000), // flat 200/h ladder (open-ended) dailyCapMinor: null, }; function v2(windowedCards: TariffCard[], tz = "Europe/Tirane", over: Partial = {}): TariffStructureV2 { return { version: 2, tz, gracePeriodEntryMin: 5, incrementMin: 60, lostTicketMinor: 100000, gracePeriodExitMin: 5, overstay: "reprice", defaultCard, windowedCards, ...over, }; } describe("V2 back-compat: a V2 with no windowed cards prices like its default ladder", () => { it("default-only V2 == equivalent V1", () => { const s = v2([]); // 200/h flat ladder, 3h expect(computeFee(entered, at(180), s)).toBe(60000); }); }); describe("V2 time-of-day window (happy hour)", () => { // Tirane is UTC+2 in June (DST). entered 00:00Z = 02:00 local. // Happy hour 04:00–06:00 local = 02:00–04:00Z. Default 200/h, happy 50/h. const happy: TariffCard = { name: "happy", priority: 10, window: { fromHour: "04:00", toHour: "06:00" }, blocks: ladder(5000), }; const s = v2([happy]); it("a stay crossing into happy hour bills each increment by its wall-clock card", () => { // 0-120min elapsed = local 02:00-04:00 (default 200/h ×2 = 400), // 120-240min = local 04:00-06:00 (happy 50/h ×2 = 100). Total 500 = 50000. expect(computeFee(entered, at(240), s)).toBe(50000); }); it("a stay entirely before happy hour is all default", () => { expect(computeFee(entered, at(120), s)).toBe(40000); // 2h × 200 }); }); describe("V2 overnight wrap window", () => { // night 22:00→06:00 local (wraps midnight), cheap 50/h. const night: TariffCard = { name: "night", priority: 10, window: { fromHour: "22:00", toHour: "06:00" }, blocks: ladder(5000), }; const s = v2([night]); it("an early-morning stay (local 02:00-04:00) is inside the wrap → night rate", () => { expect(computeFee(entered, at(120), s)).toBe(10000); // 2h × 50 }); }); describe("V2 day-of-week tested at the increment's wall-clock day", () => { // 2026-06-18 is a Thursday (dow 4). A Friday-only card must NOT apply. const friOnly: TariffCard = { name: "fri", priority: 10, window: { dow: [5] }, blocks: ladder(5000) }; it("Thursday stay does not get the Friday card", () => { expect(computeFee(entered, at(120), v2([friOnly]))).toBe(40000); // default 200×2 }); const thuOnly: TariffCard = { name: "thu", priority: 10, window: { dow: [4] }, blocks: ladder(5000) }; it("Thursday stay gets the Thursday card", () => { expect(computeFee(entered, at(120), v2([thuOnly]))).toBe(10000); // 50×2 }); }); describe("V2 flat card", () => { const flatNight: TariffCard = { name: "flat", priority: 10, window: { fromHour: "00:00", toHour: "23:59" }, // effectively all day here flatMinor: 3000, }; it("flat card charges flatMinor per increment", () => { expect(computeFee(entered, at(180), v2([flatNight]))).toBe(9000); // 3h × 30 }); }); describe("V2 category filter", () => { const busCard: TariffCard = { name: "bus", priority: 10, category: "bus", blocks: ladder(40000) }; const s = v2([busCard]); it("a bus session uses the bus card (400/h)", () => { expect(computeFee(entered, at(120), s, "bus")).toBe(80000); }); it("a car session ignores the bus card → default (200/h)", () => { expect(computeFee(entered, at(120), s, "car")).toBe(40000); }); it("no category given ignores the bus card → default", () => { expect(computeFee(entered, at(120), s)).toBe(40000); }); }); describe("V2 daily cap uses the DEFAULT card's cap on a mixed day", () => { // default cap 1000/day; a cheap night card present. 24h elapsed. const night: TariffCard = { name: "night", priority: 10, window: { fromHour: "22:00", toHour: "06:00" }, blocks: ladder(5000) }; const s = v2([night], "Europe/Tirane", { defaultCard: { ...defaultCard, dailyCapMinor: 100000 } }); it("a 24h stay is capped at the default card's 1000/day", () => { expect(computeFee(entered, at(1440), s)).toBe(100000); }); }); describe("V2 precedence is total + order-independent", () => { // Specificity order is date > dow > hour-only (see plan / tariff-time-tiers.md). // So a dow-constrained card beats an hour-only card at an overlapping instant. const dowCard: TariffCard = { name: "a-dow", priority: 5, window: { dow: [4] }, blocks: ladder(10000) }; // Thu, 100/h const hourCard: TariffCard = { name: "b-hour", priority: 5, window: { fromHour: "02:00", toHour: "04:00" }, blocks: ladder(5000) }; // local 02-04, 50/h it("dow (more specific than hour-only) wins at an overlapping instant", () => { // local 02:00-04:00 = elapsed 0-120; both match, dow ranks above hour → 100/h expect(computeFee(entered, at(120), v2([dowCard, hourCard]))).toBe(20000); }); it("a date window beats a dow window (date is most specific)", () => { const dateCard: TariffCard = { name: "c-date", priority: 1, window: { dateFrom: "2026-06-18", dateTo: "2026-06-18" }, blocks: ladder(5000) }; // 50/h // date beats dow even with LOWER priority (specificity dominates priority) expect(computeFee(entered, at(120), v2([dowCard, dateCard]))).toBe(10000); }); it("fee is identical when windowedCards order is shuffled", () => { const a = computeFee(entered, at(120), v2([dowCard, hourCard])); const b = computeFee(entered, at(120), v2([hourCard, dowCard])); expect(a).toBe(b); }); }); describe("V2 DST determinism (Europe/Tirane)", () => { // Spring forward 2026-03-29 03:00 local (clocks 02:00→03:00). Fall back 2026-10-25. const cheap: TariffCard = { name: "c", priority: 10, window: { fromHour: "00:00", toHour: "23:59" }, flatMinor: 1000 }; it("a stay across the spring-forward boundary prices deterministically", () => { const e = "2026-03-29T00:00:00.000Z"; // 01:00 local pre-jump const a1 = computeFee(e, new Date(Date.parse(e) + 240 * 60_000).toISOString(), v2([cheap])); const a2 = computeFee(e, new Date(Date.parse(e) + 240 * 60_000).toISOString(), v2([cheap])); expect(a1).toBe(a2); // determinism expect(a1).toBe(4000); // 4h × flat 10 }); }); // --------------------------------------------------------------------------- // (e) validation accept/reject matrix // --------------------------------------------------------------------------- describe("validate V1 (unchanged messages)", () => { it("accepts the live structure", () => { expect(validateTariffStructure({ ...liveV1, blocks: [...liveV1.blocks, { uptoMin: null, priceMinorPerIncrement: 5000 }] })).toEqual([]); }); it("rejects a bounded last block", () => { expect(validateTariffStructure(liveV1)).toContain( "the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly", ); }); }); describe("validate V2", () => { const okDefault: TariffCard = { name: "d", priority: 0, blocks: ladder(20000) }; const base = { version: 2 as const, tz: "Europe/Tirane", gracePeriodEntryMin: 5, incrementMin: 60, lostTicketMinor: 0, gracePeriodExitMin: 5, overstay: "reprice" as const }; it("accepts a minimal default-only V2", () => { expect(validateTariffStructure({ ...base, defaultCard: okDefault })).toEqual([]); }); it("requires tz when windowedCards present", () => { const errs = validateTariffStructure({ ...base, tz: "", defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { dow: [1] }, blocks: ladder(5000) }] }); expect(errs).toContain("tz (IANA timezone) is required when windowedCards are present"); }); it("rejects a card with both flat and blocks", () => { const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } }); expect(errs).toContain("defaultCard must set exactly one of flatMinor, blocks, or steps"); }); it("rejects defaultCard with a window", () => { const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } }); expect(errs).toContain("defaultCard must not have a window (it is the always-active fallback)"); }); it("rejects a STEPPED base card combined with windowed tiers (they would be ignored)", () => { const steppedDefault: TariffCard = { name: "d", priority: 0, steps: [{ uptoMin: 60, totalMinor: 200 }] }; const errs = validateTariffStructure({ ...base, defaultCard: steppedDefault, windowedCards: [{ name: "night", priority: 1, window: { dow: [1] }, blocks: ladder(5000) }], }); expect(errs.some((e) => /up-to-duration \(stepped\) base/.test(e))).toBe(true); }); it("accepts a STEPPED base card with NO tiers", () => { const steppedDefault: TariffCard = { name: "d", priority: 0, steps: [{ uptoMin: 60, totalMinor: 200 }] }; expect(validateTariffStructure({ ...base, defaultCard: steppedDefault })).toEqual([]); }); it("rejects a bad hour format", () => { const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { fromHour: "25:00", toHour: "26:00" }, blocks: ladder(5000) }] }); expect(errs.some((e) => e.includes("fromHour"))).toBe(true); }); it("rejects ambiguous precedence (equal specificity+priority, overlapping)", () => { const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [ { name: "x", priority: 5, window: { dow: [1, 2] }, blocks: ladder(5000) }, { name: "y", priority: 5, window: { dow: [2, 3] }, blocks: ladder(6000) }, ], }); expect(errs.some((e) => e.includes("higher priority to break the tie"))).toBe(true); }); it("allows the tie to be broken by priority", () => { const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [ { name: "x", priority: 5, window: { dow: [1, 2] }, blocks: ladder(5000) }, { name: "y", priority: 6, window: { dow: [2, 3] }, blocks: ladder(6000) }, ], }); expect(errs).toEqual([]); }); }); // --------------------------------------------------------------------------- // (h) priceSession — the grace/overstay wrapper shared by the booth + Tariff Lab. // liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h), // daily cap 100000, exit grace 5 min. // --------------------------------------------------------------------------- describe("priceSession grace + overstay", () => { const paidAt = (min: number) => at(min); it("unpaid → bills entry→asOf (running total)", () => { const r = priceSession(entered, at(120), liveV1, []); expect(r.overstay).toBe(false); expect(r.withinGrace).toBe(false); expect(r.periodStart).toBe(entered); expect(r.amountMinor).toBe(30000); // 2h: 20000 + 10000 }); it("paid and still within walk-back grace → settled (owes 0)", () => { // Paid at 120 min with a 5-min grace; asOf 123 min is inside the window. const r = priceSession(entered, at(123), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]); expect(r.withinGrace).toBe(true); expect(r.overstay).toBe(false); expect(r.amountMinor).toBe(0); }); it("paid but grace expired → overstay priced as a NEW period from grace-expiry", () => { // Paid at 120 min, grace 5 → expires at 125 min. asOf 245 min ⇒ a 2h new period. const r = priceSession(entered, at(245), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]); expect(r.overstay).toBe(true); expect(r.withinGrace).toBe(false); expect(r.periodStart).toBe(at(125)); // The new period is its own ladder from 0: 2h ⇒ 20000 + 10000 = 30000. expect(r.amountMinor).toBe(30000); }); it("overstay does NOT collapse to 0 under a daily cap (regression: ticket 1245791632490)", () => { // A ~2-day overstay: with 'full stay minus paid' the cap made this 0. The // new-period model re-accrues — strictly positive. const r = priceSession(entered, at(120 + 5 + 2880), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]); expect(r.overstay).toBe(true); expect(r.amountMinor).toBe(200000); // 2 capped days }); }); // --------------------------------------------------------------------------- // (i) STEPPED ("up-to") pricing — the owner's total-by-duration matrix. // 0-1h=200, 0-3h=500, 0-6h=800, 0-9h=900, 0-12h=1000. Beyond 12h, the top total // (1000) repeats as a per-day price. Boundary is <= (inclusive). // --------------------------------------------------------------------------- const stepped: TariffStructureV1 = { gracePeriodEntryMin: 5, incrementMin: 60, blocks: [], // ignored when steps present steps: [ { uptoMin: 60, totalMinor: 200 }, { uptoMin: 180, totalMinor: 500 }, { uptoMin: 360, totalMinor: 800 }, { uptoMin: 540, totalMinor: 900 }, { uptoMin: 720, totalMinor: 1000 }, ], dailyCapMinor: null, lostTicketMinor: 100000, gracePeriodExitMin: 5, overstay: "reprice", }; describe("stepped (up-to) pricing — owner matrix", () => { const cases: Record = { "3": 0, // within entry grace → free "30": 200, // ≤ 1h "60": 200, // exactly 1h (inclusive) "61": 500, // into the 3h tier "180": 500, // exactly 3h "181": 800, // into the 6h tier "360": 800, // exactly 6h "540": 900, // exactly 9h "720": 1000, // exactly 12h }; for (const [min, want] of Object.entries(cases)) { it(`${min} min → ${want}`, () => { expect(computeFee(entered, at(Number(min)), stepped)).toBe(want); }); } it("beyond the top tier the day's total is the top tier (daily-cap behaviour)", () => { // 13h is past the 12h top tier but still within ONE rolling day → top total 1000 // (the top tier is that day's ceiling; it does NOT restart a new tier cycle). expect(computeFee(entered, at(13 * 60), stepped)).toBe(1000); // exactly 24h = one full day at the top total expect(computeFee(entered, at(24 * 60), stepped)).toBe(1000); // 25h = day1 ceiling (1000) + 1h into day2 (200) = 1200 expect(computeFee(entered, at(25 * 60), stepped)).toBe(1200); // 26h = 1000 + (2h → ≤180min tier = 500) = 1500 expect(computeFee(entered, at(26 * 60), stepped)).toBe(1500); }); it("priceSession routes overstay through the stepped engine too", () => { // paid at 120, grace 5 → expires 125; asOf = 125 + 180 (3h new period) → 500 const r = priceSession(entered, at(125 + 180), stepped, [{ paidAt: at(120), graceExitMin: 5 }]); expect(r.overstay).toBe(true); expect(r.amountMinor).toBe(500); }); it("validates: a stepped V1 is valid; non-ascending uptoMin is rejected", () => { expect(validateTariffStructure(stepped)).toEqual([]); const bad = { ...stepped, steps: [{ uptoMin: 180, totalMinor: 500 }, { uptoMin: 60, totalMinor: 200 }] }; expect(validateTariffStructure(bad).length).toBeGreaterThan(0); }); it("rejects a daily cap combined with steps", () => { const capped = { ...stepped, dailyCapMinor: 100000 }; expect(validateTariffStructure(capped).some((e) => /dailyCap/i.test(e))).toBe(true); }); });