Files
parking_solution/wiki/concepts/tariff.md
T
julian 58d8f06ba0 docs(wiki): tariff research — legacy ParkSQL2017 schema, time-tiers & validation/sponsorship design
Ingest the predecessor SQL Server schema (raw + source summary) and file design
pages for time-of-day/seasonal tariff tiers and merchant validation/postpaid
sponsorship. Cross-link tariff.md and validation-discounts.md. No code.
2026-06-18 10:59:21 +02:00

12 KiB
Raw Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
domain
business
pricing
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 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-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.

{
  "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 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 + 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).