docs(wiki): tariff research — legacy ParkSQL2017 schema, time-tiers & validation/sponsorship design
Ingest the predecessor SQL Server schema (raw + source summary) and file design pages for time-of-day/seasonal tariff tiers and merchant validation/postpaid sponsorship. Cross-link tariff.md and validation-discounts.md. No code.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, pricing, design]
|
||||
sources: [parksql2017-legacy-schema]
|
||||
updated: 2026-06-17
|
||||
status: open
|
||||
---
|
||||
|
||||
# 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.
|
||||
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**.
|
||||
|
||||
> Status: **design, not built.** No schema/code committed yet — this records the chosen shape and
|
||||
> the rejected alternatives so implementation is a transcription.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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).
|
||||
Reference in New Issue
Block a user