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:
@@ -606,8 +606,9 @@ export interface TariffWindow {
|
||||
readonly toHour?: string;
|
||||
}
|
||||
|
||||
/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap).
|
||||
* `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */
|
||||
/** A V2 pricing card: a per-increment flat rate, a block ladder, a stepped table
|
||||
* (defaultCard only), or a whole-window package (windowed cards only). The pricing
|
||||
* fields are mutually exclusive — exactly one. The defaultCard has no window. */
|
||||
export interface TariffCard {
|
||||
/** Human label (also the final, deterministic precedence tiebreak). */
|
||||
readonly name: string;
|
||||
@@ -617,13 +618,21 @@ export interface TariffCard {
|
||||
readonly category?: string;
|
||||
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
|
||||
readonly window?: TariffWindow;
|
||||
/** Flat price per billing increment (mutually exclusive with `blocks`/`steps`). */
|
||||
/** Flat price PER BILLING INCREMENT (an hourly flat rate at increment 60) —
|
||||
* mutually exclusive with the other pricing fields. NOT a whole-stay price;
|
||||
* for "one total for the whole window" use `packageMinor`. */
|
||||
readonly flatMinor?: number;
|
||||
/** Marginal block ladder (mutually exclusive with `flatMinor`/`steps`); last open-ended. */
|
||||
/** Marginal block ladder (mutually exclusive with the other pricing fields); last open-ended. */
|
||||
readonly blocks?: readonly TariffBlock[];
|
||||
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/
|
||||
* `blocks`). The top tier's total is this card's per-day price. */
|
||||
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with the other
|
||||
* pricing fields; defaultCard only). The top tier's total is the per-day price. */
|
||||
readonly steps?: readonly TariffStep[];
|
||||
/** WINDOW PACKAGE (windowed cards only, 2026-07-05): ONE total charged per
|
||||
* contiguous occurrence of this card winning increments — e.g. "any presence in
|
||||
* the 20:00–07:00 window = 400, leave earlier and it's still 400". Any touch of
|
||||
* the window pays the full package; a stay spanning two nights pays it twice
|
||||
* (once per occurrence). Mutually exclusive with the other pricing fields. */
|
||||
readonly packageMinor?: number;
|
||||
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
|
||||
* a mixed day (see computeFeeV2). null = no cap. */
|
||||
readonly dailyCapMinor?: number | null;
|
||||
@@ -856,18 +865,30 @@ function computeFeeV2(
|
||||
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
|
||||
|
||||
let total = 0;
|
||||
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
|
||||
// contiguous run of increments it wins (an "occurrence" — e.g. one night), however
|
||||
// little of the window the car actually used. The tracker survives the day-segment
|
||||
// loop so a night run crossing the rolling-24h boundary charges once, not twice;
|
||||
// the charge lands in the segment where the occurrence starts (that day's cap
|
||||
// applies to it). A stay touching the window on two different nights = two
|
||||
// occurrences = two charges.
|
||||
let prevWinner: TariffCard | null = null;
|
||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||
const segEnd = Math.min(segStart + DAY, minutes);
|
||||
let segFee = 0;
|
||||
for (let within = segStart; within < segEnd; within += inc) {
|
||||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||||
const card = selectCard(cards, wall);
|
||||
if (card.flatMinor != null) {
|
||||
if (card.packageMinor != null) {
|
||||
// First increment of a new occurrence pays the package; the rest ride free.
|
||||
if (prevWinner !== card) segFee += card.packageMinor;
|
||||
} else if (card.flatMinor != null) {
|
||||
segFee += card.flatMinor;
|
||||
} else {
|
||||
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
||||
segFee += rateAt(card.blocks ?? [], within - segStart);
|
||||
}
|
||||
prevWinner = card;
|
||||
}
|
||||
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
||||
total += segFee;
|
||||
@@ -983,9 +1004,17 @@ function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefau
|
||||
const hasFlat = c.flatMinor != null;
|
||||
const hasBlocks = c.blocks != null;
|
||||
const hasStepTable = c.steps != null;
|
||||
const modes = [hasFlat, hasBlocks, hasStepTable].filter(Boolean).length;
|
||||
const hasPackage = c.packageMinor != null;
|
||||
const modes = [hasFlat, hasBlocks, hasStepTable, hasPackage].filter(Boolean).length;
|
||||
if (modes !== 1) {
|
||||
errs.push(`${label} must set exactly one of flatMinor, blocks, or steps`);
|
||||
errs.push(`${label} must set exactly one of flatMinor, blocks, steps, or packageMinor`);
|
||||
} else if (hasPackage) {
|
||||
// A whole-window package needs a window to be an occurrence of — meaningless on
|
||||
// the always-active defaultCard (a base "one price per stay/day" is a 1-row
|
||||
// stepped table there). See wiki/concepts/tariff-time-tiers.md.
|
||||
if (isDefault) errs.push(`${label}: packageMinor (whole-window package) is only allowed on a windowed card`);
|
||||
nonNegInt(c.packageMinor, `${label}.packageMinor`, errs);
|
||||
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor does not apply to a window package (the package IS the window's total)`);
|
||||
} else if (hasFlat) {
|
||||
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
|
||||
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
|
||||
|
||||
@@ -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