feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)

Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.

computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.

Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 20:00:13 +02:00
parent 91cc79b14e
commit cf1ff5676d
21 changed files with 1524 additions and 163 deletions
+61 -13
View File
@@ -2,19 +2,21 @@
type: concept
tags: [parking, domain, business, pricing, design]
sources: [parksql2017-legacy-schema]
updated: 2026-06-17
status: open
updated: 2026-06-18
status: settled
---
# Tariff Time Tiers — happy hour, off-peak, weekend, seasonal
Design for **time-of-day / day-of-week / seasonal pricing** on top of the existing [[tariff]] engine.
**Time-of-day / day-of-week / seasonal / category pricing** on top of the existing [[tariff]] engine.
Resolves the `tariff.md` open question *"Time-of-day / weekday tiers — not in the block model yet."*
Driven by two concrete operator asks: a **happy-hour** rate, and (from [[parksql2017-legacy-schema|the
legacy schema]]) **vehicle/customer categories**.
Driven by the ask to match the legacy [[parksql2017-legacy-schema|ParkSQL2017]] pricing breadth
(happy hour, weekend/seasonal windows, vehicle/customer category, flat rate) — but on our
integer-minor-unit money + immutable signed-version engine, NOT legacy's float money / mutable rows.
> Status: **design, not built.** No schema/code committed yet — this records the chosen shape and
> the rejected alternatives so implementation is a transcription.
> Status: **BUILT 2026-06-18 (V2 tariff).** This page records the as-built shape + the decisions.
> The engine is the "V2" arm of `TariffStructure` in `@parking/shared`; a bare V1 structure (no
> `defaultCard`) still prices via the unchanged V1 algorithm. See the as-built section at the end.
## The two real-world models we looked at
@@ -106,10 +108,56 @@ A bare `defaultCard` (no `windowedCards`) is exactly today's tariff — so this
site that never wants tiers never sees them. Keeps the **intuitive-for-operators** goal: the common
case stays one rate card; tiers are opt-in.
## As-built (2026-06-18) — resolved decisions
- **Shape**: `TariffStructure` is a discriminated union. **V1** = the original bare ladder (unchanged,
verbatim algorithm). **V2** = `{ version:2, tz, <shared knobs>, defaultCard, windowedCards[] }`.
Discriminant = presence of `defaultCard`. Grace/increment/lostTicket/exit-grace are **top-level
(shared)**; the flat-XOR-ladder body + per-card `dailyCapMinor` live on each card.
- **Ladder accrual = elapsed-continuous** (decided). Elapsed minutes advance the block-ladder
position; wall-clock selects the card per increment. A happy-hour boundary mid-stay does NOT reset
the ladder or the daily cap. Implemented by stepping one `incrementMin` at a time and re-selecting
the card (boundary slicing is implicit).
- **Timezone is FROZEN in the version** (`structure.tz`), sourced from **site config**
(`site_config.timezone`, default `Europe/Tirane`) and stamped server-side on publish — NEVER read
from the host clock, or historical repricing would drift and break the signed ledger. Tested for
DST determinism (`Europe/Tirane` spring-forward/fall-back).
- **Daily cap on a mixed day = the DEFAULT card's `dailyCapMinor`** governs the whole rolling-24h
segment (decided). Windowed cards lower the rate, never the day ceiling. Predictable + easy to
explain.
- **Precedence** = specificity tuple **(date > dow > hour-only)**, then integer `priority` (higher
wins), then `name` lexicographically as the **final, total, order-independent** tiebreak. Validation
*rejects* two cards tied on (category, specificity, priority) with overlapping windows, forcing the
operator to disambiguate with `priority`. (Property-tested: shuffling `windowedCards` yields an
identical fee.)
- **Category = a FIELD on each card** (`card.category`), NOT a tariff scope (reversed the earlier
lean). Justification: both pricing call-sites hardcode the single `scope:"site"` tariff; a card-field
keeps the whole category→price mapping inside the one immutable `structure` the `payment` event
already pins via `tariffVersionId` — fewer frozen moving parts, no `tariffs`-table rework. A card
with no `category` applies to all; the `defaultCard` is category-agnostic. The session's category is
**frozen in the signed `vehicle_entry` payload** (`payload.category`), so exit reprices identically.
Sourced today from `site_config.default_vehicle_category` (operator policy; default
`DEFAULT_VEHICLE_CATEGORY` in `@parking/shared`). Per-relay capture (a "bus lane") is the future
seam, mirroring per-relay direction.
- **Flat rate** is a first-class card body (`flatMinor`, mutually exclusive with `blocks`). A flat V1
is published as a single open-ended block (V1 has no flat field).
- **UI** (`TariffComposer.tsx`): default card **front-and-centre** (flat/ladder toggle + cap); tiers
under a collapsed **"Advanced: time & seasonal tiers"** disclosure (window builder — dow checkboxes,
optional date range, optional hour range with an overnight hint; category; priority; flat/ladder
body reusing the default editor). `toStructure` emits a **bare V1 when there are no tiers**
(back-compat: untouched sites publish exactly today's shape).
**As-built code**: `computeFee`/`computeFeeV2`/`validateTariffStructure`/`selectCard`/`localBreakdown`
in `packages/shared/src/index.ts` (+ `tariff.test.ts`, 36 cases incl. the golden V1 regression);
`routes/tariffs.ts` (tz stamping), `routes/site.ts` (tz + default-category fields), `entry-flow.ts`
(category frozen at entry), `pay-station.ts` + `exit-flow.ts` (read category, pass to `computeFee`);
`schema.ts` + migrations `0005`/`0006` (`site_config.timezone`, `default_vehicle_category`);
`TariffComposer.tsx` + `api.ts` + i18n.
## Open
- Elapsed-continuous vs. per-window ladder reset (lean: elapsed-continuous).
- Holiday/special-event calendar: a date list per version, or a separate editable calendar table?
- Precedence model — confirm most-specific + explicit `priority` tiebreak.
- Category axis — confirm "category = tariff scope" vs. window dimension (deferred).
- UI: how to author windows without confusing operators (the notoriously-hard part — keep default
card front-and-center, tiers as an "advanced" add).
- Holiday/special-event calendar: today a date range per card (`dateFrom`/`dateTo`); a reusable named
holiday calendar (one date list, referenced by cards) is a future nicety, not built.
- Per-relay/lane **category capture** at a transient gate (the "bus lane") — seam noted in
`entry-flow.ts`; today every transient takes the site default category.
- A composer **price preview** ("at 14:30 Tue a 2h stay costs …") — high-value for operator trust,
deferred.
+9 -7
View File
@@ -217,15 +217,17 @@ Unlike the event log, tariff data is **mutable master data** in the sense that n
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
[[open-questions]].
## Extensions under design
## Extensions
Two operator asks extend this engine; both have design pages (not yet built), grounded in
[[parksql2017-legacy-schema|the legacy schema]] + external research:
Grounded in [[parksql2017-legacy-schema|the legacy schema]] + external research:
- **Time-of-day / weekday / seasonal tiers** (happy hour, off-peak, weekend, vehicle category) —
see [[tariff-time-tiers]]. Chosen shape: **time-windowed rate cards** selected by wall-clock window,
layered additively on this structure (a bare default card = today's behaviour). The hard part is
slicing a stay at window boundaries while keeping the block ladder + daily cap continuous.
- **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