feat(tariff): complete the progressive ladder — require open-ended last block, hours-based composer
The stepped-block engine already does "first N hrs x X, next N hrs x Y, ...,
24h cap" (ordered blocks, per-block rate, rolling-24h cap). No new axis; this
completes the model and removes its footgun.
- validateTariffStructure (shared) now REQUIRES the last block to be open-ended
(uptoMin: null). A bounded final block silently inherited its own rate past
its bound (a hidden, never-stated price — e.g. the live ALL tariff billed
hour 4+ at the 3rd-hour rate). rateAt() still prices legacy bounded-tail
versions; validation is publish-only, so published immutable versions are
unaffected (no migration).
- TariffComposer edits bands as a DURATION in hours ("first 2 hours, then next
3 hours"), accumulated into the engine's cumulative uptoMin (minutes) on
submit. The last row is a pinned, non-removable "thereafter (open-ended)"
band, so a published card always satisfies the open-ended-last rule.
blocksToForm round-trips stored minutes back to band hours (legacy loads).
- i18n: replaced upToMin/egExample with bandDuration/hoursUnit/egHours (sq+en,
catalog parity green).
Verified: validator rejects bounded-last / accepts open-ended; computeFee
correct at 1/2/3/5/6/24h for a 0-2h@200,2-5h@100,5h+@50 + 1000 cap card. Full
build green. Wiki (tariff.md, log.md) updated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+38
-8
@@ -45,11 +45,12 @@ code. All amounts are **integer minor units** in the tariff's currency.
|
||||
"currency": "EUR", // ISO 4217; selectable per tariff version
|
||||
"gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround)
|
||||
"incrementMin": 60, // billing granularity; partial increments round UP
|
||||
"blocks": [ // consumed in order as duration accrues
|
||||
"blocks": [ // consumed in order as duration accrues; uptoMin is
|
||||
// the CUMULATIVE upper bound in minutes
|
||||
{ "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour
|
||||
{ "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min
|
||||
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // null = open-ended, thereafter
|
||||
],
|
||||
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // REQUIRED open-ended last
|
||||
], // block — the explicit "thereafter" rate
|
||||
"dailyCapMinor": 1200, // cap per rolling 24h (null = no cap)
|
||||
"lostTicketMinor": 2000, // flat charge when there's no entry id
|
||||
"gracePeriodExitMin": 15, // pay-on-foot walk-back window
|
||||
@@ -92,6 +93,12 @@ because the chain + reconciliation depend on the result being reproducible.
|
||||
increment would round it up (else rounding defeats the grace window).
|
||||
- **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h
|
||||
stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily.
|
||||
- **The LAST block MUST be open-ended (`uptoMin: null`)** — enforced on publish (2026-06-18). A
|
||||
bounded final block silently inherited its own rate past its bound (a hidden, never-stated price);
|
||||
forcing an open-ended tail makes the "thereafter" rate explicit. `rateAt()` still gracefully prices
|
||||
legacy bounded-tail versions (validation runs only on publish, never on read), so already-published
|
||||
immutable versions keep pricing unchanged. This is the "first N hrs × X, next N hrs × Y, …, 24h
|
||||
cap" model made complete — the same engine, no new axis; the only gap was the unstated tail.
|
||||
|
||||
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
||||
across grace, block steps, daily cap, and multi-day reset.
|
||||
@@ -103,12 +110,17 @@ The admin authors the rate card at runtime — no hand-seeding:
|
||||
- **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any
|
||||
signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**).
|
||||
Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers,
|
||||
ordered/ascending block bounds, only the last block open-ended — so a malformed card can never be
|
||||
published. The single site `tariffs` row is created lazily on first read/publish.
|
||||
ordered/ascending block bounds, **the last block open-ended (enforced)**, and **`effectiveFrom`
|
||||
not in the past** (no backdating) — so a malformed or retroactive card can never be published. The
|
||||
single site `tariffs` row is created lazily on first read/publish.
|
||||
- **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment,
|
||||
daily cap, lost-ticket fee, and add/remove rate blocks; amounts entered in major units, converted
|
||||
to integer minor units on submit. Shows the active version + history; "Publish" creates a new
|
||||
version (past sessions keep their pricing).
|
||||
daily cap, lost-ticket fee, and add/remove rate bands; amounts entered in major units, converted to
|
||||
integer minor units on submit. **Bands are edited as a DURATION in hours** ("this band lasts N
|
||||
hours") — the owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes; the
|
||||
composer accumulates per-band hours into the engine's cumulative `uptoMin` (minutes) on submit. The
|
||||
**last band is always the open-ended "thereafter"** row (not removable, no hours field), so a
|
||||
published card always satisfies the open-ended-last rule. Shows the active version + history;
|
||||
"Publish" creates a new version (past sessions keep their pricing).
|
||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||
|
||||
@@ -163,6 +175,24 @@ that was in force when it was incurred — never today's. So a tariff is **never
|
||||
- An **in-progress** session that crosses a version boundary uses the version in force at **entry**
|
||||
(consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter.
|
||||
|
||||
### No backdating — versioning would otherwise be retroactive (fixed 2026-06-18)
|
||||
|
||||
The two bullets above only hold if a new version's `effectiveFrom` **cannot be in the past**. The
|
||||
selector is "latest `effectiveFrom ≤ entry time`", so publishing a version with a **backdated**
|
||||
`effectiveFrom` would silently re-select it for sessions that **already entered** — retroactively
|
||||
repricing in-progress (and re-quotable) stays. That is exactly the rewrite the versioning exists to
|
||||
prevent, and it was **publishable** until this fix (the publish handler accepted any `effectiveFrom`,
|
||||
defaulting to now).
|
||||
|
||||
**Rule (enforced server-side in `routes/tariffs.ts`):** on publish, `effectiveFrom` must be **≥ now**
|
||||
(a 60 s skew tolerance absorbs clock drift + round-trip). A **future** `effectiveFrom` is allowed —
|
||||
scheduling a forthcoming price change is legitimate and forward-only. A past one is rejected `400`.
|
||||
Combined with entry-time selection, this makes the guarantee structural: **once a car has entered, no
|
||||
later publish can change its price**, because no new version can carry an `effectiveFrom` that
|
||||
predates the entry. We deliberately did **not** also pin `tariffVersionId` onto the `vehicle_entry`
|
||||
event (entry-time selection + no-backdating already freezes the price); revisit only if multi-tariff
|
||||
`scope` makes entry-time resolution ambiguous.
|
||||
|
||||
## Data model (first cut — with [[session-model]])
|
||||
|
||||
| Table / field | Notes |
|
||||
|
||||
Reference in New Issue
Block a user