Files
julian 5287be5278
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
docs(wiki): catch-up sweep — five pages lagging the log
rongta-printer still named the cashino driver id (→ escpos + migration
0023 note); tariff-time-tiers listed the composer price preview as
deferred (→ delivered by the lab fee breakdown); tariff.md lab section
gained the breakdown + composer increment-guard paragraph; i18n.md now
records the "25 Qer 14:30" date standard + never-toLocaleString-for-
dates rule; fleet-deployment-komodo gained the park-lab stack + tier
table (the park-lab addition had also slipped the log — both fixed).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 13:08:55 +02:00

180 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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, <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). ⚠ `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**~~ — largely DELIVERED 2026-07-06 by the Tariff Lab's fee
BREAKDOWN (see [[tariff]] §Tariff Lab): simulate any stay against a draft/version and read the
line items. A live preview inside the composer form itself remains a possible nicety.