From 8acef0464c425b6140931db5e3b5eb08e76549c3 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 21 Jun 2026 13:34:35 +0200 Subject: [PATCH] fix(subs): price out-of-window charge from minutes actually parked, not a fixed entry stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/subscription-flow.ts | 46 +++++++++++----------- apps/web/src/ActiveSessions.tsx | 24 +++++++----- apps/web/src/BoothScreen.tsx | 9 +++-- packages/shared/src/index.ts | 17 ++++++--- wiki/entities/subscription.md | 57 +++++++++++++++++++--------- wiki/log.md | 16 ++++++++ 6 files changed, 113 insertions(+), 56 deletions(-) diff --git a/apps/server/src/subscription-flow.ts b/apps/server/src/subscription-flow.ts index ff5666d..483beb7 100644 --- a/apps/server/src/subscription-flow.ts +++ b/apps/server/src/subscription-flow.ts @@ -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, diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index 2ecab32..a1ee7de 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -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 - {/* 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 ? (