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:
@@ -3,6 +3,7 @@ import { priceSession, type TariffStructure, type Tender } from "@parking/shared
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||
import { windowCharge } from "./subscription-window.js";
|
||||
|
||||
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||
@@ -200,6 +201,30 @@ export class PayStation {
|
||||
tender: Tender,
|
||||
overrideMinor?: number,
|
||||
): Promise<{ amountMinor: number; currency: string }> {
|
||||
// A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here
|
||||
// (not a transient quote — the subscription itself is prepaid). The payment is keyed
|
||||
// to the occurrence so the exit gate (#windowOwed − payments) clears.
|
||||
const subWindow = this.#payableSubscriptionWindow(identity);
|
||||
if (subWindow) {
|
||||
const amountMinor = overrideMinor ?? subWindow.dueMinor;
|
||||
await this.#log.append({
|
||||
type: "payment",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
amountMinor,
|
||||
currency: subWindow.currency ?? undefined,
|
||||
tender,
|
||||
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
|
||||
subscriptionWindowCharge: true,
|
||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
|
||||
},
|
||||
});
|
||||
this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`);
|
||||
return { amountMinor, currency: subWindow.currency ?? "" };
|
||||
}
|
||||
|
||||
const q = this.quote(identity);
|
||||
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||
|
||||
@@ -273,8 +298,11 @@ export class PayStation {
|
||||
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
|
||||
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
|
||||
|
||||
// Amount owed now (best-effort; null if no tariff resolves). Only meaningful while
|
||||
// open AND transient — a subscription is prepaid, never quoted/charged.
|
||||
// Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session
|
||||
// it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a
|
||||
// time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried
|
||||
// on the entry payload + a live late-exit charge), which the booth must take so the
|
||||
// exit gate clears. See wiki/entities/subscription.md.
|
||||
let amountMinor: number | null = null;
|
||||
let currency: string | null = null;
|
||||
if (open && !isSubscription) {
|
||||
@@ -285,6 +313,12 @@ export class PayStation {
|
||||
} catch {
|
||||
/* no active tariff — leave null; modal shows session without a price */
|
||||
}
|
||||
} else if (open && isSubscription) {
|
||||
const w = this.#subscriptionWindowDue(id, subscriptionId);
|
||||
if (w && w.dueMinor > 0) {
|
||||
amountMinor = w.dueMinor;
|
||||
currency = w.currency;
|
||||
}
|
||||
}
|
||||
|
||||
const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
|
||||
@@ -417,6 +451,65 @@ export class PayStation {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right
|
||||
* now: carried early-entry charge (signed on the entry payload) + a fresh late-exit
|
||||
* charge (window-close→now) − whatever they've already paid against the occurrence.
|
||||
* null when the plan has no timeframes / nothing is owed. Mirrors SubscriptionFlow's
|
||||
* exit-gate computation so the booth quote and the gate agree.
|
||||
*/
|
||||
#subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null {
|
||||
if (!subscriptionId) return null;
|
||||
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||
if (!sub) return null;
|
||||
|
||||
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
|
||||
const entryRow = rows.find((r) => r.type === "vehicle_entry");
|
||||
const ep = (entryRow?.payload ?? {}) as { windowOwedMinor?: number; windowCurrency?: string };
|
||||
const entryOwed = typeof ep.windowOwedMinor === "number" ? ep.windowOwedMinor : 0;
|
||||
|
||||
const exitCh = windowCharge(this.#db, sub.planVersionId, new Date().toISOString(), "exit");
|
||||
const exitOwed = exitCh?.amountMinor ?? 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const dueMinor = entryOwed + exitOwed - paid;
|
||||
const currency = ep.windowCurrency ?? exitCh?.currency ?? null;
|
||||
return { dueMinor, currency };
|
||||
}
|
||||
|
||||
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
|
||||
* the due amount + currency + the tariff version that priced the late-exit charge (for
|
||||
* the payment payload), or null when it's transient / nothing owed. */
|
||||
#payableSubscriptionWindow(
|
||||
identity: string,
|
||||
): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null {
|
||||
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all();
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return null;
|
||||
const ep = (entry.payload ?? {}) as { permit?: boolean; permitId?: string; windowTariffVersionId?: string };
|
||||
if (ep.permit !== true && ep.permitId == null) return null; // transient
|
||||
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out
|
||||
const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null);
|
||||
if (!due || due.dueMinor <= 0) return null;
|
||||
// The late-exit charge resolves its own tariff version; for the entry-only case we
|
||||
// stamped windowTariffVersionId on entry — pass whichever applies for reproducibility.
|
||||
const exitCh = windowCharge(this.#db, this.#planVersionOf(ep.permitId ?? null), new Date().toISOString(), "exit");
|
||||
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: exitCh?.tariffVersionId ?? ep.windowTariffVersionId ?? null };
|
||||
}
|
||||
|
||||
/** The planVersionId of a subscription (for resolving its timeframes), or null. */
|
||||
#planVersionOf(subscriptionId: string | null): string | null {
|
||||
if (!subscriptionId) return null;
|
||||
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||
return row?.planVersionId ?? null;
|
||||
}
|
||||
|
||||
/** The subscriber's holder name for a subscription id (for a friendly UI label),
|
||||
* or null. Best-effort: a deleted subscription just yields null. */
|
||||
#holderOf(subscriptionId: string | null): string | null {
|
||||
|
||||
Reference in New Issue
Block a user