Files
parking_solution/packages/shared/src/tariff.test.ts
T
julian cf1ff5676d feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.

computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.

Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:00:13 +02:00

258 lines
11 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,
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<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 or blocks");
});
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 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([]);
});
});