Files
parking_solution/wiki/concepts/tariff.md
T
julian cc507f490f feat(tariff): stepped ("up-to") pricing mode — total-by-duration
Owners often state rates as a total-by-duration matrix (0-1h=200, 0-3h=500,
0-6h=800, 0-9h=900, 0-12h=1000) that the marginal hourly ladder can't express
(the ladder sums per-increment rates; this is cumulative totals at thresholds).
Add STEPPED as a third pricing mode alongside the ladder and flat.

- @parking/shared: TariffStep {uptoMin, totalMinor} + a `steps[]` field on V1
  structures and V2 cards (mutually exclusive with blocks/flatMinor). steppedFee():
  smallest tier with uptoMin >= duration wins (INCLUSIVE boundary), the top tier
  repeats as a per-day cap; wired into computeFeeV1 + computeFeeV2 (V2 default card
  only — a whole-stay total can't be sliced per-increment by a windowed card).
  Validation: ascending uptoMin, non-negative totals, no daily-cap-with-steps,
  steps-only-on-default. priceSession/quote/booth/Lab price it via the shared core.
- Composer UI: a "By duration (up-to)" mode with an up-to/total table (base card
  only). i18n modeStepped/steppedHint/stepUpTo/stepTotal/addStep (sq+en).
- 8 new unit tests incl. the exact owner matrix, multi-day repeat, overstay, and
  validation (53 pass). Verified end-to-end via the UI: authored + published the
  matrix, Tariff Lab prices it exactly (3h->500, 6h->800, 12h->1000, 2d->2000).

Wiki: tariff (three pricing modes + stepped semantics), log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 12:33:27 +02:00

19 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 subscription), 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 is
                                // the CUMULATIVE upper bound in minutes
    { "uptoMin": 60,   "priceMinorPerIncrement": 200 },   // first hour
    { "uptoMin": 180,  "priceMinorPerIncrement": 150 },   // 60→180 min
    { "uptoMin": null, "priceMinorPerIncrement": 100 }    // REQUIRED open-ended last
  ],                            // block — the explicit "thereafter" rate
  "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).

Three pricing modes (per card / per V1 structure)

A card's body is one of three mutually-exclusive shapes — flatMinor, blocks, or steps:

  1. Hourly ladder (blocks) — the model above: a marginal per-increment rate that the engine sums across increments. "Each next hour costs X." Daily-cap and multi-day reset apply.
  2. Flat (flatMinor) — one rate per increment (a one-block ladder).
  3. Stepped / "up-to" (steps) — added 2026-06-20. A total-by-duration table the owner enters verbatim — the opposite of marginal: each row is the cumulative TOTAL for a stay within that tier. Needed because owners think in totals, and many real cards (flat-day, airport) are stated this way and cannot be expressed as a marginal ladder.
"steps": [                      // each row: total price for a stay UP TO uptoMin (inclusive)
  { "uptoMin": 60,  "totalMinor": 200 },   // 0–1h  → 200
  { "uptoMin": 180, "totalMinor": 500 },   // 0–3h  → 500
  { "uptoMin": 360, "totalMinor": 800 },   // 0–6h  → 800
  { "uptoMin": 540, "totalMinor": 900 },   // 0–9h  → 900
  { "uptoMin": 720, "totalMinor": 1000 }   // 0–12h → 1000
]

Stepped semantics (decided with the user, 2026-06-20):

  • The smallest tier whose uptoMin ≥ duration wins; the boundary is inclusive (≤) — a stay of exactly 3h00m costs the 3h tier (500), not the next.
  • Beyond the largest threshold, that tier's total is the per-day price (a daily-cap repeat): a 13h stay within one rolling day = 1000 (the top total is the day's ceiling), and a 25h stay = 1000 (day 1) + the stepped ladder for the remaining 1h on day 2 = 1200.
  • A steps table replaces the blocks ladder and forbids dailyCapMinor (the top tier IS the per-day cap). In V2 it is allowed only on the defaultCard — a whole-stay total can't be sliced per-increment by a windowed card, so windowed/stepped don't compose.
  • Validation: ≥1 row, strictly-ascending positive uptoMin, non-negative integer totals (totals need not be monotonic — an owner may price a longer stay cheaper).

