Files
parking_solution/packages/shared/src/tariff.test.ts
T
julian 9a1feeeb20 fix(tariff): reject stepped base combined with time/seasonal tiers
A stepped ("up-to") default card prices the whole stay as one total, so the V2
engine short-circuits to steppedFee and NEVER consults windowed cards — any
time/seasonal tiers would silently never fire. Found live: an active tariff had a
stepped base plus weekday-night + weekend tiers, and every 3h stay priced 600 ALL
regardless of hour/day because the tiers were dead.

- validateTariffV2 now rejects a stepped defaultCard combined with windowedCards,
  with an actionable message (switch the base to ladder/flat, or remove the tiers).
- Composer shows an inline red warning the moment base mode is stepped and tiers
  exist; publishing is blocked server-side regardless.
- ApiError now carries the server's problems[], so the publish error surfaces the
  SPECIFIC reason instead of a generic "invalid tariff structure".
- 2 new validation tests (55 pass).

Wiki: tariff, log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 14:03:22 +02:00

386 lines
17 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,
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<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, 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<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);
});
});