--- 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. > **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 [[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]]. ## 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 [[permit]]; 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]]).