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:
2026-07-05 14:31:25 +02:00
parent a9f18be700
commit d9e6c13831
3 changed files with 110 additions and 12 deletions
+38 -9
View File
@@ -606,8 +606,9 @@ export interface TariffWindow {
readonly toHour?: string; readonly toHour?: string;
} }
/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap). /** A V2 pricing card: a per-increment flat rate, a block ladder, a stepped table
* `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */ * (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 { export interface TariffCard {
/** Human label (also the final, deterministic precedence tiebreak). */ /** Human label (also the final, deterministic precedence tiebreak). */
readonly name: string; readonly name: string;
@@ -617,13 +618,21 @@ export interface TariffCard {
readonly category?: string; readonly category?: string;
/** Wall-clock activation window. Absent only on the defaultCard (always active). */ /** Wall-clock activation window. Absent only on the defaultCard (always active). */
readonly window?: TariffWindow; 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; 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[]; readonly blocks?: readonly TariffBlock[];
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/ /** STEPPED ("up-to") total-by-duration table (mutually exclusive with the other
* `blocks`). The top tier's total is this card's per-day price. */ * pricing fields; defaultCard only). The top tier's total is the per-day price. */
readonly steps?: readonly TariffStep[]; 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 /** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
* a mixed day (see computeFeeV2). null = no cap. */ * a mixed day (see computeFeeV2). null = no cap. */
readonly dailyCapMinor?: number | null; readonly dailyCapMinor?: number | null;
@@ -856,18 +865,30 @@ function computeFeeV2(
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!); if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
let total = 0; 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) { for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes); const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0; let segFee = 0;
for (let within = segStart; within < segEnd; within += inc) { for (let within = segStart; within < segEnd; within += inc) {
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz); const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
const card = selectCard(cards, wall); 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; segFee += card.flatMinor;
} else { } else {
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule). // Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
segFee += rateAt(card.blocks ?? [], within - segStart); segFee += rateAt(card.blocks ?? [], within - segStart);
} }
prevWinner = card;
} }
if (dayCap != null) segFee = Math.min(segFee, dayCap); if (dayCap != null) segFee = Math.min(segFee, dayCap);
total += segFee; total += segFee;
@@ -983,9 +1004,17 @@ function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefau
const hasFlat = c.flatMinor != null; const hasFlat = c.flatMinor != null;
const hasBlocks = c.blocks != null; const hasBlocks = c.blocks != null;
const hasStepTable = c.steps != 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) { 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) { } else if (hasFlat) {
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs); nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`); if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
+55 -1
View File
@@ -223,7 +223,7 @@ describe("validate V2", () => {
}); });
it("rejects a card with both flat and blocks", () => { it("rejects a card with both flat and blocks", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } }); 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", () => { it("rejects defaultCard with a window", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } }); 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); 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);
});
});
+17 -2
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, domain, business, pricing, design] tags: [parking, domain, business, pricing, design]
sources: [parksql2017-legacy-schema] sources: [parksql2017-legacy-schema]
updated: 2026-06-18 updated: 2026-07-05
status: settled status: settled
--- ---
@@ -140,7 +140,22 @@ case stays one rate card; tiers are opt-in.
`DEFAULT_VEHICLE_CATEGORY` in `@parking/shared`). Per-relay capture (a "bus lane") is the future `DEFAULT_VEHICLE_CATEGORY` in `@parking/shared`). Per-relay capture (a "bus lane") is the future
seam, mirroring per-relay direction. seam, mirroring per-relay direction.
- **Flat rate** is a first-class card body (`flatMinor`, mutually exclusive with `blocks`). A flat V1 - **Flat rate** is a first-class card body (`flatMinor`, mutually exclusive with `blocks`). A flat V1
is published as a single open-ended block (V1 has no flat field). is published as a single open-ended block (V1 has no flat field). ⚠ `flatMinor` is **per billing
increment** (an hourly flat rate at increment 60) — NOT a whole-stay/whole-window price. This was
misread in the field (park-buzi published a "night 400" believing it covered the night; it billed
400/h, 2026-07-05) — the UI now labels it "Flat price / hour" and the whole-window need got its own
mode:
- **WINDOW PACKAGE (`packageMinor`, 2026-07-05 — windowed cards only).** "Any presence in this
window = ONE total" (the real night rate: 20:00–07:00 = 400, leave earlier and it's still 400).
Decisions (operator, 2026-07-05): charged **once per occurrence** (a stay touching two nights pays
twice); **any touch pays full** (an 06:30 arrival before the 07:00 close pays the whole package —
package pricing's accepted sharp edge); **not offered on the base card** (a base "one price per
day" is a 1-row up-to table — no duplicate concept). Engine: one charge 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 rate as usual; the charge lands
in the day segment where the occurrence starts (that day's default-card cap applies). Mutually
exclusive with flat/blocks/steps + no per-card cap (the package IS the window's total); validator
enforces both and the composer offers the mode only on tier cards.
- **UI** (`TariffComposer.tsx`): default card **front-and-centre** (flat/ladder toggle + cap); tiers - **UI** (`TariffComposer.tsx`): default card **front-and-centre** (flat/ladder toggle + cap); tiers
under a collapsed **"Advanced: time & seasonal tiers"** disclosure (window builder — dow checkboxes, under a collapsed **"Advanced: time & seasonal tiers"** disclosure (window builder — dow checkboxes,
optional date range, optional hour range with an overnight hint; category; priority; flat/ladder optional date range, optional hour range with an overnight hint; category; priority; flat/ladder