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

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

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

  1. 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.
  • 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 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). 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 + 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).