Files
parking_solution/wiki/concepts/tariff.md
T
julian dfa76346d6 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
2026-06-18 16:58:47 +02:00

241 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, domain, business, pricing]
sources: []
updated: 2026-06-15
status: open
---
# Tariff (Fee Model)
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
not code** — the park owner builds and constantly edits the rate card at runtime (like a
[[subscription]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
beyond the host).
> Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing
> publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned
> over time), modelled with an id/scope so multiple rate cards can be added later without migration;
> (3) **currency is selectable** (ISO 4217) and the money model is **FX-ready but FX is deferred**.
## Design principles
- **Pure function of (entry time, charge time, tariff).** `fee = f(enteredAt, asOf, tariff)`. No
side effects, deterministic, unit-testable. The pay station calls it with `asOf = now`; the exit
lane re-checks against the recorded payment.
- **Data-driven.** The tariff lives as a config record (its own table or seeded config), versioned,
so a historical session always reprices against the tariff in force when it was incurred. Never
hard-code rates (this is an [[open-questions|open-question]]-adjacent procurement input — sites
differ).
- **Integer minor units.** Money is integer cents (or the site currency's minor unit) — never
floats. Avoids rounding drift across a revenue ledger.
- **The fee, once paid, is a signed `payment` event** ([[parking-session]]) — the computation is
reproducible, but the *charged* amount is fixed in the chain.
## The composable structure — stepped blocks + daily cap
The admin composes a **rate card** the fee function interprets. The general model is an **ordered
list of duration blocks** (flat rate is just one block) plus a daily cap — chosen because it
expresses every common operator shape (first-hour pricing, tapering, caps) with no special cases in
code. All amounts are **integer minor units** in the tariff's currency.
```jsonc
{
"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; 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 } // 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
"overstay": "reprice" // top-up = recompute(entry→now) − alreadyPaid (decided)
}
```
> **The numbers above are illustrative, not defaults to ship.** "No one knows the pricing and it
> changes constantly" — so the admin authors all of it; the system ships with **no rate card** and
> the owner must compose + publish one before the lot can charge (until then: free, or gated —
> operator policy, see Open).
**Lost ticket** is not just the flat `lostTicketMinor`: the admin may **override with an arbitrary
amount** at the moment (operator judgement — establish entry time from [[opencv-anpr-service|plate]]
capture/CCTV and charge real duration, or apply a set penalty). The configured flat fee is the
default; the chosen amount is recorded in the signed `payment` event ([[parking-session]]).
## The fee algorithm (pure, integer, offline)
```
fee(enteredAt, asOf, tariff):
minutes = roundUp(asOf − enteredAt, incrementMin)
if minutes ≤ gracePeriodEntryMin: return 0
total = 0
for each rolling 24h segment of the stay:
segMinutes = minutes within this segment
segFee = walk `blocks` in order, charging priceMinorPerIncrement for each
incrementMin that falls in each block's [prevUpto, uptoMin) range
if dailyCapMinor: segFee = min(segFee, dailyCapMinor)
total += segFee
return total
```
Deterministic, side-effect-free, unit-testable; the daily cap is applied **per rolling 24h** (so an
overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec
because the chain + reconciliation depend on the result being reproducible.
**Settled edges (2026-06-15, with tests):**
- **Grace uses RAW duration** — a stay within `gracePeriodEntryMin` is free even though the
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.
### Composer (as-built 2026-06-15)
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, **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 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).
## The pay-on-foot consequence
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
time references**, not one:
1. At the **pay station**: `fee = f(enteredAt, now, tariff)` — charge for time parked so far.
2. At the **exit lane**: the session is valid to leave iff `now ≤ paidAt + gracePeriodExit`.
Past that, an **overstay top-up** = `f(paidAt, now, tariff.overstayRate)` is due before exit.
`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people
who paid; too long gives free parking between pay and exit.
> **As-built correction (2026-06-17):** the overstay top-up reprices from **entry**, not `paidAt` —
> `computeFee(enteredAt, now, …)` (so the timer never restarts; the customer pays the true entry→now
> total). The line above (`f(paidAt, now, …)`) was the original sketch; the implementation uses entry.
### ⚠ Open question — walk-back grace renews on every payment
A consequence of the two-time-reference model, surfaced via the [[booth-exit-flow|booth exit /
voucher]] path: every `payment` event stores its own `gracePeriodExit`, and the exit check reads the
**latest** payment's value. So an **overstay top-up re-grants a full, fresh grace window** each time.
The fee is correct (always recomputed from entry — no free exit), but the **walk-back grace doubles**
(or repeats) on every top-up — a customer could pay → wait → pay a tiny delta → earn another window →
repeat. The leak is **time, not money**, bounded by increment coarseness but real.
Candidate policies (business call): grant grace on a top-up **only when it charged new money**
(recommended), a **single non-renewing window** from the first payment, or a **per-session grace
cap**. Full analysis + the decided/undecided halves live in [[booth-exit-flow]]. Pick a policy before
production.
## Permit holders
A valid [[subscription]] bypasses tariff computation entirely for the covered period (subscription
already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for
the uncovered time — an edge case to design with [[subscription]].
## Versioning — edits publish immutable, effective-dated versions
Prices change constantly, **and** a historical [[parking-session]] must reprice against the rate
that was in force when it was incurred — never today's. So a tariff is **never edited in place**:
- Each save **publishes a new version** with an `effectiveFrom` timestamp; prior versions are
**immutable**. Picking the version for a session = "the latest version with `effectiveFrom ≤
session entry time`".
- The session's **`payment` event records the `tariffVersionId`** it was priced under
([[parking-session]], [[append-only-event-chain]]). The charged amount is then both reproducible
*and* fixed in the signed chain — an admin can't retroactively rewrite prices to alter what a past
session "should have" paid without it being visible.
- 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 |
| --- | --- |
| `tariffs` | a logical rate card: `id`, `scope` (site/lane/zone — only "site" used now), `name`. |
| `tariff_versions` | `id`, `tariffId`, `effectiveFrom`, `currency`, `structure` (the JSON above), `createdBy`, `createdAt`. **Immutable.** |
| (active) | "one active tariff per site" = one `tariffs` row; multiple `tariff_versions` over time. The `scope`/`id` exist so multiple rate cards can be added later **without migration**. |
Unlike the event log, tariff data is **mutable master data** in the sense that new versions are
*added*; but each version row, once published, is never changed — close to append-only, and the
*use* of it is fixed in the signed `payment` event.
## Currency & FX — selectable now, FX deferred
- Each `tariff_version` names its **`currency`** (ISO 4217), admin-selectable. Amounts everywhere
are `{ minorUnits, currency }` — never a bare number, never a float.
- A `payment` event stores its **`currency`** and a reserved **`fxRate` (null for now)** + optional
`baseCurrency`. So when an exchange-rate system is added later, historical payments stay
reproducible (you know the currency charged and, once FX exists, the rate applied) — **no
migration** of stored amounts.
- **FX engine is NOT built now.** When it is, it needs an *offline* rate source (rates can't depend
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
[[open-questions]].
## Extensions under design
Two operator asks extend this engine; both have design pages (not yet built), grounded in
[[parksql2017-legacy-schema|the legacy schema]] + external research:
- **Time-of-day / weekday / seasonal tiers** (happy hour, off-peak, weekend, vehicle category) —
see [[tariff-time-tiers]]. Chosen shape: **time-windowed rate cards** selected by wall-clock window,
layered additively on this structure (a bare default card = today's behaviour). The hard part is
slicing a stay at window boundaries while keeping the block ladder + daily cap continuous.
- **Validation & sponsorship** (merchant comps, coupons, **postpaid B2B** "enter/exit free, bill the
business monthly") — see [[validation-sponsorship]]. A validation is a **typed modifier applied as a
signed event** on a transient session, distinct from a [[subscription]]; postpaid sponsors accrue a
monthly-invoiced liability derivable from the chain.
## Open
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).