diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e925c4d..a1092bb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 | 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`); diff --git a/packages/shared/src/tariff.test.ts b/packages/shared/src/tariff.test.ts index 6a758fd..3cc7de3 100644 --- a/packages/shared/src/tariff.test.ts +++ b/packages/shared/src/tariff.test.ts @@ -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); + }); +}); diff --git a/wiki/concepts/tariff-time-tiers.md b/wiki/concepts/tariff-time-tiers.md index a32e2b5..18f267c 100644 --- a/wiki/concepts/tariff-time-tiers.md +++ b/wiki/concepts/tariff-time-tiers.md @@ -2,7 +2,7 @@ type: concept tags: [parking, domain, business, pricing, design] sources: [parksql2017-legacy-schema] -updated: 2026-06-18 +updated: 2026-07-05 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 seam, mirroring per-relay direction. - **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 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