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.
10 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||
|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-06-15 | 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 withasOf = 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-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
paymentevent (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.
{
"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
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
gracePeriodEntryMinis 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) andPOST /api/tariff/versions(publish a new immutable version; admin only). Publishing validates the structure viavalidateTariffStructure(shared) — non-negative integers, ordered/ascending block bounds, only the last block open-ended — so a malformed card can never be published. The single sitetariffsrow 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/tariffreturnsactive: nulland the pay station returns409 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:
- At the pay station:
fee = f(enteredAt, now, tariff)— charge for time parked so far. - 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
effectiveFromtimestamp; prior versions are immutable. Picking the version for a session = "the latest version witheffectiveFrom ≤ session entry time". - The session's
paymentevent records thetariffVersionIdit 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_versionnames itscurrency(ISO 4217), admin-selectable. Amounts everywhere are{ minorUnits, currency }— never a bare number, never a float. - A
paymentevent stores itscurrencyand a reservedfxRate(null for now) + optionalbaseCurrency. 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).