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
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user