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:
@@ -1,4 +1,4 @@
|
||||
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||
import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||
|
||||
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
|
||||
// with no matching vehicle_exit. Never a hand-maintained counter (which is
|
||||
@@ -7,11 +7,18 @@ import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||
export interface Occupancy {
|
||||
/** Cars currently inside (open sessions). */
|
||||
readonly count: number;
|
||||
/** Spots HELD for active subscribers who are NOT currently parked (when the
|
||||
* reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds
|
||||
* `quantity` spots minus however many of its cars are already inside. */
|
||||
readonly reserved: number;
|
||||
/** Admin-set nominal capacity, or null = no limit. */
|
||||
readonly capacity: number | null;
|
||||
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
||||
readonly free: number | null;
|
||||
/** True when count ≥ capacity (always false when uncapped). */
|
||||
/** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */
|
||||
readonly effectiveFree: number | null;
|
||||
/** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity
|
||||
* (always false when uncapped). Subscribers are never gated by this. */
|
||||
readonly full: boolean;
|
||||
}
|
||||
|
||||
@@ -37,13 +44,66 @@ export function siteCapacity(db: Db): number | null {
|
||||
return row?.capacity ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless
|
||||
* `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status
|
||||
* active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that
|
||||
* subscription already inside (so we never double-count a parked subscriber). This is
|
||||
* what makes a transient see "full" sooner while the subscriber's spot is held.
|
||||
*/
|
||||
export function reservedSubscriberSpots(db: Db): number {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (!cfg?.reserveSubscriberSpots) return 0;
|
||||
|
||||
// Cars currently inside per subscription (occurrence entries by permitId, net of exits).
|
||||
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
const insidePerSub = new Map<string, number>();
|
||||
const net = new Map<string, number>(); // occurrence identity → entries−exits
|
||||
const subOf = new Map<string, string>(); // occurrence identity → subscription id
|
||||
for (const r of rows) {
|
||||
const id = r.identity;
|
||||
if (!id) continue;
|
||||
if (r.type === "vehicle_entry") {
|
||||
const pl = (r.payload ?? {}) as { permitId?: string };
|
||||
if (pl.permitId == null) continue; // transient
|
||||
net.set(id, (net.get(id) ?? 0) + 1);
|
||||
subOf.set(id, pl.permitId);
|
||||
} else if (r.type === "vehicle_exit") {
|
||||
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
||||
}
|
||||
}
|
||||
for (const [id, n] of net) if (n > 0) {
|
||||
const sub = subOf.get(id)!;
|
||||
insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const subs = db.select().from(subscriptions).all();
|
||||
let reserved = 0;
|
||||
for (const s of subs) {
|
||||
const active =
|
||||
s.status === "active" &&
|
||||
(s.validFrom == null || now >= s.validFrom) &&
|
||||
(s.validTo == null || now <= s.validTo);
|
||||
if (!active) continue;
|
||||
const qty = s.quantity ?? 1;
|
||||
const inside = insidePerSub.get(s.id) ?? 0;
|
||||
reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion
|
||||
}
|
||||
return reserved;
|
||||
}
|
||||
|
||||
export function getOccupancy(db: Db): Occupancy {
|
||||
const count = occupancyCount(db);
|
||||
const capacity = siteCapacity(db);
|
||||
const reserved = reservedSubscriberSpots(db);
|
||||
return {
|
||||
count,
|
||||
reserved,
|
||||
capacity,
|
||||
free: capacity == null ? null : capacity - count,
|
||||
full: capacity != null && count >= capacity,
|
||||
effectiveFree: capacity == null ? null : capacity - count - reserved,
|
||||
// A transient is refused once physical cars + held subscriber spots reach capacity.
|
||||
full: capacity != null && count + reserved >= capacity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,6 +29,9 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||
exitVoucherDefault?: boolean;
|
||||
/** Site default monthly subscription price in minor units (pre-fills the form). */
|
||||
subscriptionMonthlyPriceMinor?: number | null;
|
||||
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
|
||||
* parked — so transients see "full" sooner and the subscriber's spot is held. */
|
||||
reserveSubscriberSpots?: boolean;
|
||||
}
|
||||
|
||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||
@@ -37,6 +40,7 @@ type SiteConfig = {
|
||||
capacity: number | null;
|
||||
exitVoucherDefault: boolean;
|
||||
subscriptionMonthlyPriceMinor: number | null;
|
||||
reserveSubscriberSpots: boolean;
|
||||
} & Record<TextField, string | null>;
|
||||
|
||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||
@@ -44,6 +48,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
||||
capacity: row?.capacity ?? null,
|
||||
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||
} as SiteConfig;
|
||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||
return out;
|
||||
@@ -95,6 +100,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
patch.subscriptionMonthlyPriceMinor = p ?? null;
|
||||
}
|
||||
if ("reserveSubscriberSpots" in body) {
|
||||
if (typeof body.reserveSubscriberSpots !== "boolean") {
|
||||
return reply.code(400).send({ error: "reserveSubscriberSpots must be a boolean" });
|
||||
}
|
||||
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
|
||||
}
|
||||
for (const f of TEXT_FIELDS) {
|
||||
if (f in body) patch[f] = normText(body[f]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, subscriptionPlans, type Db } from "@parking/db";
|
||||
import { SUBSCRIPTION_PERIODS, type SubscriptionPeriod } from "@parking/shared";
|
||||
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { siteTz } from "../subscription-window.js";
|
||||
|
||||
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
|
||||
// from (so they never type a price). Mirrors the tariff composer: plans are
|
||||
@@ -22,6 +23,20 @@ interface PlanBody {
|
||||
currency?: string;
|
||||
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||
effectiveFrom?: string;
|
||||
/** Allowed-time windows (tariff bridge); null/omitted = 24/7. */
|
||||
timeframes?: PlanTimeframes | null;
|
||||
}
|
||||
|
||||
/** Validate the optional timeframes blob (minutes-of-day 0–1439, sane grace). */
|
||||
function validTimeframes(tf: PlanTimeframes | null | undefined): string | null {
|
||||
if (tf == null) return null;
|
||||
const okMin = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439);
|
||||
for (const dt of [tf.weekday, tf.weekend]) {
|
||||
if (!dt) continue;
|
||||
if (!dt.allDay && (!okMin(dt.fromMin) || !okMin(dt.toMin))) return "window times must be minutes-of-day (0–1439)";
|
||||
}
|
||||
if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */
|
||||
@@ -51,6 +66,8 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) {
|
||||
errs.push("effectiveFrom must be a valid ISO-8601 timestamp");
|
||||
}
|
||||
const tfErr = validTimeframes(b.timeframes);
|
||||
if (tfErr) errs.push(tfErr);
|
||||
return errs;
|
||||
}
|
||||
|
||||
@@ -85,6 +102,11 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales",
|
||||
});
|
||||
}
|
||||
// Stamp the site tz into the timeframes so the windows evaluate in the site's
|
||||
// wall-clock, FROZEN in this version (mirrors how tariff V2 freezes its tz).
|
||||
const timeframes =
|
||||
b.timeframes != null ? { ...b.timeframes, tz: b.timeframes.tz || siteTz(db) } : null;
|
||||
|
||||
const row = {
|
||||
id: randomUUID(),
|
||||
planId,
|
||||
@@ -93,10 +115,11 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
pricePerPeriodMinor: b.pricePerPeriodMinor!,
|
||||
currency: b.currency!.trim(),
|
||||
effectiveFrom,
|
||||
timeframes,
|
||||
active: true,
|
||||
createdBy: req.user?.username ?? null,
|
||||
};
|
||||
db.insert(subscriptionPlans).values(row).run();
|
||||
db.insert(subscriptionPlans).values(row as typeof subscriptionPlans.$inferInsert).run();
|
||||
return reply.code(201).send(row);
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ interface SubscriptionBody {
|
||||
* REQUIRED (the span priced against the plan). For a comp sub, both optional. */
|
||||
validFrom?: string | null;
|
||||
validTo?: string | null;
|
||||
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
|
||||
/** How many cars this subscription covers (a family pays once for N cars). Sale =
|
||||
* plan span price × quantity; maxConcurrent defaults to it. ≥ 1, default 1. */
|
||||
quantity?: number | null;
|
||||
/** Car-count binding: cars inside at once. Default = quantity; null = unbound. */
|
||||
maxConcurrent?: number | null;
|
||||
status?: "active" | "suspended" | "revoked";
|
||||
credentials?: Credential[];
|
||||
@@ -61,6 +64,7 @@ interface QuoteBody {
|
||||
planId?: string;
|
||||
validFrom?: string;
|
||||
validTo?: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
|
||||
@@ -112,6 +116,9 @@ export async function subscriptionRoutes(
|
||||
if (!plan) errs.push("no active plan found for the selected planId");
|
||||
}
|
||||
}
|
||||
if (b.quantity != null && (!Number.isInteger(b.quantity) || b.quantity < 1)) {
|
||||
errs.push("quantity must be a positive integer (cars covered)");
|
||||
}
|
||||
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||
errs.push("status must be active|suspended|revoked");
|
||||
}
|
||||
@@ -188,16 +195,23 @@ export async function subscriptionRoutes(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Resolve + price a priced sale: returns the plan version, the effective span, and
|
||||
* the server-computed quote. Returns null for a comp sub (no planId). Throws on a
|
||||
* planId that no longer resolves (validate() guards the happy path). */
|
||||
function priceSale(b: SubscriptionBody): { plan: SubscriptionPlan; validFrom: string; validTo: string; quote: SubscriptionQuote } | null {
|
||||
/** Resolve + price a priced sale: returns the plan version, the effective span, the
|
||||
* quantity (cars covered), and the server-computed quote with the amount already
|
||||
* MULTIPLIED by quantity (a family paying once for N cars). Returns null for a comp
|
||||
* sub (no planId). validate() guards the happy path. */
|
||||
function priceSale(
|
||||
b: SubscriptionBody,
|
||||
): { plan: SubscriptionPlan; validFrom: string; validTo: string; quantity: number; quote: SubscriptionQuote } | null {
|
||||
if (!b.planId?.trim() || !b.validTo?.trim()) return null;
|
||||
const validFrom = b.validFrom?.trim() || new Date().toISOString();
|
||||
const validTo = b.validTo.trim();
|
||||
const plan = resolvePlanVersion(db, b.planId.trim(), validFrom);
|
||||
if (!plan) return null;
|
||||
return { plan, validFrom, validTo, quote: priceSubscriptionSpan(plan, validFrom, validTo) };
|
||||
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
|
||||
const base = priceSubscriptionSpan(plan, validFrom, validTo);
|
||||
// Price ×N: the whole sale covers N cars on one subscription.
|
||||
const quote: SubscriptionQuote = { ...base, amountMinor: base.amountMinor * quantity };
|
||||
return { plan, validFrom, validTo, quantity, quote };
|
||||
}
|
||||
|
||||
// List all subscriptions (with their credentials + plates).
|
||||
@@ -264,7 +278,10 @@ export async function subscriptionRoutes(
|
||||
}
|
||||
const plan = resolvePlanVersion(db, b.planId.trim(), validFrom);
|
||||
if (!plan) return reply.code(404).send({ error: "no active plan for that planId" });
|
||||
return { ...priceSubscriptionSpan(plan, validFrom, validTo), plan };
|
||||
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
|
||||
const base = priceSubscriptionSpan(plan, validFrom, validTo);
|
||||
// Echo the ×quantity total so the form previews the family's combined price.
|
||||
return { ...base, amountMinor: base.amountMinor * quantity, quantity, plan };
|
||||
});
|
||||
|
||||
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
|
||||
@@ -286,7 +303,15 @@ export async function subscriptionRoutes(
|
||||
currency: priced ? priced.quote.currency : null,
|
||||
planId: priced ? priced.plan.planId : null,
|
||||
planVersionId: priced ? priced.plan.id : null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
||||
quantity: priced ? priced.quantity : (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
|
||||
// maxConcurrent defaults to the quantity (the family's N cars can all be inside),
|
||||
// unless the operator set it explicitly (null = unbound).
|
||||
maxConcurrent:
|
||||
b.maxConcurrent !== undefined
|
||||
? b.maxConcurrent
|
||||
: priced
|
||||
? priced.quantity
|
||||
: (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
|
||||
validFrom: priced ? priced.validFrom : (b.validFrom ?? null),
|
||||
validTo: priced ? priced.validTo : resolveValidTo(b, null),
|
||||
status: b.status ?? "active",
|
||||
@@ -350,10 +375,11 @@ export async function subscriptionRoutes(
|
||||
planId: plan.planId,
|
||||
planVersionId: plan.id,
|
||||
periods: quote.periods,
|
||||
...(priced.quantity > 1 ? { quantity: priced.quantity } : {}),
|
||||
},
|
||||
});
|
||||
app.log.info(
|
||||
`subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}) for ${id} by ${operator}` +
|
||||
`subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}×${priced.quantity}car) for ${id} by ${operator}` +
|
||||
(inShift ? "" : " [no open shift]"),
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { desc, eq, siteConfig, subscriptionPlans, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import {
|
||||
computeFee,
|
||||
outOfWindowGap,
|
||||
type PlanTimeframes,
|
||||
type SubscriptionPlan,
|
||||
type TariffStructure,
|
||||
} from "@parking/shared";
|
||||
|
||||
// Subscription TIME-WINDOW → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may be
|
||||
// parked (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:
|
||||
// - early ENTRY: arrival → window-open is owed (deferred; collected at exit).
|
||||
// - late EXIT: window-close → departure is owed (exit is GATED until paid).
|
||||
// Only plans WITH timeframes trigger any charge; a 24/7 plan never does. The gap math is
|
||||
// pure + tz-aware (outOfWindowGap in @parking/shared); pricing reuses computeFee (the same
|
||||
// engine transient stays use). See wiki/entities/subscription.md ("tariff bridge").
|
||||
|
||||
const DEFAULT_TZ = "Europe/Tirane";
|
||||
|
||||
/** A computed out-of-window charge: the gap, what it costs, and the tariff version used
|
||||
* (recorded so it reprices identically — like every transient payment). */
|
||||
export interface WindowCharge {
|
||||
readonly amountMinor: number;
|
||||
readonly gapStart: string;
|
||||
readonly gapEnd: string;
|
||||
readonly minutes: number;
|
||||
readonly currency: string;
|
||||
readonly tariffVersionId: string;
|
||||
}
|
||||
|
||||
/** The plan VERSION that priced a subscription's sale (by planVersionId), or null. The
|
||||
* timeframes are read from THIS version so a later plan edit can't retroactively change
|
||||
* an existing subscriber's window rules. */
|
||||
export function planVersionById(db: Db, planVersionId: string | null): SubscriptionPlan | null {
|
||||
if (!planVersionId) return null;
|
||||
const row = db.select().from(subscriptionPlans).where(eq(subscriptionPlans.id, planVersionId)).get();
|
||||
return row ? (row as unknown as SubscriptionPlan) : null;
|
||||
}
|
||||
|
||||
/** The site IANA timezone (falls back to the project default). */
|
||||
export function siteTz(db: Db): string {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||
}
|
||||
|
||||
/** The active site tariff version in force at `at` (latest effectiveFrom ≤ at), or null. */
|
||||
function tariffVersionAt(db: Db, at: string) {
|
||||
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||
if (!tariff) return null;
|
||||
const versions = db
|
||||
.select()
|
||||
.from(tariffVersions)
|
||||
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||
.all();
|
||||
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the out-of-window charge for a subscriber scan at `atISO`, or null when there
|
||||
* is nothing to charge (no plan timeframes, in-window, weekend all-day, within grace, or
|
||||
* no tariff configured). `edge` = "entry" (early) or "exit" (late). The gap is priced as
|
||||
* a fresh transient stay of that duration (computeFee over [gapStart, gapEnd]).
|
||||
*/
|
||||
export function windowCharge(
|
||||
db: Db,
|
||||
planVersionId: string | null,
|
||||
atISO: string,
|
||||
edge: "entry" | "exit",
|
||||
): WindowCharge | null {
|
||||
const plan = planVersionById(db, planVersionId);
|
||||
const timeframes = (plan?.timeframes ?? null) as PlanTimeframes | null;
|
||||
if (!timeframes) return null; // 24/7 plan (or comp sub) — never a time charge.
|
||||
|
||||
const tz = timeframes.tz || siteTz(db);
|
||||
const gap = outOfWindowGap(timeframes, tz, atISO, edge);
|
||||
if (!gap) return null; // in-window / all-day / within grace.
|
||||
|
||||
const tv = tariffVersionAt(db, gap.start);
|
||||
if (!tv) return null; // no tariff to price against — can't charge (don't trap).
|
||||
const structure = tv.structure as unknown as TariffStructure;
|
||||
const amountMinor = computeFee(gap.start, gap.end, structure);
|
||||
if (amountMinor <= 0) return null;
|
||||
|
||||
return {
|
||||
amountMinor,
|
||||
gapStart: gap.start,
|
||||
gapEnd: gap.end,
|
||||
minutes: gap.minutes,
|
||||
currency: tv.currency,
|
||||
tariffVersionId: tv.id,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user