Files
parking_solution/wiki/concepts/tariff.md
T
julian 5287be5278
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
docs(wiki): catch-up sweep — five pages lagging the log
rongta-printer still named the cashino driver id (→ escpos + migration
0023 note); tariff-time-tiers listed the composer price preview as
deferred (→ delivered by the lab fee breakdown); tariff.md lab section
gained the breakdown + composer increment-guard paragraph; i18n.md now
records the "25 Qer 14:30" date standard + never-toLocaleString-for-
dates rule; fleet-deployment-komodo gained the park-lab stack + tier
table (the park-lab addition had also slipped the log — both fixed).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 13:08:55 +02:00

374 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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
[[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).
> **Shared pattern (2026-06-20):** [[subscription]] pricing now uses this same model — a
> **versioned, effective-dated, admin-composed catalog** (`subscription_plans`), resolved by "latest
> active version with `effectiveFrom ≤ sale`", with the sale persisting its `planVersionId` for
> reproducible repricing. The operator selects a plan + span; the price is looked up, never typed.
> Tariffs price *transient* stays by duration; plans price *subscription* spans by ceil(periods).
> **The tariff also prices SUBSCRIBERS now (2026-06-20).** A [[subscription]] plan with time windows
> charges the **transient tariff** for any out-of-window parking (early entry / late exit) — the
> subscriber temporarily *becomes* a transient for those minutes. `computeFee` is reused unchanged;
> the gap is a normal `[start, end]` priced against the active version (recorded `tariffVersionId` for
> reproducibility). See [[subscription]] "the tariff bridge".
> 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 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 **increment** costs X." Daily-cap and multi-day reset apply.
2. **Flat (`flatMinor`)** — one rate per increment (a one-block ladder).
> **⚠ `priceMinorPerIncrement` is PER BILLING INCREMENT, not per hour.** The effective hourly rate is
> `price × (60 / incrementMin)`. So with `incrementMin: 30`, a block priced `100` charges **100 every
> half-hour = 200/hour** → a 3h stay costs `100 × 6 = 600`, not 300. The example below uses
> `incrementMin: 60`, where per-increment happens to equal per-hour — which hides the distinction.
> This has caused repeated "the Lab is wrong" confusion (2026-06-20); the engine was correct each
> time, the *rate was per 30-min increment*. To bill 100/hour at a 30-min increment, set the price to
> `50`; or set `incrementMin: 60`. The composer column is labelled "Price / increment" and the
> billing increment is a separate top-level field — see Open (a per-hour preview is a candidate UX
> fix).
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.
```jsonc
"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.
- **A stepped base + time/seasonal tiers is REJECTED** (`validateTariffV2`, 2026-06-20). The engine
short-circuits to `steppedFee` on a stepped default card and never consults windowed cards, so any
tiers would **silently never fire**. Rather than publish dead tiers, validation refuses the combo
("time/seasonal tiers do not apply to an up-to-duration (stepped) base rate — remove the tiers, or
switch the base rate to an hourly ladder or flat price"); the composer also shows an inline red
warning the moment both are present. (Discovered live: an active version had a stepped base AND
weekday-night + weekend tiers; the tiers priced nothing — every 3h stay was the stepped 600
regardless of time. The `problems[]` array now surfaces through `ApiError` to the publish message.)
- 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|Tariff Lab]] 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|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.
- **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|Tariff Lab]] 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). Since 2026-07-05 a **right
sidebar lists the published history** (name or effective date, active badge — mirrors the lab's
sidebar); clicking a version **loads it into the editor as the starting point** for the next
publish (currency select ALL/EUR/USD; optional version-name field). Publishing never edits the
clicked version — the sidebar hint says so explicitly.
- 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; drafts redesign 2026-07-05; fee breakdown 2026-07-06)
> **Fee breakdown ("how is this sum produced").** The lab's Outcome panel lists the fee's LINE
> ITEMS: banded same-price increment runs (time window · N × unit · tier-card name), window-package
> occurrences, stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE
> adjustments, entry grace, plus a rounding note (raw min → billed min at the increment). Produced
> by `explainFee` in `@parking/shared` — the SAME computeFee walk with a trace collector, so
> Σ items ≡ the amount by construction (golden V1 regression unchanged). `/api/tariff/simulate`
> returns it as `breakdown` (null when settled). Also the composer grew INCREMENT-UNIT guards
> (2026-07-06): price labels state the real unit live ("Çmimi / orë" at 60, "Çmimi / N min"
> otherwise), an amber warning fires when increment ≠ 60, and each ladder/flat price shows its
> "= X / orë" equivalence — closing the 60→10 ×6-prices trap; example defaults are
> currency-scaled (ALL: 200/100, not the euro-scale 2.00/1.00).
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:
compose an **experimental rate card**, price hypothetical stays against it in seconds, and publish
only when satisfied.
- **Drafts (`tariff_drafts` table, 2026-07-05).** The lab's rate cards live in their own **mutable**
table — the one deliberate exception to "editing publishes a version". Rationale (operator ask,
2026-07-05): experimenting by publishing real versions churns the immutable history with noise AND
risks a wrong card being live while the admin iterates ("we risk taking tickets with a grossly
wrong version"). A draft prices nothing and signs nothing, so mutability is safe; the ONLY way a
draft affects a customer is publication through the normal `POST /api/tariff/versions` path
(validated, tz-stamped, immutable, effectiveFrom-guarded). Drafts are **validated + tz-stamped on
save exactly like a publish**, so a saved draft can always be simulated and "Publish" can never
fail on a card that saved fine.
- **API** (`apps/server/src/routes/tariffs.ts`): `GET/POST/PUT/DELETE /api/tariff/drafts[...]`
(list `tariff:read`; mutations `tariff:update`). `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); the lab passes a draft's stored `structure`
inline. `GET /api/tariff/simulate/session/:identity` (prefill from a real ledger session) still
exists API-side but the UI no longer uses it. All **read-only — no ledger writes.**
- **UI** (`apps/web/src/TariffLab.tsx`, Setup → Tariff → "Tariff Lab" tab): a **sidebar lists every
lab draft AND the full published history** (active card first, then older immutable versions) —
click any to price against it (drafts send their structure inline; published versions go by
`tariffVersionId`). Published versions carry an **optional name** (`tariff_versions.name`,
migration 0022, stamped at publish and immutable like the row): publishing a draft carries the
draft's name onto the version, and the composer page grew an optional version-name field — so
history reads "Winter 2027", not UUID prefixes. The main pane is a pure
**entry/exit** pair (the 2026-06-20 ticket-loader, payment, and category inputs were dropped in the
redesign — the lab is for composing rates, not re-evaluating tickets) plus amount due, billed
period, overstay/settled state, and the curve. **"New draft" / "Edit" open the composer form in a
modal** — the *same* form the `/setup/tariff` page uses, extracted to
`apps/web/src/TariffEditorForm.tsx` (new drafts prefill from the active card). Per-draft
**Publish** (confirm prompt) goes through the normal immutable-version path. Prices via the same
`priceSession` the booth uses, so the lab and the live booth can never diverge.
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|the 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).
- **Per-hour preview in the composer** (UX, candidate) — "Price / increment" is repeatedly misread as
per-hour (see the ⚠ note above). Showing the computed effective per-hour rate beside each ladder
price (`price × 60/incrementMin`), or a small live fee preview, would prevent it. No engine change.
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).