Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).
1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
amount = span price × quantity; maxConcurrent defaults to the quantity so all
N cars can be inside. Quantity rides in the payment payload.
2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
tariff (the subscriber is a transient for that time):
- early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
the vehicle_entry payload), collected at exit;
- late exit: window-close → departure, and exit is GATED
(sub.refused.unpaidWindow) until paid at the booth.
Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
reuses computeFee + the active tariff version
(apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
business gate — the fail-open rule still governs the offline path.
3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
max(0, quantity − itsCarsInside) per active subscription, so transients see
"full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
never gated by full.
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.
Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
22 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||
|---|---|---|---|---|---|---|---|---|
| concept |
|
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 witheffectiveFrom ≤ sale", with the sale persisting itsplanVersionIdfor 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.
computeFeeis reused unchanged; the gap is a normal[start, end]priced against the active version (recordedtariffVersionIdfor 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 withasOf = 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
paymentevent (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:
- 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. - Flat (
flatMinor) — one rate per increment (a one-block ladder).
⚠
priceMinorPerIncrementis PER BILLING INCREMENT, not per hour. The effective hourly rate isprice × (60 / incrementMin). So withincrementMin: 30, a block priced100charges 100 every half-hour = 200/hour → a 3h stay costs100 × 6 = 600, not 300. The example below usesincrementMin: 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 to50; or setincrementMin: 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).
- 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 ≥ durationwins; 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
stepstable replaces theblocksladder and forbidsdailyCapMinor(the top tier IS the per-day cap). In V2 it is allowed only on thedefaultCard— 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 tosteppedFeeon 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. Theproblems[]array now surfaces throughApiErrorto 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
gracePeriodEntryMinis 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) andPOST /api/tariff/versions(publish a new immutable version; admin only). Publishing validates the structure viavalidateTariffStructure(shared) — non-negative integers, ordered/ascending block bounds, the last block open-ended (enforced), andeffectiveFromnot in the past (no backdating) — so a malformed or retroactive card can never be published. The single sitetariffsrow 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 cumulativeuptoMin(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/tariffreturnsactive: nulland the pay station returns409 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/simulateprices a hypothetical session — body{enteredAt, asOf, payments[], category?, tariffVersionId? | structure?}— and returns the fullpriceSessionoutcome 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/:identityprefills 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 samepriceSessionthe 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:
- At the pay station:
fee = f(enteredAt, now, tariff)— charge for time parked so far. - 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
effectiveFromtimestamp; prior versions are immutable. Picking the version for a session = "the latest version witheffectiveFrom ≤ session entry time". - The session's
paymentevent records thetariffVersionIdit 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_versionnames itscurrency(ISO 4217), admin-selectable. Amounts everywhere are{ minorUnits, currency }— never a bare number, never a float. - A
paymentevent stores itscurrencyand a reservedfxRate(null for now) + optionalbaseCurrency. 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). AdefaultCardplus 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 (nodefaultCard) 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).