feat(subs): print an advisory "out-of-window" slip at early entry

A subscriber entering outside their plan's window owes a deferred charge, but
nothing printed — they had no paper proof a fee was pending. Print a best-effort
ADVISORY slip at entry ("PARKIM — JASHTË ORARIT"): holder, entry time, "entered
out-of-window (window opens HH:MM)", and the key line "⚠ fee computed at exit"
+ the occurrence number. It is NOT a payable ticket and carries NO amount — the
total is computed at the booth on settlement, combining early-entry AND any
late-exit time into one number (windowOwedBetween over the whole stay).

Best-effort like the Z-report / subscription card: printed AFTER the barrier
opens and fully swallowed, so a missing/failed printer never blocks entry. New
printWindowChargeNotice (booth-print.ts) via the generic printReport; wired into
the subscription entry flow when an out-of-window entry charge applies.

(The "both charges at the booth" requirement was already satisfied by the
windowOwedBetween fix — verified: early-entry + late-exit minutes combine in one
calc at lookup/exit. This commit only adds the entry paper trail.) Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 20:47:45 +02:00
parent de858e91f4
commit f2734641b2
3 changed files with 69 additions and 10 deletions
+18 -8
View File
@@ -103,14 +103,24 @@ out-of-window scans, the system **charges the out-of-window minutes at the norma
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`).
- **Early entry is DEFERRED:** the barrier opens now; the owed amount is **signed onto the
`vehicle_entry` payload** (`windowOwedMinor` + the priced gap + `windowTariffVersionId`) — the
on-chain source of truth, read back at exit.
- **Late exit is GATED:** at exit, `totalOwed = carried entry charge + a fresh late-exit charge −
payments`. If `> 0`, the exit is **REFUSED** with a new 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 any taking) and re-scans. The booth pay modal surfaces the amount as an
"OUT-OF-WINDOW" charge (`PayStation.lookup`/`pay` handle the subscription-window case).
- **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).
- **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
any taking) and re-scans. The booth pay modal surfaces it as an "OUT-OF-WINDOW" charge
(`PayStation.lookup`/`pay`). So an early-entry-then-late-exit subscriber pays **both** portions in a
single amount, computed when they reach the booth.
> ⚠ **Exit gate vs. "never trap a vehicle."** This refusal is a **host-ONLINE business gate**,
> identical in kind to the existing transient `exit.refused.unpaid`/overstay gate — a working host
> *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide*