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`);
|
||||
|
||||
Reference in New Issue
Block a user