Files
parking_solution/packages/shared/src/tariff.test.ts
T
julian 692dff5f89 feat(validations): merchant (bar/lavazh) ticket validations end-to-end
In-park merchants discharge customers' parking: a merchant user scans the
ticket on their device (/validate; validation:create + program↔user binding)
and applies their program — comp / first-N-minutes free / amount-off (capped,
typed at scan) / percent. All money stays at the booth: the quote folds live
validations in a canonical order (timeCredit → percent → fixed → comp, net
floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and
CONSUMES the validation ids (an overstay's fresh period never re-applies
them), the receipt prints the gross → lines → net story, and the Z/X-report
carries discountTotalMinor leakage. Every apply/void is a signed, attributed
ledger event (refId = append-only void); program config is /setup/site master
data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves
sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route
integration tests + priceSession fold suite.

See wiki/concepts/validation-discounts.md for the full design record.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:49:58 +02:00

636 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from "vitest";
import {
computeFee,
explainFee,
priceSession,
validateTariffStructure,
type FeeBreakdownItem,
type TariffStructure,
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<number, number> = {
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> = {}): 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, steps, or packageMinor");
});
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<string, number> = {
"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);
});
});
// ---------------------------------------------------------------------------
// (j) WINDOW PACKAGE (packageMinor) — "any presence in the window = one total".
// Charged once per contiguous occurrence of the card winning increments; any touch
// pays the full package; a run crossing the rolling-24h boundary charges ONCE.
// Base: open-ended 100/h ladder (minor 10000). Night card: 20:00–07:00 = 40000.
// tz Europe/Tirane (summer = UTC+2); windows carry no dow so weekday is irrelevant.
// ---------------------------------------------------------------------------
describe("V2 window package (whole-window total)", () => {
const pkg: TariffStructureV2 = {
version: 2,
tz: "Europe/Tirane",
gracePeriodEntryMin: 0,
incrementMin: 60,
lostTicketMinor: 0,
gracePeriodExitMin: 5,
overstay: "reprice",
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 10000 }], dailyCapMinor: null },
windowedCards: [{ name: "night", priority: 10, packageMinor: 40000, window: { fromHour: "20:00", toHour: "07:00" } }],
};
const fee = (enter: string, exit: string) => computeFee(enter, exit, pkg);
it("leave early, the package stays: 22:00→23:30 (90 min in-window) = 40000", () => {
expect(fee("2026-06-16T22:00:00+02:00", "2026-06-16T23:30:00+02:00")).toBe(40000);
});
it("any touch pays full: 06:00→06:45 (45 min at the window's tail) = 40000", () => {
expect(fee("2026-06-16T06:00:00+02:00", "2026-06-16T06:45:00+02:00")).toBe(40000);
});
it("increments inside one occurrence add nothing: a full night 20:00→07:00 = 40000", () => {
expect(fee("2026-06-16T20:00:00+02:00", "2026-06-17T07:00:00+02:00")).toBe(40000);
});
it("mixed 29h stay: two occurrences + day hours; the run over the rolling-day boundary charges ONCE", () => {
// Enter Tue 02:00, exit Wed 07:00 (29 increments). Occurrence 1: 02:00–06:00 (the
// overnight window's tail) = 40000. Base: 07:00–19:00 = 13 × 10000. Occurrence 2:
// Tue 20:00 → Wed 06:00 — CROSSES the rolling-24h boundary (Wed 02:00) but is one
// contiguous run → one 40000, not two. Total 40000 + 130000 + 40000 = 210000.
expect(fee("2026-06-16T02:00:00+02:00", "2026-06-17T07:00:00+02:00")).toBe(210000);
});
it("validates: package on the defaultCard is rejected", () => {
const bad = { ...pkg, defaultCard: { name: "d", priority: 0, packageMinor: 40000 } };
expect(validateTariffStructure(bad).some((e) => /only allowed on a windowed card/.test(e))).toBe(true);
});
it("validates: package is exclusive with other pricing fields + the cap", () => {
const both = { ...pkg, windowedCards: [{ name: "n", priority: 1, packageMinor: 1, flatMinor: 1, window: { dow: [1] } }] };
expect(validateTariffStructure(both).some((e) => /exactly one of/.test(e))).toBe(true);
const capped = { ...pkg, windowedCards: [{ name: "n", priority: 1, packageMinor: 1, dailyCapMinor: 100, window: { dow: [1] } }] };
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 },
]);
});
});
// ---------------------------------------------------------------------------
// (i) Merchant validations — the priceSession discount fold (2026-07-13).
// liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h),
// daily cap 100000, exit grace 5 min. See wiki/concepts/validation-discounts.md.
// ---------------------------------------------------------------------------
describe("priceSession merchant validations", () => {
const val = (
mode: "comp" | "timeCredit" | "fixed" | "percent",
over: Partial<import("./index.js").SessionValidation> = {},
): import("./index.js").SessionValidation => ({
programId: "bar",
label: "Bar",
mode,
...over,
});
it("no validations → gross == net, no lines (back-compat)", () => {
const r = priceSession(entered, at(120), liveV1, []);
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(30000);
expect(r.discountMinor).toBe(0);
expect(r.validationLines).toEqual([]);
});
it("comp zeroes the fee and the line carries the whole gross", () => {
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("comp")]);
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(0);
expect(r.discountMinor).toBe(30000);
expect(r.validationLines).toEqual([{ programId: "bar", label: "Bar", mode: "comp", discountMinor: 30000 }]);
});
it("fixed subtracts, floors at 0, and clamps the line to the remainder", () => {
// 2h → 30000 gross; 300-off style: fixed 20000 → net 10000.
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 20000 })]);
expect(r.amountMinor).toBe(10000);
expect(r.discountMinor).toBe(20000);
// Bigger than the fee → net 0, line clamped to the gross (Σ lines ≡ gross − net).
const r2 = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 99999 })]);
expect(r2.amountMinor).toBe(0);
expect(r2.validationLines[0]!.discountMinor).toBe(30000);
});
it("timeCredit prices as if entered later — 'first hour free' is literal", () => {
// 2h stay, 60 free minutes → bill the remaining 1h at the FIRST block (20000),
// exactly what a 1h stay costs.
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("timeCredit", { minutes: 60 })]);
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(computeFee(at(60), at(120), liveV1));
expect(r.amountMinor).toBe(20000);
expect(r.validationLines[0]!.discountMinor).toBe(10000);
});
it("timeCredit covering the whole stay → net 0", () => {
const r = priceSession(entered, at(50), liveV1, [], undefined, [val("timeCredit", { minutes: 120 })]);
expect(r.amountMinor).toBe(0);
expect(r.discountMinor).toBe(r.grossMinor);
});
it("percent takes a floor'd share of the remaining fee", () => {
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("percent", { percent: 50 })]);
expect(r.amountMinor).toBe(15000);
expect(r.discountMinor).toBe(15000);
});
it("stacking is canonical-order (timeCredit → percent → fixed → comp) and Σ lines ≡ gross − net", () => {
// Scan order deliberately reversed; the fold must still do time first.
const r = priceSession(entered, at(120), liveV1, [], undefined, [
val("fixed", { amountMinor: 5000, programId: "bar" }),
val("timeCredit", { minutes: 60, programId: "lavazh", label: "Lavazh" }),
]);
// gross 30000 → time credit leaves 20000 → fixed 5000 → net 15000.
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(15000);
const sum = r.validationLines.reduce((a, l) => a + l.discountMinor, 0);
expect(sum).toBe(r.discountMinor);
expect(r.validationLines.map((l) => l.mode)).toEqual(["timeCredit", "fixed"]);
});
it("a settled (paid + within grace) session ignores validations", () => {
const r = priceSession(entered, at(123), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
val("comp"),
]);
expect(r.withinGrace).toBe(true);
expect(r.amountMinor).toBe(0);
expect(r.validationLines).toEqual([]);
});
it("an overstay period applies (unconsumed) validations to the FRESH period", () => {
// Paid at 120, grace 5 → overstay period starts at 125. A 60-min credit eats the
// overstay's first hour: net = fee(185→245 from period start) = the 1h price… i.e.
// fee of (245−125−60)=60 min from the ladder start.
const r = priceSession(entered, at(245), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
val("timeCredit", { minutes: 60 }),
]);
expect(r.overstay).toBe(true);
expect(r.grossMinor).toBe(computeFee(at(125), at(245), liveV1));
expect(r.amountMinor).toBe(computeFee(at(185), at(245), liveV1));
});
});