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
+63 -3
View File
@@ -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 // 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 // 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 { export interface Occupancy {
/** Cars currently inside (open sessions). */ /** Cars currently inside (open sessions). */
readonly count: number; 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. */ /** Admin-set nominal capacity, or null = no limit. */
readonly capacity: number | null; readonly capacity: number | null;
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */ /** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
readonly free: number | null; 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; readonly full: boolean;
} }
@@ -37,13 +44,66 @@ export function siteCapacity(db: Db): number | null {
return row?.capacity ?? 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 { export function getOccupancy(db: Db): Occupancy {
const count = occupancyCount(db); const count = occupancyCount(db);
const capacity = siteCapacity(db); const capacity = siteCapacity(db);
const reserved = reservedSubscriberSpots(db);
return { return {
count, count,
reserved,
capacity, capacity,
free: capacity == null ? null : capacity - count, 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,
}; };
} }
+95 -2
View File
@@ -3,6 +3,7 @@ import { priceSession, type TariffStructure, type Tender } from "@parking/shared
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.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 // 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: // car (pay-on-foot — payment is decoupled from exit). Two steps:
@@ -200,6 +201,30 @@ export class PayStation {
tender: Tender, tender: Tender,
overrideMinor?: number, overrideMinor?: number,
): Promise<{ amountMinor: number; currency: string }> { ): 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 q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor; 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; paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt); const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
// Amount owed now (best-effort; null if no tariff resolves). Only meaningful while // Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session
// open AND transient — a subscription is prepaid, never quoted/charged. // 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 amountMinor: number | null = null;
let currency: string | null = null; let currency: string | null = null;
if (open && !isSubscription) { if (open && !isSubscription) {
@@ -285,6 +313,12 @@ export class PayStation {
} catch { } catch {
/* no active tariff — leave null; modal shows session without a price */ /* 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; const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
@@ -417,6 +451,65 @@ export class PayStation {
return out; 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), /** The subscriber's holder name for a subscription id (for a friendly UI label),
* or null. Best-effort: a deleted subscription just yields null. */ * or null. Best-effort: a deleted subscription just yields null. */
#holderOf(subscriptionId: string | null): string | null { #holderOf(subscriptionId: string | null): string | null {
+11
View File
@@ -29,6 +29,9 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
exitVoucherDefault?: boolean; exitVoucherDefault?: boolean;
/** Site default monthly subscription price in minor units (pre-fills the form). */ /** Site default monthly subscription price in minor units (pre-fills the form). */
subscriptionMonthlyPriceMinor?: number | null; 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 /** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
@@ -37,6 +40,7 @@ type SiteConfig = {
capacity: number | null; capacity: number | null;
exitVoucherDefault: boolean; exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null; subscriptionMonthlyPriceMinor: number | null;
reserveSubscriberSpots: boolean;
} & Record<TextField, string | null>; } & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
@@ -44,6 +48,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
capacity: row?.capacity ?? null, capacity: row?.capacity ?? null,
exitVoucherDefault: row?.exitVoucherDefault ?? false, exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null, subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
} as SiteConfig; } as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out; return out;
@@ -95,6 +100,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
patch.subscriptionMonthlyPriceMinor = p ?? null; 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) { for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]); if (f in body) patch[f] = normText(body[f]);
} }
+25 -2
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, eq, subscriptionPlans, type Db } from "@parking/db"; 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 { requirePermission } from "../auth.js";
import { siteTz } from "../subscription-window.js";
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS // Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
// from (so they never type a price). Mirrors the tariff composer: plans are // from (so they never type a price). Mirrors the tariff composer: plans are
@@ -22,6 +23,20 @@ interface PlanBody {
currency?: string; currency?: string;
/** When this version takes effect (ISO-8601). Defaults to now. */ /** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string; 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. */ /** 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))) { if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) {
errs.push("effectiveFrom must be a valid ISO-8601 timestamp"); errs.push("effectiveFrom must be a valid ISO-8601 timestamp");
} }
const tfErr = validTimeframes(b.timeframes);
if (tfErr) errs.push(tfErr);
return errs; 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", 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 = { const row = {
id: randomUUID(), id: randomUUID(),
planId, planId,
@@ -93,10 +115,11 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
pricePerPeriodMinor: b.pricePerPeriodMinor!, pricePerPeriodMinor: b.pricePerPeriodMinor!,
currency: b.currency!.trim(), currency: b.currency!.trim(),
effectiveFrom, effectiveFrom,
timeframes,
active: true, active: true,
createdBy: req.user?.username ?? null, 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); return reply.code(201).send(row);
}); });
+35 -9
View File
@@ -45,7 +45,10 @@ interface SubscriptionBody {
* REQUIRED (the span priced against the plan). For a comp sub, both optional. */ * REQUIRED (the span priced against the plan). For a comp sub, both optional. */
validFrom?: string | null; validFrom?: string | null;
validTo?: 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; maxConcurrent?: number | null;
status?: "active" | "suspended" | "revoked"; status?: "active" | "suspended" | "revoked";
credentials?: Credential[]; credentials?: Credential[];
@@ -61,6 +64,7 @@ interface QuoteBody {
planId?: string; planId?: string;
validFrom?: string; validFrom?: string;
validTo?: string; validTo?: string;
quantity?: number;
} }
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader /** 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 (!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)) { if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked"); errs.push("status must be active|suspended|revoked");
} }
@@ -188,16 +195,23 @@ export async function subscriptionRoutes(
return fallback; return fallback;
} }
/** Resolve + price a priced sale: returns the plan version, the effective span, and /** Resolve + price a priced sale: returns the plan version, the effective span, the
* the server-computed quote. Returns null for a comp sub (no planId). Throws on a * quantity (cars covered), and the server-computed quote with the amount already
* planId that no longer resolves (validate() guards the happy path). */ * MULTIPLIED by quantity (a family paying once for N cars). Returns null for a comp
function priceSale(b: SubscriptionBody): { plan: SubscriptionPlan; validFrom: string; validTo: string; quote: SubscriptionQuote } | null { * 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; if (!b.planId?.trim() || !b.validTo?.trim()) return null;
const validFrom = b.validFrom?.trim() || new Date().toISOString(); const validFrom = b.validFrom?.trim() || new Date().toISOString();
const validTo = b.validTo.trim(); const validTo = b.validTo.trim();
const plan = resolvePlanVersion(db, b.planId.trim(), validFrom); const plan = resolvePlanVersion(db, b.planId.trim(), validFrom);
if (!plan) return null; 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). // List all subscriptions (with their credentials + plates).
@@ -264,7 +278,10 @@ export async function subscriptionRoutes(
} }
const plan = resolvePlanVersion(db, b.planId.trim(), validFrom); const plan = resolvePlanVersion(db, b.planId.trim(), validFrom);
if (!plan) return reply.code(404).send({ error: "no active plan for that planId" }); 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) => { 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, currency: priced ? priced.quote.currency : null,
planId: priced ? priced.plan.planId : null, planId: priced ? priced.plan.planId : null,
planVersionId: priced ? priced.plan.id : 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), validFrom: priced ? priced.validFrom : (b.validFrom ?? null),
validTo: priced ? priced.validTo : resolveValidTo(b, null), validTo: priced ? priced.validTo : resolveValidTo(b, null),
status: b.status ?? "active", status: b.status ?? "active",
@@ -350,10 +375,11 @@ export async function subscriptionRoutes(
planId: plan.planId, planId: plan.planId,
planVersionId: plan.id, planVersionId: plan.id,
periods: quote.periods, periods: quote.periods,
...(priced.quantity > 1 ? { quantity: priced.quantity } : {}),
}, },
}); });
app.log.info( 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]"), (inShift ? "" : " [no open shift]"),
); );
} catch (err) { } catch (err) {
+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 { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js"; import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js"; import { snapshotAsync } from "./snapshot.js";
import { windowCharge } from "./subscription-window.js";
import type { VisionClient } from "./vision-client.js"; import type { VisionClient } from "./vision-client.js";
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without // 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 }; return { accepted: false, direction: "exit", reason };
} }
const occurrenceId = oldest.identity; 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({ await this.#log.append({
type: "vehicle_exit", type: "vehicle_exit",
direction: "exit", direction: "exit",
@@ -170,6 +188,14 @@ export class SubscriptionFlow {
return { accepted: false, direction: "entry", reason }; 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 // 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. // the payload's `permitId` (which every fold matches on), so the key stays compact.
const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`; const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
@@ -179,10 +205,30 @@ export class SubscriptionFlow {
source, source,
identity: occurrenceId, identity: occurrenceId,
// No ticket, no fee — the subscription IS the authorization. Recorded for audit. // No ticket, no fee — the subscription IS the authorization. Recorded for audit.
// `permitId`/`permit` are the on-chain field names (immutable). // `permitId`/`permit` are the on-chain field names (immutable). A deferred early-
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via }, // 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, 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"); await this.#open(resolved, "entry", occurrenceId, "subscription entry");
try { try {
this.#db this.#db
@@ -202,6 +248,49 @@ export class SubscriptionFlow {
return { accepted: true, direction: "entry" }; 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 * The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose * fold over the signed ledger. An occurrence is a `vehicle_entry` (whose
+94
View File
@@ -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,
};
}
+32 -16
View File
@@ -58,10 +58,18 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
// exit. A normal within-grace paid session is NOT payable (it's settled). See // exit. A normal within-grace paid session is NOT payable (it's settled). See
// booth-exit-flow.md / reopenBarrier server guard. // booth-exit-flow.md / reopenBarrier server guard.
const isOverstay = s?.overstay === true; const isOverstay = s?.overstay === true;
// A subscription is prepaid: never charged. The only booth action is an audited // A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off. // owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
// Allow pay for an unpaid session OR an overstay (new-period top-up) one. // it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
const canPay = !!(shiftReady && s?.found && s.open && (!alreadyPaid || isOverstay) && !isSubscription); // when it has an amount due. Otherwise the only action is an audited assist-open.
const subWindowDue = !!(isSubscription && (s?.amountMinor ?? 0) > 0);
// Allow pay for an unpaid transient, an overstay top-up, or a subscriber window charge.
const canPay = !!(
shiftReady &&
s?.found &&
s.open &&
((!alreadyPaid && !isSubscription) || isOverstay || subWindowDue)
);
async function handleOpenBarrier() { async function handleOpenBarrier() {
if (!s) return; if (!s) return;
@@ -252,25 +260,33 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
/> />
</div> </div>
{/* Total — a subscription is prepaid (no amount); show a badge. For an {/* Total — a subscription is prepaid (no amount) UNLESS it owes an
overstay the amount is the TOP-UP delta, not the whole stay. */} out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2"> <div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted"> <span className="text-[11px] uppercase tracking-wider text-term-muted">
{isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")} {subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
</span> </span>
<span className="text-3xl font-bold text-term-cyan"> <span className="text-3xl font-bold text-term-cyan">
{isSubscription {subWindowDue && s.amountMinor != null && s.currency
? t("pay.prepaid") ? formatMoney(s.amountMinor, s.currency)
: s.amountMinor != null && s.currency : isSubscription
? formatMoney(s.amountMinor, s.currency) ? t("pay.prepaid")
: alreadyPaid : s.amountMinor != null && s.currency
? t("booth.badgePaid") ? formatMoney(s.amountMinor, s.currency)
: t("pay.noTariff")} : alreadyPaid
? t("booth.badgePaid")
: t("pay.noTariff")}
</span> </span>
</div> </div>
{/* For a subscription, explain the only available action. */} {/* For a subscription with a window charge, explain why it's payable. For a
{isSubscription && ( plain prepaid subscription, explain the assist-open is the only action. */}
{subWindowDue ? (
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.windowChargeHint")}
</div>
) : isSubscription && (
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text"> <div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.subAssistHint")} {t("pay.subAssistHint")}
</div> </div>
+15
View File
@@ -25,6 +25,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [capInput, setCapInput] = useState(""); const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({}); const [meta, setMeta] = useState<Record<string, string>>({});
const [exitVoucherDefault, setExitVoucherDefault] = useState(false); const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
const [reserveSubs, setReserveSubs] = useState(false);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
function reload() { function reload() {
@@ -36,6 +37,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
.then((c) => { .then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity)); setCapInput(c.capacity == null ? "" : String(c.capacity));
setExitVoucherDefault(c.exitVoucherDefault); setExitVoucherDefault(c.exitVoucherDefault);
setReserveSubs(c.reserveSubscriberSpots);
const m: Record<string, string> = {}; const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]); for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m); setMeta(m);
@@ -49,6 +51,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const patch: Partial<SiteConfig> = { const patch: Partial<SiteConfig> = {
capacity: raw === "" ? null : Math.round(Number(raw)), capacity: raw === "" ? null : Math.round(Number(raw)),
exitVoucherDefault, exitVoucherDefault,
reserveSubscriberSpots: reserveSubs,
}; };
// Send each metadata field; "" → null is applied server-side. // Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? ""; for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
@@ -97,6 +100,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
{t("site.printExitDefault")} {t("site.printExitDefault")}
<span className="hint">{t("site.printExitHint")}</span> <span className="hint">{t("site.printExitHint")}</span>
</label> </label>
<label className="flex items-start gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="mt-0.5 accent-term-amber"
checked={reserveSubs}
onChange={(e) => setReserveSubs(e.target.checked)}
/>
<span>
{t("site.reserveSubs")}
<span className="hint block">{t("site.reserveSubsHint")}</span>
</span>
</label>
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted"> <div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")} {t("site.parkDetails")}
</div> </div>
+24 -3
View File
@@ -33,6 +33,7 @@ interface FormState {
holderName: string; holderName: string;
contact: string; contact: string;
planId: string; // selected plan (sells/prices it); "" = comp (no charge) planId: string; // selected plan (sells/prices it); "" = comp (no charge)
quantity: string; // cars covered by this one subscription (price ×N)
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank) tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
carBound: boolean; // false = unbound (maxConcurrent null) carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string; maxConcurrent: string;
@@ -52,6 +53,7 @@ function emptyForm(): FormState {
holderName: "", holderName: "",
contact: "", contact: "",
planId: "", planId: "",
quantity: "1",
tender: "cash", tender: "cash",
carBound: true, carBound: true,
maxConcurrent: "1", maxConcurrent: "1",
@@ -66,6 +68,7 @@ function formFrom(s: Subscription): FormState {
holderName: s.holderName ?? "", holderName: s.holderName ?? "",
contact: s.contact ?? "", contact: s.contact ?? "",
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only
quantity: String(s.quantity ?? 1),
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
carBound: s.maxConcurrent != null, carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1", maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
@@ -97,6 +100,7 @@ function toInput(f: FormState, isNew: boolean): SubscriptionInput {
// A SALE: send the chosen plan; price is looked up server-side. On edit we never // A SALE: send the chosen plan; price is looked up server-side. On edit we never
// re-sell, so no planId is sent (price/plan stay frozen). // re-sell, so no planId is sent (price/plan stay frozen).
planId: planSelected ? f.planId.trim() : null, planId: planSelected ? f.planId.trim() : null,
quantity: Math.max(1, Math.round(Number(f.quantity) || 1)),
tender: f.tender, tender: f.tender,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null, maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: dateToISO(f.validFrom), validFrom: dateToISO(f.validFrom),
@@ -165,10 +169,11 @@ export function SubscriptionManager() {
setQuote(null); setQuote(null);
return; return;
} }
const quantity = Math.max(1, Math.round(Number(form.quantity) || 1));
let cancelled = false; let cancelled = false;
setQuoting(true); setQuoting(true);
const h = setTimeout(() => { const h = setTimeout(() => {
quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to }) quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to, quantity })
.then((q) => !cancelled && setQuote(q)) .then((q) => !cancelled && setQuote(q))
.catch(() => !cancelled && setQuote(null)) .catch(() => !cancelled && setQuote(null))
.finally(() => !cancelled && setQuoting(false)); .finally(() => !cancelled && setQuoting(false));
@@ -177,7 +182,7 @@ export function SubscriptionManager() {
cancelled = true; cancelled = true;
clearTimeout(h); clearTimeout(h);
}; };
}, [editing, form.planId, form.validFrom, form.validTo]); }, [editing, form.planId, form.validFrom, form.validTo, form.quantity]);
function startNew() { function startNew() {
setForm(emptyForm()); setForm(emptyForm());
@@ -378,6 +383,22 @@ export function SubscriptionManager() {
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span> <span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
</> </>
)} )}
{/* Quantity — cars covered by this ONE subscription (a family pays once for
N cars). Price ×N; maxConcurrent below pre-fills to it. */}
{form.planId.trim() !== "" && editing === "new" && (
<>
<label className="label">{t("subs.quantity")}</label>
<span className="flex flex-wrap items-center gap-2">
<input
className="input w-16"
value={form.quantity}
inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
/>
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
</span>
</>
)}
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a {/* Tender — only relevant when selling a plan (a SALE). The sale appends a
signed payment so the money shows in the feed/drawer/Z-report. */} signed payment so the money shows in the feed/drawer/Z-report. */}
{form.planId.trim() !== "" && editing === "new" && ( {form.planId.trim() !== "" && editing === "new" && (
@@ -434,7 +455,7 @@ export function SubscriptionManager() {
unit: t(PERIOD_KEY[quote.period]), unit: t(PERIOD_KEY[quote.period]),
amount: (quote.amountMinor / 100).toLocaleString(), amount: (quote.amountMinor / 100).toLocaleString(),
currency: quote.currency, currency: quote.currency,
}) }) + (quote.quantity && quote.quantity > 1 ? ` (×${quote.quantity})` : "")
: t("subs.quotePrompt")} : t("subs.quotePrompt")}
</span> </span>
)} )}
+95 -1
View File
@@ -29,10 +29,40 @@ interface PlanForm {
period: SubscriptionPeriod; period: SubscriptionPeriod;
priceMajor: string; priceMajor: string;
currency: string; currency: string;
// Timeframes (tariff bridge). Off → 24/7. On → weekday window (enter-after / exit-before
// as HH:MM) + weekend all-day toggle + grace minutes.
restrictTimes: boolean;
wdFrom: string; // weekday window opens (HH:MM) — when the subscriber may enter
wdTo: string; // weekday window closes (HH:MM) — by when they should exit
weekendAllDay: boolean;
graceMin: string;
} }
function emptyForm(): PlanForm { function emptyForm(): PlanForm {
return { planId: "", name: "", period: "month", priceMajor: "", currency: DEFAULT_CURRENCY }; return {
planId: "",
name: "",
period: "month",
priceMajor: "",
currency: DEFAULT_CURRENCY,
restrictTimes: false,
wdFrom: "20:00",
wdTo: "08:00",
weekendAllDay: true,
graceMin: "0",
};
}
/** "HH:MM" → minutes-of-day, or null if blank/invalid. */
function hhmmToMin(s: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(s.trim());
if (!m) return null;
const min = Number(m[1]) * 60 + Number(m[2]);
return min >= 0 && min <= 1439 ? min : null;
}
/** minutes-of-day → "HH:MM". */
function minToHHMM(min: number): string {
return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
} }
export function SubscriptionPlansManager() { export function SubscriptionPlansManager() {
@@ -55,6 +85,20 @@ export function SubscriptionPlansManager() {
const major = Number(form.priceMajor); const major = Number(form.priceMajor);
if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") }); if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") });
if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") }); if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") });
// Build the timeframes blob from the form (null = 24/7). The weekday window is the
// allowed interval [wdFrom, wdTo) (wraps midnight for a night plan); weekend is all-day
// or inherits the weekday window. The server stamps the site tz.
let timeframes = null as Parameters<typeof createSubscriptionPlan>[0]["timeframes"];
if (form.restrictTimes) {
const from = hhmmToMin(form.wdFrom);
const to = hhmmToMin(form.wdTo);
if (from == null || to == null) return setMsg({ kind: "err", text: t("plans.needWindow") });
timeframes = {
weekday: { fromMin: from, toMin: to },
weekend: form.weekendAllDay ? { allDay: true } : { fromMin: from, toMin: to },
graceMin: Math.max(0, Math.round(Number(form.graceMin) || 0)),
};
}
try { try {
await createSubscriptionPlan({ await createSubscriptionPlan({
planId: form.planId.trim() || undefined, planId: form.planId.trim() || undefined,
@@ -62,6 +106,7 @@ export function SubscriptionPlansManager() {
period: form.period, period: form.period,
pricePerPeriodMinor: Math.round(major * 100), pricePerPeriodMinor: Math.round(major * 100),
currency: form.currency.trim() || DEFAULT_CURRENCY, currency: form.currency.trim() || DEFAULT_CURRENCY,
timeframes,
}); });
setForm(null); setForm(null);
reload(); reload();
@@ -80,12 +125,19 @@ export function SubscriptionPlansManager() {
/** Publish a new version of an existing plan (pre-fills its identity + last values). */ /** Publish a new version of an existing plan (pre-fills its identity + last values). */
function newVersionOf(p: SubscriptionPlan) { function newVersionOf(p: SubscriptionPlan) {
const tf = p.timeframes ?? null;
const wd = tf?.weekday;
setForm({ setForm({
planId: p.planId, planId: p.planId,
name: p.name, name: p.name,
period: p.period, period: p.period,
priceMajor: String(p.pricePerPeriodMinor / 100), priceMajor: String(p.pricePerPeriodMinor / 100),
currency: p.currency, currency: p.currency,
restrictTimes: tf != null,
wdFrom: wd?.fromMin != null ? minToHHMM(wd.fromMin) : "20:00",
wdTo: wd?.toMin != null ? minToHHMM(wd.toMin) : "08:00",
weekendAllDay: tf?.weekend?.allDay ?? true,
graceMin: String(tf?.graceMin ?? 0),
}); });
setMsg(null); setMsg(null);
} }
@@ -179,6 +231,48 @@ export function SubscriptionPlansManager() {
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span> <span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
</span> </span>
</div> </div>
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
window they're charged the transient tariff for the gap. Off = 24/7. */}
<div className="mt-3 border-t border-term-border pt-3">
<label className="flex items-center gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={form.restrictTimes}
onChange={(e) => setForm((f) => f && { ...f, restrictTimes: e.target.checked })}
/>
{t("plans.restrictTimes")}
</label>
{form.restrictTimes && (
<div className="mt-2 grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("plans.weekdayWindow")}</label>
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
{t("plans.enterAfter")}
<input type="time" className="input w-28" value={form.wdFrom} onChange={(e) => setForm((f) => f && { ...f, wdFrom: e.target.value })} />
{t("plans.exitBefore")}
<input type="time" className="input w-28" value={form.wdTo} onChange={(e) => setForm((f) => f && { ...f, wdTo: e.target.value })} />
</span>
<label className="label">{t("plans.weekend")}</label>
<label className="flex items-center gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={form.weekendAllDay}
onChange={(e) => setForm((f) => f && { ...f, weekendAllDay: e.target.checked })}
/>
{t("plans.weekendAllDay")}
</label>
<label className="label">{t("plans.grace")}</label>
<span className="flex items-center gap-2">
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
</span>
</div>
)}
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
</div>
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>} {form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button> <button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
+26 -1
View File
@@ -494,6 +494,20 @@ export interface SubscriptionCredential {
} }
export type SubscriptionPeriod = "day" | "week" | "month"; export type SubscriptionPeriod = "day" | "week" | "month";
/** A subscriber's allowed parking window for a day-type (minutes-from-local-midnight).
* A scan outside the window is charged the transient tariff for the gap. */
export interface DayWindow {
allDay?: boolean;
fromMin?: number;
toMin?: number;
}
export interface PlanTimeframes {
weekday?: DayWindow;
weekend?: DayWindow;
graceMin?: number;
tz?: string;
}
/** A subscription PLAN version — admin-composed, versioned config the operator sells /** A subscription PLAN version — admin-composed, versioned config the operator sells
* from (so they never type a price). */ * from (so they never type a price). */
export interface SubscriptionPlan { export interface SubscriptionPlan {
@@ -505,6 +519,8 @@ export interface SubscriptionPlan {
currency: string; currency: string;
effectiveFrom: string; effectiveFrom: string;
active: boolean; active: boolean;
/** Allowed-time windows (tariff bridge); null/absent = 24/7, no time charge. */
timeframes?: PlanTimeframes | null;
} }
export interface Subscription { export interface Subscription {
@@ -518,6 +534,8 @@ export interface Subscription {
/** Which plan + immutable version priced this sale (null for legacy/comp). */ /** Which plan + immutable version priced this sale (null for legacy/comp). */
planId: string | null; planId: string | null;
planVersionId: string | null; planVersionId: string | null;
/** Cars covered by this one subscription (price was ×N). Default 1. */
quantity: number;
maxConcurrent: number | null; maxConcurrent: number | null;
validFrom: string | null; validFrom: string | null;
validTo: string | null; validTo: string | null;
@@ -540,6 +558,8 @@ export type SubscriptionInput = {
/** Coverage window. Priced sale: validFrom defaults to now, validTo required. */ /** Coverage window. Priced sale: validFrom defaults to now, validTo required. */
validFrom: string | null; validFrom: string | null;
validTo: string | null; validTo: string | null;
/** Cars covered (price ×N). Default 1. */
quantity?: number;
maxConcurrent: number | null; maxConcurrent: number | null;
status?: Subscription["status"]; status?: Subscription["status"];
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a /** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
@@ -549,12 +569,13 @@ export type SubscriptionInput = {
plates: string[]; plates: string[];
}; };
/** A server-computed quote: periods (ceil) × per-period price for a span. */ /** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */
export interface SubscriptionQuote { export interface SubscriptionQuote {
periods: number; periods: number;
amountMinor: number; amountMinor: number;
currency: string; currency: string;
period: SubscriptionPeriod; period: SubscriptionPeriod;
quantity?: number;
plan: SubscriptionPlan; plan: SubscriptionPlan;
} }
@@ -589,6 +610,7 @@ export function createSubscriptionPlan(body: {
pricePerPeriodMinor: number; pricePerPeriodMinor: number;
currency: string; currency: string;
effectiveFrom?: string; effectiveFrom?: string;
timeframes?: PlanTimeframes | null;
}): Promise<SubscriptionPlan> { }): Promise<SubscriptionPlan> {
return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) });
} }
@@ -600,6 +622,7 @@ export function quoteSubscription(body: {
planId: string; planId: string;
validFrom: string | null; validFrom: string | null;
validTo: string; validTo: string;
quantity?: number;
}): Promise<SubscriptionQuote> { }): Promise<SubscriptionQuote> {
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
} }
@@ -779,6 +802,8 @@ export interface SiteConfig {
exitVoucherDefault: boolean; exitVoucherDefault: boolean;
/** Site default monthly subscription price (minor units); pre-fills the form. */ /** Site default monthly subscription price (minor units); pre-fills the form. */
subscriptionMonthlyPriceMinor: number | null; subscriptionMonthlyPriceMinor: number | null;
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
reserveSubscriberSpots: boolean;
parkName: string | null; parkName: string | null;
operatorName: string | null; operatorName: string | null;
/** NIUS — Albanian tax/identification number. */ /** NIUS — Albanian tax/identification number. */
+16
View File
@@ -387,6 +387,8 @@ export const en: Catalog = {
perWeek: "week", perWeek: "week",
perMonth: "month", perMonth: "month",
plan: "Plan", plan: "Plan",
quantity: "Cars",
quantityHint: "cars covered by this subscription (price ×N)",
planNone: "— comp / no charge —", planNone: "— comp / no charge —",
planNoneAvail: "No plans defined — an admin must create one first.", planNoneAvail: "No plans defined — an admin must create one first.",
quoting: "pricing…", quoting: "pricing…",
@@ -466,6 +468,16 @@ export const en: Catalog = {
needPrice: "Enter a price greater than zero.", needPrice: "Enter a price greater than zero.",
saved: "Plan saved.", saved: "Plan saved.",
confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).", confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).",
needWindow: "Enter valid window times (HH:MM).",
restrictTimes: "Restrict parking times (charge transient tariff outside the window)",
weekdayWindow: "Weekday",
enterAfter: "enter after",
exitBefore: "· exit before",
weekend: "Weekend",
weekendAllDay: "all day (no restriction)",
grace: "Grace",
graceHint: "minutes tolerance around the window edges",
timeframesHint: "A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).",
}, },
site: { site: {
occupancy: "Occupancy:", occupancy: "Occupancy:",
@@ -476,6 +488,8 @@ export const en: Catalog = {
capacityPlaceholder: "e.g. 120", capacityPlaceholder: "e.g. 120",
printExitDefault: "Print exit ticket by default", printExitDefault: "Print exit ticket by default",
printExitHint: "(booth far from exit → customer self-exits with a voucher)", printExitHint: "(booth far from exit → customer self-exits with a voucher)",
reserveSubs: "Reserve subscriber spots",
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
parkDetails: "Park details (optional — shown on tickets/receipts)", parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save", save: "Save",
saved: "Saved.", saved: "Saved.",
@@ -659,6 +673,8 @@ export const en: Catalog = {
plan: "Plan", plan: "Plan",
prepaid: "PREPAID", prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.", subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
windowCharge: "OUT-OF-WINDOW",
windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment to allow the exit.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).", subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.", voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip) // payment receipt (transparency slip)
+16
View File
@@ -398,6 +398,8 @@ export const sq = {
perWeek: "javë", perWeek: "javë",
perMonth: "muaj", perMonth: "muaj",
plan: "Plani", plan: "Plani",
quantity: "Makina",
quantityHint: "makina të mbuluara nga ky abonim (çmimi ×N)",
planNone: "— pa pagesë / falas —", planNone: "— pa pagesë / falas —",
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.", planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
quoting: "duke llogaritur…", quoting: "duke llogaritur…",
@@ -477,6 +479,16 @@ export const sq = {
needPrice: "Shkruaj një çmim më të madh se zero.", needPrice: "Shkruaj një çmim më të madh se zero.",
saved: "Plani u ruajt.", saved: "Plani u ruajt.",
confirmRetire: "Të tërhiqet plani “{{name}}”? Nuk do të jetë më i shitshëm (historiku ruhet).", confirmRetire: "Të tërhiqet plani “{{name}}”? Nuk do të jetë më i shitshëm (historiku ruhet).",
needWindow: "Shkruaj orare të vlefshme (HH:MM).",
restrictTimes: "Kufizo oraret e parkimit (tarifë kalimtare jashtë intervalit)",
weekdayWindow: "Ditë pune",
enterAfter: "hyrje pas",
exitBefore: "· dalje para",
weekend: "Fundjavë",
weekendAllDay: "gjithë ditën (pa kufizim)",
grace: "Tolerancë",
graceHint: "minuta tolerancë rreth kufijve të intervalit",
timeframesHint: "Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).",
}, },
site: { site: {
occupancy: "Prania:", occupancy: "Prania:",
@@ -487,6 +499,8 @@ export const sq = {
capacityPlaceholder: "p.sh. 120", capacityPlaceholder: "p.sh. 120",
printExitDefault: "Printo biletën e daljes si parazgjedhje", printExitDefault: "Printo biletën e daljes si parazgjedhje",
printExitHint: "(klienti skanon biletën në dalje)", printExitHint: "(klienti skanon biletën në dalje)",
reserveSubs: "Rezervo vendet e abonentëve",
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)", parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj", save: "Ruaj",
saved: "U ruajt.", saved: "U ruajt.",
@@ -673,6 +687,8 @@ export const sq = {
plan: "Plani", plan: "Plani",
prepaid: "I PARAPAGUAR", prepaid: "I PARAPAGUAR",
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.", subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
windowCharge: "JASHTË ORARIT",
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën për të lejuar daljen.",
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).", subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.", voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
// payment receipt (transparency slip) // payment receipt (transparency slip)
@@ -0,0 +1,7 @@
-- Subscription plans v2: per-plan allowed-time windows (tariff bridge), per-subscription
-- car quantity, and a site toggle to reserve subscriber spots in the occupancy count.
-- All additive ALTER ADD COLUMN — backward-compatible (existing rows take the defaults:
-- timeframes null = 24/7, quantity 1, reserve off). SQLite ADD COLUMN is in-place.
ALTER TABLE `subscription_plans` ADD `timeframes` text;--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `quantity` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE `site_config` ADD `reserve_subscriber_spots` integer DEFAULT 0 NOT NULL;
+7
View File
@@ -78,6 +78,13 @@
"when": 1781885300000, "when": 1781885300000,
"tag": "0010_subscription_plans", "tag": "0010_subscription_plans",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1781885400000,
"tag": "0011_subscription_plan_v2",
"breakpoints": true
} }
] ]
} }
+17 -1
View File
@@ -218,6 +218,14 @@ export const siteConfig = sqliteTable("site_config", {
* own price and may differ. null = no site default set. See * own price and may differ. null = no site default set. See
* wiki/entities/subscription.md. */ * wiki/entities/subscription.md. */
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"), subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
/** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car
* (by quantity) even when they're not parked — so transients see "full" sooner and
* the subscriber's spot is held. When OFF (default), only cars physically inside
* count (the operator handles overflow by valet/key-juggling). Stored 0/1.
* See wiki/concepts/capacity-occupancy.md. */
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
.notNull()
.default(false),
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a /** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into * tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
* each published tariff version's structure.tz so the windows are frozen/immutable * each published tariff version's structure.tz so the windows are frozen/immutable
@@ -295,6 +303,11 @@ export const subscriptionPlans = sqliteTable("subscription_plans", {
currency: text("currency").notNull(), currency: text("currency").notNull(),
// Latest version with effectiveFrom ≤ the sale instant prices the sale. // Latest version with effectiveFrom ≤ the sale instant prices the sale.
effectiveFrom: text("effective_from").notNull(), effectiveFrom: text("effective_from").notNull(),
// Composed allowed-time windows (PlanTimeframes in @parking/shared); null = 24/7, no
// restriction. When set, a scan OUTSIDE the window is charged the transient tariff for
// the out-of-window minutes (a "night plan" subscriber arriving early owes that gap).
// Evaluated in the site timezone. See wiki/entities/subscription.md (tariff bridge).
timeframes: text("timeframes", { mode: "json" }).$type<Record<string, unknown>>(),
// Soft-retire (0) without deleting history; active=1 plans are sellable. // Soft-retire (0) without deleting history; active=1 plans are sellable.
active: integer("active", { mode: "boolean" }).notNull().default(true), active: integer("active", { mode: "boolean" }).notNull().default(true),
createdBy: text("created_by"), createdBy: text("created_by"),
@@ -321,8 +334,11 @@ export const subscriptions = sqliteTable("subscriptions", {
// tariffVersionId. // tariffVersionId.
planId: text("plan_id"), planId: text("plan_id"),
planVersionId: text("plan_version_id"), planVersionId: text("plan_version_id"),
// How many cars this ONE subscription covers (e.g. a family pays once for 2 cars).
// Sale amount = plan span price × quantity; maxConcurrent defaults to it. Default 1.
quantity: integer("quantity").notNull().default(1),
// Car-count binding: how many of the subscription's cars may be inside at once. // Car-count binding: how many of the subscription's cars may be inside at once.
// null = unbound. Default 1. // null = unbound. Defaults to `quantity` at sale.
maxConcurrent: integer("max_concurrent").default(1), maxConcurrent: integer("max_concurrent").default(1),
validFrom: text("valid_from"), validFrom: text("valid_from"),
validTo: text("valid_to"), validTo: text("valid_to"),
+95
View File
@@ -68,6 +68,29 @@ export const ADMIN_ROLE_ID = "admin";
export type SubscriptionPeriod = "day" | "week" | "month"; export type SubscriptionPeriod = "day" | "week" | "month";
export const SUBSCRIPTION_PERIODS: readonly SubscriptionPeriod[] = ["day", "week", "month"]; export const SUBSCRIPTION_PERIODS: readonly SubscriptionPeriod[] = ["day", "week", "month"];
/** A subscriber's allowed parking window for a day-type, as minutes-from-local-midnight
* (0–1439). The window is the interval [fromMin, toMin); `toMin <= fromMin` means it
* WRAPS past midnight (e.g. 20:00→08:00 = a night window: 1200..480). `allDay` = the
* whole day is allowed (no charge). An ABSENT day-window = no restriction (24/7) for
* that day-type. */
export interface DayWindow {
readonly allDay?: boolean;
readonly fromMin?: number; // window opens (minutes-of-day, local)
readonly toMin?: number; // window closes (minutes-of-day, local)
}
/** Composed allowed-time windows on a [[subscription]] plan. A scan OUTSIDE the window
* is charged the transient tariff for the out-of-window minutes (the "tariff bridge").
* null/absent timeframes on a plan = 24/7, no charge ever. Evaluated in the site tz. */
export interface PlanTimeframes {
readonly weekday?: DayWindow; // Mon–Fri
readonly weekend?: DayWindow; // Sat–Sun
/** Tolerance (minutes) around the window edges before a charge applies. */
readonly graceMin?: number;
/** IANA tz the windows are wall-clock evaluated in (the site tz, captured at sale). */
readonly tz?: string;
}
/** One immutable VERSION of a subscription plan (admin-composed catalog; latest with /** One immutable VERSION of a subscription plan (admin-composed catalog; latest with
* effectiveFrom ≤ sale instant prices a sale — the tariff-version pattern). The * effectiveFrom ≤ sale instant prices a sale — the tariff-version pattern). The
* operator SELLS from this catalog; they never type a price. */ * operator SELLS from this catalog; they never type a price. */
@@ -80,6 +103,8 @@ export interface SubscriptionPlan {
readonly currency: string; readonly currency: string;
readonly effectiveFrom: string; readonly effectiveFrom: string;
readonly active: boolean; readonly active: boolean;
/** Allowed-time windows (tariff bridge). null/absent = 24/7, no time charge. */
readonly timeframes?: PlanTimeframes | null;
readonly createdBy?: string | null; readonly createdBy?: string | null;
readonly createdAt?: string; readonly createdAt?: string;
} }
@@ -302,6 +327,9 @@ export const REASON_CODES = [
"sub.refused.outOfWindow", "sub.refused.outOfWindow",
"sub.refused.noSession", "sub.refused.noSession",
"sub.refused.atCapacity", "sub.refused.atCapacity",
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
"sub.refused.unpaidWindow",
] as const; ] as const;
export type ReasonCode = (typeof REASON_CODES)[number]; export type ReasonCode = (typeof REASON_CODES)[number];
@@ -329,6 +357,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window", "sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)", "sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)", "sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
}; };
/** /**
@@ -1042,6 +1071,72 @@ export function localBreakdown(instantMs: number, tz: string): WallClock {
}; };
} }
// --- Subscription plan timeframes — the "tariff bridge" gap (pure, tz-aware) -------
/** Is a day-of-week a weekend (Sat/Sun)? */
function isWeekend(dow: number): boolean {
return dow === 0 || dow === 6;
}
/** Minute-of-day is inside the window [fromMin, toMin)? A window with toMin ≤ fromMin
* WRAPS past midnight (night window 20:00→08:00 ⇒ in = m ≥ 1200 OR m < 480). */
function inWindow(m: number, fromMin: number, toMin: number): boolean {
return toMin <= fromMin ? m >= fromMin || m < toMin : m >= fromMin && m < toMin;
}
/**
* The out-of-window GAP for a subscriber scan, or null when the scan is in-window (or
* the plan/day is unrestricted/all-day). This is the portion charged at the transient
* tariff (the "tariff bridge"):
* - edge "entry" (early arrival): gap = [scan, next window-OPEN] — they pay transient
* from arrival until their window starts (a 09:00 arrival to a 20:00 night window
* owes 09:00→20:00, capped by the tariff's daily cap).
* - edge "exit" (late departure): gap = [last window-CLOSE, scan] — they pay transient
* from when their window ended until they actually leave (08:00→08:45).
* Grace widens the allowed window by `graceMin` on the relevant edge. Pure + tz-aware
* (wall-clock in `timeframes.tz` or the passed `tz`). Minutes-of-day arithmetic anchored
* on the scan's own local day keeps it DST-robust for the short gaps involved.
*/
export function outOfWindowGap(
timeframes: PlanTimeframes | null | undefined,
tz: string,
atISO: string,
edge: "entry" | "exit",
): { start: string; end: string; minutes: number } | null {
if (!timeframes) return null;
const atMs = Date.parse(atISO);
if (Number.isNaN(atMs)) return null;
const zone = timeframes.tz || tz;
const wall = localBreakdown(atMs, zone);
const day: DayWindow | undefined = isWeekend(wall.dow) ? timeframes.weekend : timeframes.weekday;
// No window for this day-type, or explicitly all-day ⇒ unrestricted, no charge.
if (!day || day.allDay) return null;
if (typeof day.fromMin !== "number" || typeof day.toMin !== "number") return null;
const grace = Math.max(0, timeframes.graceMin ?? 0);
const nowMin = wall.hour * 60 + wall.minute;
if (inWindow(nowMin, day.fromMin, day.toMin)) return null; // already allowed
// Minutes (always ≥ 0) until the window OPENS, measured forward from the scan.
const minsUntil = (target: number) => ((target - nowMin) % 1440 + 1440) % 1440;
// Minutes (always ≥ 0) since the window CLOSED, measured backward from the scan.
const minsSince = (target: number) => ((nowMin - target) % 1440 + 1440) % 1440;
if (edge === "entry") {
// Early: charge from the scan until the window opens (minus grace tolerance).
let mins = minsUntil(day.fromMin) - grace;
if (mins <= 0) return null; // within grace of opening
const end = new Date(atMs + mins * 60_000).toISOString();
return { start: atISO, end, minutes: mins };
}
// Late exit: charge from when the window closed (plus grace) until the scan.
let mins = minsSince(day.toMin) - grace;
if (mins <= 0) return null; // within grace of closing
const start = new Date(atMs - mins * 60_000).toISOString();
return { start, end: atISO, minutes: mins };
}
/** "HH:MM" → minutes-of-day (0-1439). Invalid → NaN (validation rejects those). */ /** "HH:MM" → minutes-of-day (0-1439). Invalid → NaN (validation rejects those). */
function hourToMin(hhmm: string): number { function hourToMin(hhmm: string): number {
const m = /^(\d{2}):(\d{2})$/.exec(hhmm); const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import { outOfWindowGap, type PlanTimeframes } from "./index.js";
// The "tariff bridge" gap for a subscriber scan outside their allowed window. UTC tz
// keeps the wall-clock arithmetic obvious in the tests. See wiki/entities/subscription.md.
// Night plan: weekday allowed 20:00→08:00 (wraps midnight); weekend all-day.
const night: PlanTimeframes = {
weekday: { fromMin: 20 * 60, toMin: 8 * 60 }, // 1200 → 480
weekend: { allDay: true },
graceMin: 0,
tz: "UTC",
};
// A weekday + a weekend (2026-06-22 is a Monday; 2026-06-20 is a Saturday).
const monday = (hhmm: string) => `2026-06-22T${hhmm}:00.000Z`;
const saturday = (hhmm: string) => `2026-06-20T${hhmm}:00.000Z`;
describe("outOfWindowGap — entry edge (early arrival)", () => {
it("19:30 arrival to a 20:00 window owes 30 min", () => {
const g = outOfWindowGap(night, "UTC", monday("19:30"), "entry");
expect(g).not.toBeNull();
expect(g!.minutes).toBe(30);
expect(g!.start).toBe(monday("19:30"));
expect(g!.end).toBe(monday("20:00"));
});
it("09:00 daytime arrival owes the whole gap to 20:00 (11h)", () => {
const g = outOfWindowGap(night, "UTC", monday("09:00"), "entry");
expect(g!.minutes).toBe(11 * 60);
expect(g!.end).toBe(monday("20:00"));
});
it("in-window arrival (22:00) owes nothing", () => {
expect(outOfWindowGap(night, "UTC", monday("22:00"), "entry")).toBeNull();
});
it("after-midnight in-window arrival (02:00) owes nothing", () => {
expect(outOfWindowGap(night, "UTC", monday("02:00"), "entry")).toBeNull();
});
});
describe("outOfWindowGap — exit edge (late departure)", () => {
it("08:45 exit after an 08:00 window close owes 45 min", () => {
const g = outOfWindowGap(night, "UTC", monday("08:45"), "exit");
expect(g!.minutes).toBe(45);
expect(g!.start).toBe(monday("08:00"));
expect(g!.end).toBe(monday("08:45"));
});
it("in-window exit (07:00) owes nothing", () => {
expect(outOfWindowGap(night, "UTC", monday("07:00"), "exit")).toBeNull();
});
});
describe("outOfWindowGap — weekend all-day", () => {
it("any Saturday scan is free (entry + exit)", () => {
expect(outOfWindowGap(night, "UTC", saturday("09:00"), "entry")).toBeNull();
expect(outOfWindowGap(night, "UTC", saturday("23:30"), "exit")).toBeNull();
});
});
describe("outOfWindowGap — grace tolerance", () => {
const withGrace: PlanTimeframes = { ...night, graceMin: 15 };
it("19:50 entry (10 min before open) is within a 15-min grace → no charge", () => {
expect(outOfWindowGap(withGrace, "UTC", monday("19:50"), "entry")).toBeNull();
});
it("19:30 entry (30 min before) still charged, minus 15 grace = 15 min", () => {
const g = outOfWindowGap(withGrace, "UTC", monday("19:30"), "entry");
expect(g!.minutes).toBe(15);
});
it("08:10 exit within a 15-min grace of the 08:00 close → no charge", () => {
expect(outOfWindowGap(withGrace, "UTC", monday("08:10"), "exit")).toBeNull();
});
});
describe("outOfWindowGap — unrestricted", () => {
it("null timeframes → never a charge", () => {
expect(outOfWindowGap(null, "UTC", monday("09:00"), "entry")).toBeNull();
});
it("a day-type with no window → no charge", () => {
const weekdayOnly: PlanTimeframes = { weekday: { fromMin: 1200, toMin: 480 }, tz: "UTC" };
// weekend absent ⇒ unrestricted on Saturday.
expect(outOfWindowGap(weekdayOnly, "UTC", saturday("09:00"), "entry")).toBeNull();
});
});
+17
View File
@@ -33,6 +33,23 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly, loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
not silently corrected. not silently corrected.
## Reserved subscriber spots (admin toggle, built 2026-06-20)
By default occupancy counts only cars **physically inside** — a subscriber who isn't parked frees
their spot to transients, and the operator handles any overflow by valet/key-juggling. A site can
instead **hold a spot for every active subscriber**, so the lot reads "full" to transients sooner and
the subscriber's place is guaranteed:
- `site_config.reserve_subscriber_spots` (bool, default off). When ON,
`reservedSubscriberSpots(db)` sums, over every **active** subscription (status active AND
`now ∈ [validFrom, validTo]`), `max(0, quantity − itsCarsCurrentlyInside)` — i.e. it reserves only
the **not-yet-parked** portion of each subscription's [[subscription|quantity]] (a parked
subscriber already occupies a real spot; counting them twice would over-reserve).
- `getOccupancy` gains `reserved` + `effectiveFree = capacity − count − reserved`. The transient FULL
gate becomes **`count + reserved ≥ capacity`**. Subscribers are still **never** gated by full
(their flow ignores it) — reservation only tightens the *transient* gate.
- OFF = the prior behaviour exactly (`reserved = 0`).
## "Full" is a soft, operator-configurable policy ## "Full" is a soft, operator-configurable policy
Refusing at capacity is the **default**, not an absolute. An operator may opt into Refusing at capacity is the **default**, not an absolute. An operator may opt into
+6
View File
@@ -20,6 +20,12 @@ beyond the host).
> reproducible repricing. The operator selects a plan + span; the price is looked up, never typed. > reproducible repricing. The operator selects a plan + span; the price is looked up, never typed.
> Tariffs price *transient* stays by duration; plans price *subscription* spans by ceil(periods). > Tariffs price *transient* stays by duration; plans price *subscription* spans by ceil(periods).
> **The tariff also prices SUBSCRIBERS now (2026-06-20).** A [[subscription]] plan with time windows
> charges the **transient tariff** for any out-of-window parking (early entry / late exit) — the
> subscriber temporarily *becomes* a transient for those minutes. `computeFee` is reused unchanged;
> the gap is a normal `[start, end]` priced against the active version (recorded `tariffVersionId` for
> reproducibility). See [[subscription]] "the tariff bridge".
> Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing > Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing
> publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned > publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned
> over time), modelled with an id/scope so multiple rate cards can be added later without migration; > over time), modelled with an id/scope so multiple rate cards can be added later without migration;
+39
View File
@@ -78,6 +78,45 @@ subscription row, one window. The amount the operator should collect is **N × t
`now` ∈ [validFrom, validTo]** — so a 3-month window simply stays valid for three months. `now` ∈ [validFrom, validTo]** — so a 3-month window simply stays valid for three months.
- An explicit **`validTo` override** is still accepted (manual end date) when `months` isn't used. - An explicit **`validTo` override** is still accepted (manual end date) when `months` isn't used.
### v2 — quantity, plan timeframes (tariff bridge), reserved spots (built 2026-06-20)
Three enhancements driven by real scenarios (migration `0011`):
**Quantity (`subscriptions.quantity`, default 1).** One subscription can cover **N cars** — a family
where the husband pays once for two cars. The sale amount is `priceSubscriptionSpan(...) × quantity`;
`maxConcurrent` defaults to the quantity (so both cars can be inside). The payment payload carries
`quantity`. Credentials/plates for all N cars live on the one subscription.
**Plan timeframes → the TARIFF BRIDGE (`subscription_plans.timeframes`).** A plan may restrict WHEN a
subscriber may park (e.g. weekday allowed 20:00→08:00, weekend all-day). Instead of **refusing**
out-of-window scans, the system **charges the out-of-window minutes at the normal transient
[[tariff]]** — the subscriber becomes a transient customer for the time outside their window:
- `PlanTimeframes` = per day-type `DayWindow` ({ allDay | fromMin, toMin } minutes-of-local-midnight;
`toMin ≤ fromMin` wraps past midnight for a night window) + `graceMin` + the site `tz` (frozen in
the plan version, like a V2 tariff's tz). null timeframes = 24/7, no charge ever.
- `outOfWindowGap(timeframes, tz, at, edge)` (pure, tz-aware, unit-tested in `@parking/shared`)
returns the `[start, end]` portion outside the window. **Early entry**: gap = arrival → next
window-open (a 09:00 arrival to a 20:00 window owes 09:00→20:00, capped by the tariff's daily cap).
**Late exit**: gap = window-close → departure. The gap is priced with `computeFee` (the same engine
transient stays use) at the active tariff version (`apps/server/src/subscription-window.ts`).
- **Early entry is DEFERRED:** the barrier opens now; the owed amount is **signed onto the
`vehicle_entry` payload** (`windowOwedMinor` + the priced gap + `windowTariffVersionId`) — the
on-chain source of truth, read back at exit.
- **Late exit is GATED:** at exit, `totalOwed = carried entry charge + a fresh late-exit charge −
payments`. If `> 0`, the exit is **REFUSED** with a new signed reason `sub.refused.unpaidWindow`;
the subscriber settles at the booth (a signed `payment` keyed to the occurrence — folds into the
shift/drawer/Z-report like any taking) and re-scans. The booth pay modal surfaces the amount as an
"OUT-OF-WINDOW" charge (`PayStation.lookup`/`pay` handle the subscription-window case).
> ⚠ **Exit gate vs. "never trap a vehicle."** This refusal is a **host-ONLINE business gate**,
> identical in kind to the existing transient `exit.refused.unpaid`/overstay gate — a working host
> *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide*
> (power/host/network loss) path, which still opens. The two are not in conflict; don't conflate them.
**Reserved subscriber spots** — see [[capacity-occupancy]] (an admin toggle that holds a spot per
active subscriber's car in the [[occupancy]] full-gate). The subscriber flow itself is never gated by
"full"; reservation only tightens the *transient* gate.
### Collecting the fee is a SHIFT transaction — BUILT 2026-06-20 ### Collecting the fee is a SHIFT transaction — BUILT 2026-06-20
Selling/renewing a subscription is a **financial transaction a common operator makes during their Selling/renewing a subscription is a **financial transaction a common operator makes during their
+21
View File
@@ -1152,3 +1152,24 @@ hotel sale prices to 2,400 ALL, appends ONE signed payment with planVersionId, c
Build + lint 12/12. Updated [[subscription]] (plan catalog supersedes typed price; data model) + Build + lint 12/12. Updated [[subscription]] (plan catalog supersedes typed price; data model) +
[[tariff]] (shared versioned-config pattern). The site default price column is kept only to seed the [[tariff]] (shared versioned-config pattern). The site default price column is kept only to seed the
first plan. first plan.
## [2026-06-20] feat | Subscription v2 — quantity, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements (migration 0011, additive columns):
(1) QUANTITY — one subscription covers N cars (a family pays once for 2); sale = span price × quantity,
maxConcurrent defaults to it.
(2) PLAN TIMEFRAMES → TARIFF BRIDGE — a plan may restrict when a subscriber may park (weekday
20:00→08:00, weekend all-day). Outside the window they're charged the TRANSIENT tariff for the gap
(not refused): early entry = arrival→window-open (deferred, signed as windowOwedMinor on the
vehicle_entry); 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 — fail-open still governs the offline path (flagged in the wiki).
(3) RESERVED SPOTS — site toggle reserve_subscriber_spots: occupancy holds max(0, quantity−inside) per
active sub, so transients see "full" sooner; effectiveFree = capacity − count − reserved. Subscribers
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 + takes payment.
Verified on a copy of the live DB: qty 2 = 2× price; night-plan 19:30 entry → 30min/15,000 ALL owed,
stamped + paid → gate clears, chain verifies; reserve toggle holds a qty-2 sub's 2 spots. Build+lint
12/12; 80 shared tests. Updated [[subscription]], [[capacity-occupancy]], [[tariff]].