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