--- type: concept tags: [parking, domain, business, pricing, design] sources: [parksql2017-legacy-schema] updated: 2026-07-05 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, , 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). ⚠ `flatMinor` is **per billing increment** (an hourly flat rate at increment 60) — NOT a whole-stay/whole-window price. This was misread in the field (park-buzi published a "night 400" believing it covered the night; it billed 400/h, 2026-07-05) — the UI now labels it "Flat price / hour" and the whole-window need got its own mode: - **WINDOW PACKAGE (`packageMinor`, 2026-07-05 — windowed cards only).** "Any presence in this window = ONE total" (the real night rate: 20:00–07:00 = 400, leave earlier and it's still 400). Decisions (operator, 2026-07-05): charged **once per occurrence** (a stay touching two nights pays twice); **any touch pays full** (an 06:30 arrival before the 07:00 close pays the whole package — package pricing's accepted sharp edge); **not offered on the base card** (a base "one price per day" is a 1-row up-to table — no duplicate concept). Engine: one charge per **contiguous run of increments the card wins**, tracked across rolling-day segments so a night crossing the 24h boundary charges once; out-of-window increments price by the base rate as usual; the charge lands in the day segment where the occurrence starts (that day's default-card cap applies). Mutually exclusive with flat/blocks/steps + no per-card cap (the package IS the window's total); validator enforces both and the composer offers the mode only on tier cards. - **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.