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
+40 -17
View File
@@ -98,23 +98,33 @@ out-of-window scans, the system **charges the out-of-window minutes at the norma
the V2 [[tariff]]**, Hën–Die; empty = every day). On a day NOT in the set the subscriber parks free.
`tz` is frozen in the plan version (like a V2 tariff's tz). null timeframes = 24/7, no charge ever.
*(A "night plan, free weekends" is just `days:[Mon..Fri], 20:00→08:00`.)*
- `outOfWindowGap(timeframes, tz, at, edge)` (pure, tz-aware, unit-tested in `@parking/shared`)
returns the `[start, end]` portion outside the window. **Early entry**: gap = arrival → next
window-open (a 09:00 arrival to a 20:00 window owes 09:00→20:00, capped by the tariff's daily cap).
**Late exit**: gap = window-close → departure. The gap is priced with `computeFee` (the same engine
transient stays use) at the active tariff version (`apps/server/src/subscription-window.ts`).
- **The owed amount is ONE computation over the whole stay** (`windowOwedBetween` →
`minutesOutsideWindow(timeframes, tz, entry, now)`): the minutes within `[entry, now]` that fall
outside the allowed window — covering **early entry AND late exit together**, bounded by the stay,
off-days free. Priced once as a transient duration (so increments + the daily cap apply). This
replaced an earlier buggy "entry-gap + exit-gap" sum whose exit gap reached back to a *previous*
day's close, charging a phantom ~12h to a car that had just entered early (the 4,100 ALL bug,
fixed 2026-06-20). Both the exit gate and the booth quote call this one function, so they agree.
- **Early entry is DEFERRED:** the barrier opens now; an advisory `windowOwedMinor` + priced gap are
signed onto the `vehicle_entry` for the feed badge, and a **best-effort advisory slip prints**
("PARKIM — JASHTË ORARIT": entered out-of-window, *fee computed at exit*, occurrence no.) so the
subscriber has paper proof. A missing/failed printer NEVER blocks the barrier (`printWindowChargeNotice`,
fully swallowed, after the open).
- **An out-of-window subscriber is a transient ONLY for the minutes actually parked outside the
window — the amount is NOT knowable at entry.** A night-plan subscriber (window opens 20:00) who
arrives at 13:21 and leaves at 14:30 parked **~1 hour** out-of-window and owes **one hour's transient
fee** — NOT the whole 13:21→20:00 gap. They may come and go several times before the window opens;
each parked interval is its own short transient charge. So nothing fixed can be billed at entry.
- **The owed amount is ONE live computation over the whole stay** (`windowOwedBetween` →
`minutesOutsideWindow(timeframes, tz, entry, settle-time)`): the minutes within `[entry, settle]`
that fall outside the allowed window, **capped at the window edges** — covering early entry AND late
exit together, off-days free. Priced once as a transient duration with `computeFee` (so increments +
the daily cap apply) at the active tariff version (`apps/server/src/subscription-window.ts`). Both
the exit gate and the booth quote call this one function against the **current time**, so they agree
and the amount reflects exactly the out-of-window minutes parked. `settle-time` is the exit-scan at
the gate, and the pay-time at the booth; the **late-exit tail keeps accruing until payment** (it
doesn't stop at the refused scan), so a subscriber who lingers past window-close pays for that time.
- **Capping is automatic:** once a subscriber crosses INTO the window (e.g. parked 19:00→21:30 with
a 20:00 open), only the 19:00→20:00 portion is charged; the in-window time is free. An early
arrival who is still parked when the window opens stops accruing at window-open.
- This corrected the earlier model that **stamped a FIXED `windowOwedMinor` = full gap-to-window-open
at entry** (e.g. 800 ALL for 13:21→20:00) and deferred it — which over-charged anyone who left
before the window opened. The fixed stamp is gone; see [[#tariff-bridge-history]].
- **Out-of-window entry opens the barrier and prints a window-bounded TICKET.** The `vehicle_entry`
carries only a **marker** (`outOfWindow: true` + `windowTariffVersionId` for reproducible pricing),
NO fixed amount. A **best-effort ticket slip prints** ("PARKIM — JASHTË ORARIT": entered
out-of-window, *fee computed at exit*, occurrence no.) carrying the occurrence id as a **scannable
Code128 + QR** — the operator scans it straight into the booth pay modal at settlement, the same
scan path as a transient ticket. A missing/failed printer NEVER blocks the barrier
(`printWindowChargeNotice`, fully swallowed, after the open).
- **Late exit is GATED:** at exit, `owed = windowOwedBetween(entry, now) − payments`. If `> 0`, the
exit is **REFUSED** with the signed reason `sub.refused.unpaidWindow`; the subscriber settles at
the booth (a signed `payment` keyed to the occurrence — folds into the shift/drawer/Z-report like
@@ -126,6 +136,19 @@ out-of-window scans, the system **charges the out-of-window minutes at the norma
> *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide*
> (power/host/network loss) path, which still opens. The two are not in conflict; don't conflate them.
##### tariff-bridge history
The out-of-window charge has had two superseded models, both over-charging:
1. **entry-gap + exit-gap sum** whose exit gap reached back to a *previous* day's close → a phantom
~12h on a car that had just entered early (the 4,100 ALL bug, fixed 2026-06-20 by switching to the
single `windowOwedBetween(entry, now)` computation).
2. **a FIXED `windowOwedMinor` stamped at entry** = the whole gap-to-window-open (e.g. 800 ALL for a
13:21 arrival to a 20:00 window), deferred and billed at exit → over-charged anyone who left before
the window opened (a 1-hour visit billed as 6.5 hours). Fixed 2026-06-21: the entry stamp is now a
**marker only** (`outOfWindow` + `windowTariffVersionId`); the amount is priced live from the
minutes **actually** parked out-of-window, capped at the window edges. `windowOwedMinor` and the
`windowGap*`/`windowCurrency` fields remain in the `LedgerPayload` type as **deprecated, read-only**
so historic signed events still type-check; they are never produced or read for pricing.
**Reserved subscriber spots** — see [[capacity-occupancy]] (an admin toggle that holds a spot per
active subscriber's car in the [[occupancy]] full-gate). The subscriber flow itself is never gated by
"full"; reservation only tightens the *transient* gate.