The owner authors this in the composer ("By duration (up-to)" mode) as an up-to N hours / total table; the #tariff-lab-simulator-as-built-2026-06-20 previews the curve. Verified end-to-end: the matrix above publishes and prices exactly (30m→200, 3h→500, 6h→800, 12h→1000, 2d→2000).

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.
  • The LAST block MUST be open-ended (uptoMin: null) — enforced on publish (2026-06-18). A bounded final block silently inherited its own rate past its bound (a hidden, never-stated price); forcing an open-ended tail makes the "thereafter" rate explicit. rateAt() still gracefully prices legacy bounded-tail versions (validation runs only on publish, never on read), so already-published immutable versions keep pricing unchanged. This is the "first N hrs × X, next N hrs × Y, …, 24h cap" model made complete — the same engine, no new axis; the only gap was the unstated tail.

As-built: computeFee(enteredAt, asOf, structure) in packages/shared (pure). Unit-tested across grace, block steps, daily cap, and multi-day reset. A higher-level priceSession(enteredAt, asOf, structure, payments[], category?) (also pure, shared) wraps computeFee with the grace/overstay logic — unpaid → entry→now; paid+within-grace → settled (0); paid+grace-expired → overstay, a fresh period from grace-expiry→now (see booth-exit-flow). The booth's PayStation.quote() and the #tariff-lab-simulator-as-built-2026-06-20 both call it, so live pricing and the simulator can never diverge.

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, the last block open-ended (enforced), and effectiveFrom not in the past (no backdating) — so a malformed or retroactive 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 bands; amounts entered in major units, converted to integer minor units on submit. Bands are edited as a DURATION in hours ("this band lasts N hours") — the owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes; the composer accumulates per-band hours into the engine's cumulative uptoMin (minutes) on submit. The last band is always the open-ended "thereafter" row (not removable, no hours field), so a published card always satisfies the open-ended-last rule. 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).

Tariff Lab (simulator, as-built 2026-06-20)

The tariff engine is a pure function of time, but you could previously only exercise it by waiting (the only clock the booth reads is the real wall-clock). The Tariff Lab closes that gap: price a session at any instant against any tariff version in seconds.

  • API (apps/server/src/routes/tariffs.ts, tariff:read — admins always have it; available on-site too, useful to quote a customer dispute): POST /api/tariff/simulate prices a hypothetical session — body {enteredAt, asOf, payments[], category?, tariffVersionId? | structure?} — and returns the full priceSession outcome plus a duration curve (fee from entry at 30m…3d, so you SEE where the daily cap flattens or a window shifts). GET /api/tariff/simulate/session/:identity prefills from a real ledger session (entry + payments + the version frozen at entry). Both are read-only — no ledger writes.
  • UI (apps/web/src/TariffLab.tsx, Setup → "Tariff Lab"): pick a version (active or any historical), set entry / "as of" times, an optional payment (with its grace), and a category; or "Load" a real ticket to re-evaluate it at any moment. Shows amount due, billed period, overstay/ settled state, and the curve. Prices via the same priceSession the booth uses (verified: a real overstay ticket reads identically in the lab and the booth). See booth-exit-flow (overstay).

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

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.

No backdating — versioning would otherwise be retroactive (fixed 2026-06-18)

The two bullets above only hold if a new version's effectiveFrom cannot be in the past. The selector is "latest effectiveFrom ≤ entry time", so publishing a version with a backdated effectiveFrom would silently re-select it for sessions that already entered — retroactively repricing in-progress (and re-quotable) stays. That is exactly the rewrite the versioning exists to prevent, and it was publishable until this fix (the publish handler accepted any effectiveFrom, defaulting to now).

Rule (enforced server-side in routes/tariffs.ts): on publish, effectiveFrom must be ≥ now (a 60 s skew tolerance absorbs clock drift + round-trip). A future effectiveFrom is allowed — scheduling a forthcoming price change is legitimate and forward-only. A past one is rejected 400. Combined with entry-time selection, this makes the guarantee structural: once a car has entered, no later publish can change its price, because no new version can carry an effectiveFrom that predates the entry. We deliberately did not also pin tariffVersionId onto the vehicle_entry event (entry-time selection + no-backdating already freezes the price); revisit only if multi-tariff scope makes entry-time resolution ambiguous.

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

Grounded in parksql2017-legacy-schema + external research:

  • Time-of-day / weekday / seasonal tiers + vehicle category + flat rate — BUILT 2026-06-18 as the V2 tariff (the "V2" arm of TariffStructure). A defaultCard plus optional windowed cards selected by wall-clock window / day-of-week / date / category, each flat or laddered; a stay is sliced at window boundaries while the block ladder + daily cap stay continuous (elapsed- continuous). A bare V1 structure (no defaultCard) is unchanged. The wall-clock tz is frozen in the version (from site config) for reproducibility. Full as-built decisions in tariff-time-tiers.
  • 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 subscription; 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).