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:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user