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
+24 -22
View File
@@ -189,13 +189,16 @@ export class SubscriptionFlow {
return { accepted: false, direction: "entry", reason };
}
// TARIFF BRIDGE — early entry. If the plan has time windows and this scan is before
// the window opens, the subscriber owes the transient tariff for arrival→window-open.
// We DEFER it (open now, collect at exit): stamp the owed amount on the SIGNED entry
// payload (the source of truth — `windowOwedMinor`), so the exit gate reads it back
// from the chain. Plans without timeframes return null → nothing owed. See
// wiki/entities/subscription.md.
const entryCharge = windowCharge(this.#db, sub.planVersionId, now, "entry");
// TARIFF BRIDGE — out-of-window entry. If the plan has time windows and this scan is
// OUTSIDE the allowed window, the subscriber will owe the transient tariff for the time
// they actually park out-of-window. The AMOUNT is NOT knowable now — it depends on when
// they leave (a subscriber who enters early and leaves before the window opens owes only
// their parked minutes, NOT the whole gap-to-window-open). So we stamp only a MARKER
// (`outOfWindow`) + the tariff version, and price it live at settlement from
// minutesOutsideWindow(entry → pay-time), which caps at the window edges. Open now
// (never trap); the charge is gated at exit. Plans without timeframes → null → no
// marker. See wiki/entities/subscription.md ("tariff bridge").
const outOfWindow = windowCharge(this.#db, sub.planVersionId, now, "entry");
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
// the payload's `permitId` (which every fold matches on), so the key stays compact.
@@ -205,29 +208,27 @@ export class SubscriptionFlow {
direction: "entry",
source,
identity: occurrenceId,
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
// `permitId`/`permit` are the on-chain field names (immutable). A deferred early-
// entry charge is signed here (windowOwedMinor + the priced gap) so it's owed at exit.
// The subscription IS the authorization (no fee for in-window use). `permitId`/`permit`
// are the on-chain field names (immutable). An out-of-window entry is MARKED here
// (`outOfWindow` + the tariff version for reproducible pricing) so the booth/exit gate
// know to charge the parked-out-of-window minutes — priced live, not a fixed amount.
payload: {
sessionRef: occurrenceId,
permitId: m.subscriptionId,
permit: true,
via: m.via,
...(entryCharge
...(outOfWindow
? {
windowOwedMinor: entryCharge.amountMinor,
windowCurrency: entryCharge.currency,
windowTariffVersionId: entryCharge.tariffVersionId,
windowGapStart: entryCharge.gapStart,
windowGapEnd: entryCharge.gapEnd,
outOfWindow: true,
windowTariffVersionId: outOfWindow.tariffVersionId,
}
: {}),
},
occurredAt: now,
});
if (entryCharge) {
if (outOfWindow) {
this.#logger.info(
`subscription early-entry charge ${entryCharge.amountMinor} ${entryCharge.currency} (${entryCharge.minutes}min) deferred on ${occurrenceId}`,
`subscription out-of-window entry marked on ${occurrenceId} (charge priced from parked minutes at exit)`,
);
}
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
@@ -246,11 +247,12 @@ export class SubscriptionFlow {
} catch (err) {
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
}
// BEST-EFFORT: print an advisory "out-of-window" slip so the subscriber has paper
// proof a fee is pending (the final amount is computed at the booth on settlement,
// combining early-entry + any late-exit time). AFTER the open + cache, and fully
// BEST-EFFORT: print the out-of-window TICKET so the subscriber has the paper the
// operator scans to settle at the booth. It carries the occurrence id as a scannable
// code; the amount is computed at settlement from the minutes actually parked
// out-of-window (capped at the window edges). AFTER the open + cache, and fully
// swallowed — a missing/failed printer must NEVER block or delay the barrier.
if (entryCharge) {
if (outOfWindow) {
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
void printWindowChargeNotice(
this.#db,