b4d0dfadd6
validateTariffStructure (shared): non-negative ints, ascending block bounds, only the last block open-ended — a malformed card can't be published. Routes: GET /api/tariff (active + history, any signed-in role), POST /api/tariff/versions (publish an immutable, effective-dated version; admin only). The single site tariff row is created lazily. Editing = publish a new version; past sessions keep their pricing. Web: TariffComposer in the admin shell — edit currency, grace windows, increment, daily cap, lost-ticket fee, and add/remove rate blocks (major-unit input -> minor on submit); shows active version + history. Verified via inject: empty -> active null; invalid blocks -> 400 with problem; valid -> 201; readonly publish -> 403; after publishing, the pay station quote returns 404 (no session) instead of 409 (no tariff) -- it now prices against the active card.
181 lines
10 KiB
Markdown
181 lines
10 KiB
Markdown
---
|
||
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
|
||
[[permit]]), 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": 60, "priceMinorPerIncrement": 200 }, // first hour
|
||
{ "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min
|
||
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // null = open-ended, thereafter
|
||
],
|
||
"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.
|
||
|
||
**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, 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.
|
||
- **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).
|
||
- 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.
|
||
|
||
## Permit holders
|
||
|
||
A valid [[permit]] 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 [[permit]].
|
||
|
||
## 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.
|
||
|
||
## 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]].
|
||
|
||
## 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.
|
||
- **Time-of-day / weekday tiers** — not in the block model yet; add as a tier wrapper if a site
|
||
needs day/night/weekend cards (deferred until asked).
|
||
- **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]]).
|