feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots

Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).

1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
   amount = span price × quantity; maxConcurrent defaults to the quantity so all
   N cars can be inside. Quantity rides in the payment payload.

2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
   park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
   NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
   tariff (the subscriber is a transient for that time):
     - early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
       the vehicle_entry payload), collected at exit;
     - late exit: window-close → departure, and exit is GATED
       (sub.refused.unpaidWindow) until paid at the booth.
   Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
   reuses computeFee + the active tariff version
   (apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
   business gate — the fail-open rule still governs the offline path.

3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
   max(0, quantity − itsCarsInside) per active subscription, so transients see
   "full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
   never gated by full.

UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.

Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 18:22:50 +02:00
parent fd4608a8f1
commit 53e1e7b25c
23 changed files with 929 additions and 40 deletions
+91 -2
View File
@@ -16,6 +16,7 @@ import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import { windowCharge } from "./subscription-window.js";
import type { VisionClient } from "./vision-client.js";
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
@@ -147,6 +148,23 @@ export class SubscriptionFlow {
return { accepted: false, direction: "exit", reason };
}
const occurrenceId = oldest.identity;
// TARIFF BRIDGE — exit gate. Total owed = carried early-entry charge (signed on the
// entry payload) + a late-exit charge (window-close→now) computed fresh. If the
// subscriber owes money and hasn't paid it, REFUSE the exit (like the transient
// unpaid/overstay gate) — they settle at the booth (a signed `payment` keyed to the
// occurrence), then re-scan. This is a host-ONLINE business gate; the offline path
// still fails open. See wiki/entities/subscription.md ("tariff bridge").
const owed = this.#windowOwed(occurrenceId, m.subscriptionId, sub.planVersionId);
const paid = this.#windowPaidMinor(occurrenceId);
if (owed.totalMinor - paid > 0) {
const reason = await this.#reject(m, "exit", "sub.refused.unpaidWindow", {
amount: ((owed.totalMinor - paid) / 100).toFixed(2),
currency: owed.currency ?? "",
});
return { accepted: false, direction: "exit", reason };
}
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
@@ -170,6 +188,14 @@ 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");
// 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.
const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
@@ -179,10 +205,30 @@ export class SubscriptionFlow {
source,
identity: occurrenceId,
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
// `permitId`/`permit` are the on-chain field names (immutable).
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via },
// `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.
payload: {
sessionRef: occurrenceId,
permitId: m.subscriptionId,
permit: true,
via: m.via,
...(entryCharge
? {
windowOwedMinor: entryCharge.amountMinor,
windowCurrency: entryCharge.currency,
windowTariffVersionId: entryCharge.tariffVersionId,
windowGapStart: entryCharge.gapStart,
windowGapEnd: entryCharge.gapEnd,
}
: {}),
},
occurredAt: now,
});
if (entryCharge) {
this.#logger.info(
`subscription early-entry charge ${entryCharge.amountMinor} ${entryCharge.currency} (${entryCharge.minutes}min) deferred on ${occurrenceId}`,
);
}
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
try {
this.#db
@@ -202,6 +248,49 @@ export class SubscriptionFlow {
return { accepted: true, direction: "entry" };
}
/**
* Total out-of-window charge owed for an occurrence right now: the carried EARLY-ENTRY
* charge (signed on the `vehicle_entry` payload as `windowOwedMinor`) + a fresh
* LATE-EXIT charge (window-close→now). Pure read; the entry portion is on-chain truth,
* the exit portion is recomputed each scan (it grows until they leave). Returns the sum
* and the currency. A plan without timeframes yields 0.
*/
#windowOwed(
occurrenceId: string,
_subscriptionId: string,
planVersionId: string | null,
): { totalMinor: number; currency: string | null } {
// Carried early-entry charge from the signed entry payload.
const entryRow = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, occurrenceId))
.all()
.find((r) => r.type === "vehicle_entry");
const ep = (entryRow?.payload ?? {}) as { windowOwedMinor?: number; windowCurrency?: string };
const entryOwed = typeof ep.windowOwedMinor === "number" ? ep.windowOwedMinor : 0;
// Fresh late-exit charge (window-close → now), priced transiently.
const exitCh = windowCharge(this.#db, planVersionId, new Date().toISOString(), "exit");
const exitOwed = exitCh?.amountMinor ?? 0;
const currency = ep.windowCurrency ?? exitCh?.currency ?? null;
return { totalMinor: entryOwed + exitOwed, currency };
}
/** Sum of signed `payment` events keyed to this occurrence (what the subscriber has
* already paid toward their window charge). Folds the append-only ledger. */
#windowPaidMinor(occurrenceId: string): number {
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
let paid = 0;
for (const r of rows) {
if (r.type !== "payment") continue;
const pl = (r.payload ?? {}) as { amountMinor?: number };
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
}
return paid;
}
/**
* The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose