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;
}
/** 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`);