cf1ff5676d
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
164 lines
10 KiB
Markdown
164 lines
10 KiB
Markdown
---
|
||
type: concept
|
||
tags: [parking, domain, business, pricing, design]
|
||
sources: [parksql2017-legacy-schema]
|
||
updated: 2026-06-18
|
||
status: settled
|
||
---
|
||
|
||
# Tariff Time Tiers — happy hour, off-peak, weekend, seasonal
|
||
|
||
**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 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: **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
|
||
|
||
1. **Legacy `BA_TicketPrice`** ([[parksql2017-legacy-schema]]): each rate-card row is scoped by
|
||
`ValidFrom`/`ValidTo` (date window) **and** `ValidFromHour`/`ValidToHour` (daily hour window) **and**
|
||
`TicketCategoryID`. Happy hour = a second price row valid 14:00–16:00. Off-peak/season = a row
|
||
with a date or hour window. The active rate is selected by **(category, now-or-entry, date)**.
|
||
2. **Research (verified):** rates modelled as **time segments nested inside recurring time frames**,
|
||
where time frames = days-of-week / holidays / special-event days (US patent 10,762,723, 3-0
|
||
verified). Industry APIs (INRIX `structured_rate`) carry `time_in`/`time_out` + `dow` per rate.
|
||
Both point at the **same primitive**: a rate that is *active for a wall-clock window*.
|
||
|
||
Both converge: **happy hour is not a discount flag — it is a selector over which rate card is active
|
||
for a given slice of wall-clock time.**
|
||
|
||
## The decision to make: which-rate selector vs. discount modifier
|
||
|
||
| Option | Shape | Verdict |
|
||
| --- | --- | --- |
|
||
| **A. Time-windowed rate cards** (recommended) | A stay is sliced at wall-clock boundaries; each slice priced by the rate card whose window covers it. Happy hour = a card with `window: {dow, fromHour, toHour}`. | Most general: one mechanism covers happy hour, early-bird, night flat, weekend, season. Matches both references. |
|
||
| **B. Discount modifier on one ladder** | Keep one ladder; apply `−X%`/`−N min` when the clock is inside a window. | Simpler, but can't express "different ladder at night," daily caps interact badly, and it's a second pricing path. Rejected as the primary model. |
|
||
|
||
**Recommendation: A.** A discount-style happy hour (B) is then expressible *as* a windowed card (a
|
||
cheaper ladder), so we don't lose it.
|
||
|
||
## The wall-clock slicing consequence (the hard part)
|
||
|
||
The current `computeFee(enteredAt, asOf, structure)` walks **elapsed** minutes through `blocks`. Time
|
||
tiers add a **second clock**: the *wall-clock* time-of-day, which the elapsed walk doesn't track. A
|
||
stay 13:30→15:30 that has happy hour 14:00–16:00 must be **split at 14:00**: 30 min normal + 90 min
|
||
happy. So the fee function must:
|
||
|
||
1. Resolve the **applicable rate set** for the stay (all cards matching the category, ordered by
|
||
precedence — see below).
|
||
2. Walk the stay in wall-clock order, **switching the active card at each window boundary**, while
|
||
keeping the **elapsed-duration position** in the block ladder continuous (so block steps and the
|
||
daily cap still accrue across a window switch — a happy hour mid-stay must not reset the ladder).
|
||
3. Keep it **pure, integer, offline, deterministic** — the same invariants the current engine and the
|
||
[[append-only-event-chain|signed chain]] depend on. The `payment` event still records the
|
||
`tariffVersionId`; the version now contains the windowed card set, so a past session reprices
|
||
identically.
|
||
|
||
> Open edge: does the block ladder accrue by **elapsed time** (a 2h stay is in the 2nd block
|
||
> regardless of windows) or **reset per window**? Legacy `IntervalChange` hints some sites reset.
|
||
> **Lean: elapsed-continuous** (predictable, no double-charging), revisit if a site needs otherwise.
|
||
|
||
## Precedence (when windows overlap)
|
||
|
||
Multiple cards can match one instant (a weekday-evening card + a holiday card). Need a deterministic
|
||
winner. Proposal, most-specific-wins, matching the research's "event rates override":
|
||
`special-event/holiday > specific date range > day-of-week + hour > hour-only > default`. Ties broken
|
||
by an explicit integer `priority`. This must be **total and pure** — no ambiguity the operator can't
|
||
predict, no "depends on row order."
|
||
|
||
## Vehicle / customer category (the second new axis)
|
||
|
||
Legacy `BA_TicketCategory` prices by **category** (car/bus/VIP/…), orthogonal to time. Two ways:
|
||
- **Multiple tariffs scoped by category** — the schema already reserves `tariffs.scope`
|
||
(`site`/`zone`); add `category` cleanly, no migration. The session records which category it was
|
||
priced under.
|
||
- **Category as another window dimension** on the card. Simpler table, busier card.
|
||
|
||
**Lean: category as a tariff scope** (a category is a different rate *card*, not a different *window*
|
||
of one). Deferred until a site actually needs non-car pricing, but the `scope` hook means **no
|
||
migration when it lands**.
|
||
|
||
## Proposed data shape (illustrative)
|
||
|
||
Extend the `TariffStructure` JSON (still one immutable [[tariff]] version) with an optional ordered
|
||
card list; absence = today's single-ladder behaviour (back-compatible):
|
||
|
||
```jsonc
|
||
{
|
||
"currency": "ALL",
|
||
"defaultCard": { /* the existing blocks/cap/grace structure */ },
|
||
"windowedCards": [
|
||
{
|
||
"name": "Happy hour",
|
||
"priority": 10,
|
||
"window": { "dow": [1,2,3,4,5], "fromHour": "14:00", "toHour": "16:00" },
|
||
"blocks": [ /* cheaper ladder */ ],
|
||
"dailyCapMinor": null
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
A bare `defaultCard` (no `windowedCards`) is exactly today's tariff — so this ships additively and a
|
||
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
|
||
- 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.
|