Files
parking_solution/wiki/concepts/tariff.md
T
julian 8a8e74561d wiki: design the business layer (session, tariff, permit, vision, shift, ops)
Pivot from the hardware/integrity layer to the parking operation. All
wiki-only; no code yet. Core principle throughout: business entities are
projections over the signed append-only event log, never mutable tables.

New concepts: parking-session, tariff (composable/versioned, FX-ready),
shift (manned-only Z-report), capacity-occupancy, validation-discounts,
reporting-analytics, clock-integrity, ticket-encoding, anti-passback.
New entities: permit, opencv-anpr-service, blocklist.
Decisions: session-model, vision-service (host-side ANPR + vehicle
verification; scoped AGPL exception for the isolated service).

Updates: append-only-event-chain (new event types + vision witness),
local-jwt-auth (drop 8h expiry -> until logout; code change pending),
lpr-camera (host-side recognition supersedes edge-AI), standing-decisions
(AGPL exception), open-questions (+FX, +pay-station money corners, backup).

Deferred + flagged: intercom/help-call, receipts/refunds/change, FX engine,
lane topology (#1).
2026-06-15 17:41:38 +02:00

8.5 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.

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).