feat(tariff): whole-window package pricing mode (packageMinor)
A windowed card can now charge ONE total for any presence in its window —
the real night rate ("20:00–07:00 = 400, leave earlier and it's still
400"), which the per-increment flatMinor could not express (park-buzi's
"night 400" card billed 400/HOUR). Engine charges once per contiguous run
of increments the card wins, tracked across rolling-day segments so a
night crossing the 24h boundary charges once; out-of-window increments
price by the base card as usual.
Operator decisions (2026-07-05): per-occurrence repeat (two nights = two
charges), any-touch-pays-full, windowed cards only (a base "price per
day" is a 1-row up-to table). Validator: mutually exclusive with
flat/blocks/steps, no per-card cap, forbidden on the defaultCard.
flatMinor docs clarified as PER INCREMENT. 6 new engine tests.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -223,7 +223,7 @@ describe("validate V2", () => {
|
||||
});
|
||||
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");
|
||||
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] } } });
|
||||
@@ -383,3 +383,57 @@ describe("stepped (up-to) pricing — owner matrix", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user