692dff5f89
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
636 lines
28 KiB
TypeScript
636 lines
28 KiB
TypeScript
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));
|
||
});
|
||
});
|