fix(subs): price out-of-window charge from minutes actually parked, not a fixed entry stamp

An out-of-window subscriber entry stamped a FIXED windowOwedMinor = the whole
gap to window-open (e.g. 800 ALL for a 13:21 arrival to a 20:00 window) and
deferred it to exit. That over-charged anyone who left before the window
opened — a 1-hour visit was billed as 6.5 hours.

The amount isn't knowable at entry: a subscriber may enter early, leave after
an hour, come and go several times before the window opens, and linger past
window-close. They should pay only for the minutes actually parked outside the
window (capped at the window edges) — exactly what minutesOutsideWindow already
computes.

So the entry now stamps a MARKER only (outOfWindow: true + windowTariffVersionId
for reproducible pricing), no fixed amount. The exit gate and booth quote price
it live via windowOwedBetween(entry → settle-time), which already caps at the
window edges (early entry stops accruing at window-open; the in-window portion
of a crossing stay is free; the late-exit tail keeps accruing until payment).
Both already called that one function, so they agree.

- subscription-flow: entry stamps outOfWindow marker; the advisory slip is now a
  scannable out-of-window TICKET (Code128 + QR of the occurrence id).
- shared LedgerPayload: add outOfWindow; mark windowOwedMinor/windowGap*/
  windowCurrency deprecated read-only (historic signed events still type-check).
- BoothScreen: window-charge badge keys on outOfWindow (or the old stamp).
- ActiveSessions: drop the always-on "Open barrier" for subscribers — the
  assist-open / window-charge payment live in the pay modal, so the list can't
  one-click past an unpaid out-of-window charge.

Verified the live model on a DB copy: 13:21→14:30 = 200 ALL; 19:55(in grace)→
23:00 = 0; 19:00→21:30 (crosses into window) = 100 ALL. Existing signed
occurrences left untouched (immutable). build+lint 14/14, shared 87/87.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-21 13:34:35 +02:00
parent df5caf8d87
commit 8acef0464c
6 changed files with 113 additions and 56 deletions
+12 -5
View File
@@ -287,13 +287,20 @@ export interface LedgerPayload {
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
readonly voucherNo?: string;
/** subscription tariff-bridge: an early-entry / late-exit transient charge OWED for
* parking outside the plan's allowed window, stamped on the vehicle_entry and collected
* (gated) at exit. The priced gap + tariff version travel alongside for reproducibility.
* See wiki/entities/subscription.md ("tariff bridge"). */
/** subscription tariff-bridge: this occurrence opened OUTSIDE the plan's allowed window,
* so the minutes actually parked out-of-window are charged at the transient tariff and
* collected (gated) at exit. The AMOUNT is NOT fixed at entry — it depends on how long
* they actually park out-of-window (capped at the window edges), so it's priced live at
* settlement from minutesOutsideWindow(entry → pay-time). Only the marker + the tariff
* version (for reproducible pricing) are stamped. See wiki/entities/subscription.md
* ("tariff bridge"). */
readonly outOfWindow?: boolean;
readonly windowTariffVersionId?: string;
/** DEPRECATED stamp — a FIXED full-gap amount written by an earlier model. No longer
* produced (it over-charged a subscriber who left before the window opened); retained
* here only so historic signed events still type-check. Never read for pricing. */
readonly windowOwedMinor?: number;
readonly windowCurrency?: string;
readonly windowTariffVersionId?: string;
readonly windowGapStart?: string;
readonly windowGapEnd?: string;
/** Free-form for forward-compat without a schema change. */