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
+15 -9
View File
@@ -12,9 +12,13 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
// possibly-present until grace runs out). Lets the operator find a stuck car —
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
// - click a row → the pay/exit modal (pay an unpaid car, or review),
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
// No payment → no Open barrier button (the no-unpaid-bypass rule).
// - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
// out-of-window charge, assist-open a prepaid subscriber, or review),
// - "Open barrier" (PAID transient sessions only) → an audited human-intervention
// re-pulse for a car that paid but whose barrier didn't confirm.
// No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get
// NO inline open here — their assist-open / window-charge payment is modal-only, so
// the list can't one-click past an unpaid out-of-window charge.
//
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
@@ -173,12 +177,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
</span>
</button>
{/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION
(prepaid). NOT an OVERSTAY session: its grace has expired, so the car
owes a top-up — the row routes to the pay/exit modal instead (no
free overstay exit). An unpaid transient also has no button
(no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */}
{(s.paidAt && !s.overstay) || s.subscription ? (
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
re-pulse for a car that paid but the barrier didn't confirm. NOT an
OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
live in the pay/exit modal — the list must not offer a one-click open,
which would bypass an unpaid window charge). An unpaid transient has no
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
{s.paidAt && !s.overstay && !s.subscription ? (
<button
type="button"
disabled={reopen.isPending || !shiftReady}
+6 -3
View File
@@ -112,9 +112,12 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
if (p.permitRefused) keys.push("booth.badgeSubRefused");
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
// Subscriber entered/exited outside their plan's allowed window → owes a deferred
// transient charge, collected (gated) at exit. Flag it so the operator KNOWS now.
if (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0) keys.push("booth.badgeWindowCharge");
// Subscriber entered outside their plan's allowed window → will owe a transient charge
// for the minutes actually parked out-of-window, priced + collected (gated) at exit.
// Flag it so the operator KNOWS now. (`windowOwedMinor` is the old fixed-amount stamp,
// kept so historic events still badge.)
if (p.outOfWindow === true || (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0))
keys.push("booth.badgeWindowCharge");
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
return keys;
}