Files
parking_solution/wiki/concepts/tariff-time-tiers.md
T
julian d9e6c13831 feat(tariff): whole-window package pricing mode (packageMinor)
A windowed card can now charge ONE total for any presence in its window —
the real night rate ("20:00–07:00 = 400, leave earlier and it's still
400"), which the per-increment flatMinor could not express (park-buzi's
"night 400" card billed 400/HOUR). Engine charges once 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 card as usual.

Operator decisions (2026-07-05): per-occurrence repeat (two nights = two
charges), any-touch-pays-full, windowed cards only (a base "price per
day" is a 1-row up-to table). Validator: mutually exclusive with
flat/blocks/steps, no per-card cap, forbidden on the defaultCard.
flatMinor docs clarified as PER INCREMENT. 6 new engine tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:25 +02:00

12 KiB
Raw Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
domain
business
pricing
design
parksql2017-legacy-schema
2026-07-05 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 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 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):

{
  "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 ("at 14:30 Tue a 2h stay costs …") — high-value for operator trust, deferred.