fd9885e9ec
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:
- tariff_drafts table (migration 0021): MUTABLE by design — the one
exception to "editing publishes a version"; a draft prices nothing and
signs nothing. Drafts are validated + tz-stamped on save exactly like a
publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
tariff:update); publishing a draft goes through the normal immutable
POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
(click any to price against it); main pane cut to pure entry/exit
(ticket loader, payment, category inputs dropped); the composer form is
extracted to TariffEditorForm.tsx and reused in a modal (new drafts
prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
publish — carried from the lab draft, or typed in the composer's new
optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
landed in d9e6c13) and the "Flat price / hour" relabel.
5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
358 lines
24 KiB
Markdown
358 lines
24 KiB
Markdown
---
|
||
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).
|
||
- 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)
|
||
|
||
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]]).
|