diff --git a/apps/server/src/occupancy.ts b/apps/server/src/occupancy.ts index e1f3fe0..f59c018 100644 --- a/apps/server/src/occupancy.ts +++ b/apps/server/src/occupancy.ts @@ -1,4 +1,4 @@ -import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db"; +import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db"; // Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events // with no matching vehicle_exit. Never a hand-maintained counter (which is @@ -7,11 +7,18 @@ import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db"; export interface Occupancy { /** Cars currently inside (open sessions). */ readonly count: number; + /** Spots HELD for active subscribers who are NOT currently parked (when the + * reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds + * `quantity` spots minus however many of its cars are already inside. */ + readonly reserved: number; /** Admin-set nominal capacity, or null = no limit. */ readonly capacity: number | null; /** capacity − count, or null when uncapped. Can read 0 (or below) when full. */ readonly free: number | null; - /** True when count ≥ capacity (always false when uncapped). */ + /** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */ + readonly effectiveFree: number | null; + /** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity + * (always false when uncapped). Subscribers are never gated by this. */ readonly full: boolean; } @@ -37,13 +44,66 @@ export function siteCapacity(db: Db): number | null { return row?.capacity ?? null; } +/** + * Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless + * `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status + * active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that + * subscription already inside (so we never double-count a parked subscriber). This is + * what makes a transient see "full" sooner while the subscriber's spot is held. + */ +export function reservedSubscriberSpots(db: Db): number { + const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); + if (!cfg?.reserveSubscriberSpots) return 0; + + // Cars currently inside per subscription (occurrence entries by permitId, net of exits). + const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); + const insidePerSub = new Map(); + const net = new Map(); // occurrence identity → entries−exits + const subOf = new Map(); // occurrence identity → subscription id + for (const r of rows) { + const id = r.identity; + if (!id) continue; + if (r.type === "vehicle_entry") { + const pl = (r.payload ?? {}) as { permitId?: string }; + if (pl.permitId == null) continue; // transient + net.set(id, (net.get(id) ?? 0) + 1); + subOf.set(id, pl.permitId); + } else if (r.type === "vehicle_exit") { + if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1); + } + } + for (const [id, n] of net) if (n > 0) { + const sub = subOf.get(id)!; + insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1); + } + + const now = new Date().toISOString(); + const subs = db.select().from(subscriptions).all(); + let reserved = 0; + for (const s of subs) { + const active = + s.status === "active" && + (s.validFrom == null || now >= s.validFrom) && + (s.validTo == null || now <= s.validTo); + if (!active) continue; + const qty = s.quantity ?? 1; + const inside = insidePerSub.get(s.id) ?? 0; + reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion + } + return reserved; +} + export function getOccupancy(db: Db): Occupancy { const count = occupancyCount(db); const capacity = siteCapacity(db); + const reserved = reservedSubscriberSpots(db); return { count, + reserved, capacity, free: capacity == null ? null : capacity - count, - full: capacity != null && count >= capacity, + effectiveFree: capacity == null ? null : capacity - count - reserved, + // A transient is refused once physical cars + held subscriber spots reach capacity. + full: capacity != null && count + reserved >= capacity, }; } diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index 28d5d0e..b76962d 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -3,6 +3,7 @@ import { priceSession, type TariffStructure, type Tender } from "@parking/shared import type { FastifyBaseLogger } from "fastify"; import type { EventLog } from "./event-log.js"; import { plateForIdentity, platesForIdentities } from "./plate-lookup.js"; +import { windowCharge } from "./subscription-window.js"; // The PAY STATION: a customer pays for an open session BEFORE walking back to the // car (pay-on-foot — payment is decoupled from exit). Two steps: @@ -200,6 +201,30 @@ export class PayStation { tender: Tender, overrideMinor?: number, ): Promise<{ amountMinor: number; currency: string }> { + // A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here + // (not a transient quote — the subscription itself is prepaid). The payment is keyed + // to the occurrence so the exit gate (#windowOwed − payments) clears. + const subWindow = this.#payableSubscriptionWindow(identity); + if (subWindow) { + const amountMinor = overrideMinor ?? subWindow.dueMinor; + await this.#log.append({ + type: "payment", + source: "manual", + identity, + payload: { + sessionRef: identity, + amountMinor, + currency: subWindow.currency ?? undefined, + tender, + ...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}), + subscriptionWindowCharge: true, + ...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}), + }, + }); + this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`); + return { amountMinor, currency: subWindow.currency ?? "" }; + } + const q = this.quote(identity); const amountMinor = overrideMinor ?? q.amountMinor; @@ -273,8 +298,11 @@ export class PayStation { paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null; const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt); - // Amount owed now (best-effort; null if no tariff resolves). Only meaningful while - // open AND transient — a subscription is prepaid, never quoted/charged. + // Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session + // it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a + // time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried + // on the entry payload + a live late-exit charge), which the booth must take so the + // exit gate clears. See wiki/entities/subscription.md. let amountMinor: number | null = null; let currency: string | null = null; if (open && !isSubscription) { @@ -285,6 +313,12 @@ export class PayStation { } catch { /* no active tariff — leave null; modal shows session without a price */ } + } else if (open && isSubscription) { + const w = this.#subscriptionWindowDue(id, subscriptionId); + if (w && w.dueMinor > 0) { + amountMinor = w.dueMinor; + currency = w.currency; + } } const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace; @@ -417,6 +451,65 @@ export class PayStation { return out; } + /** + * The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right + * now: carried early-entry charge (signed on the entry payload) + a fresh late-exit + * charge (window-close→now) − whatever they've already paid against the occurrence. + * null when the plan has no timeframes / nothing is owed. Mirrors SubscriptionFlow's + * exit-gate computation so the booth quote and the gate agree. + */ + #subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null { + if (!subscriptionId) return null; + const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get(); + if (!sub) return null; + + const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all(); + const entryRow = rows.find((r) => r.type === "vehicle_entry"); + const ep = (entryRow?.payload ?? {}) as { windowOwedMinor?: number; windowCurrency?: string }; + const entryOwed = typeof ep.windowOwedMinor === "number" ? ep.windowOwedMinor : 0; + + const exitCh = windowCharge(this.#db, sub.planVersionId, new Date().toISOString(), "exit"); + const exitOwed = exitCh?.amountMinor ?? 0; + + let paid = 0; + for (const r of rows) { + if (r.type !== "payment") continue; + const pl = (r.payload ?? {}) as { amountMinor?: number }; + if (typeof pl.amountMinor === "number") paid += pl.amountMinor; + } + + const dueMinor = entryOwed + exitOwed - paid; + const currency = ep.windowCurrency ?? exitCh?.currency ?? null; + return { dueMinor, currency }; + } + + /** Is this identity an OPEN subscription occurrence that owes a window charge? Returns + * the due amount + currency + the tariff version that priced the late-exit charge (for + * the payment payload), or null when it's transient / nothing owed. */ + #payableSubscriptionWindow( + identity: string, + ): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null { + const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all(); + const entry = rows.find((r) => r.type === "vehicle_entry"); + if (!entry) return null; + const ep = (entry.payload ?? {}) as { permit?: boolean; permitId?: string; windowTariffVersionId?: string }; + if (ep.permit !== true && ep.permitId == null) return null; // transient + if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out + const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null); + if (!due || due.dueMinor <= 0) return null; + // The late-exit charge resolves its own tariff version; for the entry-only case we + // stamped windowTariffVersionId on entry — pass whichever applies for reproducibility. + const exitCh = windowCharge(this.#db, this.#planVersionOf(ep.permitId ?? null), new Date().toISOString(), "exit"); + return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: exitCh?.tariffVersionId ?? ep.windowTariffVersionId ?? null }; + } + + /** The planVersionId of a subscription (for resolving its timeframes), or null. */ + #planVersionOf(subscriptionId: string | null): string | null { + if (!subscriptionId) return null; + const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get(); + return row?.planVersionId ?? null; + } + /** The subscriber's holder name for a subscription id (for a friendly UI label), * or null. Best-effort: a deleted subscription just yields null. */ #holderOf(subscriptionId: string | null): string | null { diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts index f983982..83ed407 100644 --- a/apps/server/src/routes/site.ts +++ b/apps/server/src/routes/site.ts @@ -29,6 +29,9 @@ interface SiteConfigBody extends Partial> { exitVoucherDefault?: boolean; /** Site default monthly subscription price in minor units (pre-fills the form). */ subscriptionMonthlyPriceMinor?: number | null; + /** Reserve a spot in occupancy for each active subscriber's car(s), even when not + * parked — so transients see "full" sooner and the subscriber's spot is held. */ + reserveSubscriberSpots?: boolean; } /** Shape returned by GET/PUT: capacity + the booth flag + the subscription default @@ -37,6 +40,7 @@ type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean; subscriptionMonthlyPriceMinor: number | null; + reserveSubscriberSpots: boolean; } & Record; function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { @@ -44,6 +48,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf capacity: row?.capacity ?? null, exitVoucherDefault: row?.exitVoucherDefault ?? false, subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null, + reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false, } as SiteConfig; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; return out; @@ -95,6 +100,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise { } patch.subscriptionMonthlyPriceMinor = p ?? null; } + if ("reserveSubscriberSpots" in body) { + if (typeof body.reserveSubscriberSpots !== "boolean") { + return reply.code(400).send({ error: "reserveSubscriberSpots must be a boolean" }); + } + patch.reserveSubscriberSpots = body.reserveSubscriberSpots; + } for (const f of TEXT_FIELDS) { if (f in body) patch[f] = normText(body[f]); } diff --git a/apps/server/src/routes/subscription-plans.ts b/apps/server/src/routes/subscription-plans.ts index 22faf11..ebf3558 100644 --- a/apps/server/src/routes/subscription-plans.ts +++ b/apps/server/src/routes/subscription-plans.ts @@ -1,8 +1,9 @@ import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { desc, eq, subscriptionPlans, type Db } from "@parking/db"; -import { SUBSCRIPTION_PERIODS, type SubscriptionPeriod } from "@parking/shared"; +import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared"; import { requirePermission } from "../auth.js"; +import { siteTz } from "../subscription-window.js"; // Subscription PLAN catalog — admin-composed, versioned config the operator SELLS // from (so they never type a price). Mirrors the tariff composer: plans are @@ -22,6 +23,20 @@ interface PlanBody { currency?: string; /** When this version takes effect (ISO-8601). Defaults to now. */ effectiveFrom?: string; + /** Allowed-time windows (tariff bridge); null/omitted = 24/7. */ + timeframes?: PlanTimeframes | null; +} + +/** Validate the optional timeframes blob (minutes-of-day 0–1439, sane grace). */ +function validTimeframes(tf: PlanTimeframes | null | undefined): string | null { + if (tf == null) return null; + const okMin = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439); + for (const dt of [tf.weekday, tf.weekend]) { + if (!dt) continue; + if (!dt.allDay && (!okMin(dt.fromMin) || !okMin(dt.toMin))) return "window times must be minutes-of-day (0–1439)"; + } + if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0"; + return null; } /** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */ @@ -51,6 +66,8 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) { errs.push("effectiveFrom must be a valid ISO-8601 timestamp"); } + const tfErr = validTimeframes(b.timeframes); + if (tfErr) errs.push(tfErr); return errs; } @@ -85,6 +102,11 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales", }); } + // Stamp the site tz into the timeframes so the windows evaluate in the site's + // wall-clock, FROZEN in this version (mirrors how tariff V2 freezes its tz). + const timeframes = + b.timeframes != null ? { ...b.timeframes, tz: b.timeframes.tz || siteTz(db) } : null; + const row = { id: randomUUID(), planId, @@ -93,10 +115,11 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom pricePerPeriodMinor: b.pricePerPeriodMinor!, currency: b.currency!.trim(), effectiveFrom, + timeframes, active: true, createdBy: req.user?.username ?? null, }; - db.insert(subscriptionPlans).values(row).run(); + db.insert(subscriptionPlans).values(row as typeof subscriptionPlans.$inferInsert).run(); return reply.code(201).send(row); }); diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts index bc5d5cd..f588782 100644 --- a/apps/server/src/routes/subscriptions.ts +++ b/apps/server/src/routes/subscriptions.ts @@ -45,7 +45,10 @@ interface SubscriptionBody { * REQUIRED (the span priced against the plan). For a comp sub, both optional. */ validFrom?: string | null; validTo?: string | null; - /** Car-count binding: cars inside at once. Default 1; null = unbound. */ + /** How many cars this subscription covers (a family pays once for N cars). Sale = + * plan span price × quantity; maxConcurrent defaults to it. ≥ 1, default 1. */ + quantity?: number | null; + /** Car-count binding: cars inside at once. Default = quantity; null = unbound. */ maxConcurrent?: number | null; status?: "active" | "suspended" | "revoked"; credentials?: Credential[]; @@ -61,6 +64,7 @@ interface QuoteBody { planId?: string; validFrom?: string; validTo?: string; + quantity?: number; } /** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader @@ -112,6 +116,9 @@ export async function subscriptionRoutes( if (!plan) errs.push("no active plan found for the selected planId"); } } + if (b.quantity != null && (!Number.isInteger(b.quantity) || b.quantity < 1)) { + errs.push("quantity must be a positive integer (cars covered)"); + } if (b.status && !["active", "suspended", "revoked"].includes(b.status)) { errs.push("status must be active|suspended|revoked"); } @@ -188,16 +195,23 @@ export async function subscriptionRoutes( return fallback; } - /** Resolve + price a priced sale: returns the plan version, the effective span, and - * the server-computed quote. Returns null for a comp sub (no planId). Throws on a - * planId that no longer resolves (validate() guards the happy path). */ - function priceSale(b: SubscriptionBody): { plan: SubscriptionPlan; validFrom: string; validTo: string; quote: SubscriptionQuote } | null { + /** Resolve + price a priced sale: returns the plan version, the effective span, the + * quantity (cars covered), and the server-computed quote with the amount already + * MULTIPLIED by quantity (a family paying once for N cars). Returns null for a comp + * sub (no planId). validate() guards the happy path. */ + function priceSale( + b: SubscriptionBody, + ): { plan: SubscriptionPlan; validFrom: string; validTo: string; quantity: number; quote: SubscriptionQuote } | null { if (!b.planId?.trim() || !b.validTo?.trim()) return null; const validFrom = b.validFrom?.trim() || new Date().toISOString(); const validTo = b.validTo.trim(); const plan = resolvePlanVersion(db, b.planId.trim(), validFrom); if (!plan) return null; - return { plan, validFrom, validTo, quote: priceSubscriptionSpan(plan, validFrom, validTo) }; + const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1; + const base = priceSubscriptionSpan(plan, validFrom, validTo); + // Price ×N: the whole sale covers N cars on one subscription. + const quote: SubscriptionQuote = { ...base, amountMinor: base.amountMinor * quantity }; + return { plan, validFrom, validTo, quantity, quote }; } // List all subscriptions (with their credentials + plates). @@ -264,7 +278,10 @@ export async function subscriptionRoutes( } const plan = resolvePlanVersion(db, b.planId.trim(), validFrom); if (!plan) return reply.code(404).send({ error: "no active plan for that planId" }); - return { ...priceSubscriptionSpan(plan, validFrom, validTo), plan }; + const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1; + const base = priceSubscriptionSpan(plan, validFrom, validTo); + // Echo the ×quantity total so the form previews the family's combined price. + return { ...base, amountMinor: base.amountMinor * quantity, quantity, plan }; }); app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => { @@ -286,7 +303,15 @@ export async function subscriptionRoutes( currency: priced ? priced.quote.currency : null, planId: priced ? priced.plan.planId : null, planVersionId: priced ? priced.plan.id : null, - maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent, + quantity: priced ? priced.quantity : (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1), + // maxConcurrent defaults to the quantity (the family's N cars can all be inside), + // unless the operator set it explicitly (null = unbound). + maxConcurrent: + b.maxConcurrent !== undefined + ? b.maxConcurrent + : priced + ? priced.quantity + : (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1), validFrom: priced ? priced.validFrom : (b.validFrom ?? null), validTo: priced ? priced.validTo : resolveValidTo(b, null), status: b.status ?? "active", @@ -350,10 +375,11 @@ export async function subscriptionRoutes( planId: plan.planId, planVersionId: plan.id, periods: quote.periods, + ...(priced.quantity > 1 ? { quantity: priced.quantity } : {}), }, }); app.log.info( - `subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}) for ${id} by ${operator}` + + `subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}×${priced.quantity}car) for ${id} by ${operator}` + (inShift ? "" : " [no open shift]"), ); } catch (err) { diff --git a/apps/server/src/subscription-flow.ts b/apps/server/src/subscription-flow.ts index e8d43eb..6fc2181 100644 --- a/apps/server/src/subscription-flow.ts +++ b/apps/server/src/subscription-flow.ts @@ -16,6 +16,7 @@ import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { EventLog } from "./event-log.js"; import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js"; import { snapshotAsync } from "./snapshot.js"; +import { windowCharge } from "./subscription-window.js"; import type { VisionClient } from "./vision-client.js"; // SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without @@ -147,6 +148,23 @@ export class SubscriptionFlow { return { accepted: false, direction: "exit", reason }; } const occurrenceId = oldest.identity; + + // TARIFF BRIDGE — exit gate. Total owed = carried early-entry charge (signed on the + // entry payload) + a late-exit charge (window-close→now) computed fresh. If the + // subscriber owes money and hasn't paid it, REFUSE the exit (like the transient + // unpaid/overstay gate) — they settle at the booth (a signed `payment` keyed to the + // occurrence), then re-scan. This is a host-ONLINE business gate; the offline path + // still fails open. See wiki/entities/subscription.md ("tariff bridge"). + const owed = this.#windowOwed(occurrenceId, m.subscriptionId, sub.planVersionId); + const paid = this.#windowPaidMinor(occurrenceId); + if (owed.totalMinor - paid > 0) { + const reason = await this.#reject(m, "exit", "sub.refused.unpaidWindow", { + amount: ((owed.totalMinor - paid) / 100).toFixed(2), + currency: owed.currency ?? "", + }); + return { accepted: false, direction: "exit", reason }; + } + await this.#log.append({ type: "vehicle_exit", direction: "exit", @@ -170,6 +188,14 @@ export class SubscriptionFlow { return { accepted: false, direction: "entry", reason }; } + // TARIFF BRIDGE — early entry. If the plan has time windows and this scan is before + // the window opens, the subscriber owes the transient tariff for arrival→window-open. + // We DEFER it (open now, collect at exit): stamp the owed amount on the SIGNED entry + // payload (the source of truth — `windowOwedMinor`), so the exit gate reads it back + // from the chain. Plans without timeframes return null → nothing owed. See + // wiki/entities/subscription.md. + const entryCharge = windowCharge(this.#db, sub.planVersionId, now, "entry"); + // A short, unique occurrence id. The subscription id is NOT embedded — it rides in // the payload's `permitId` (which every fold matches on), so the key stays compact. const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`; @@ -179,10 +205,30 @@ export class SubscriptionFlow { source, identity: occurrenceId, // No ticket, no fee — the subscription IS the authorization. Recorded for audit. - // `permitId`/`permit` are the on-chain field names (immutable). - payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via }, + // `permitId`/`permit` are the on-chain field names (immutable). A deferred early- + // entry charge is signed here (windowOwedMinor + the priced gap) so it's owed at exit. + payload: { + sessionRef: occurrenceId, + permitId: m.subscriptionId, + permit: true, + via: m.via, + ...(entryCharge + ? { + windowOwedMinor: entryCharge.amountMinor, + windowCurrency: entryCharge.currency, + windowTariffVersionId: entryCharge.tariffVersionId, + windowGapStart: entryCharge.gapStart, + windowGapEnd: entryCharge.gapEnd, + } + : {}), + }, occurredAt: now, }); + if (entryCharge) { + this.#logger.info( + `subscription early-entry charge ${entryCharge.amountMinor} ${entryCharge.currency} (${entryCharge.minutes}min) deferred on ${occurrenceId}`, + ); + } await this.#open(resolved, "entry", occurrenceId, "subscription entry"); try { this.#db @@ -202,6 +248,49 @@ export class SubscriptionFlow { return { accepted: true, direction: "entry" }; } + /** + * Total out-of-window charge owed for an occurrence right now: the carried EARLY-ENTRY + * charge (signed on the `vehicle_entry` payload as `windowOwedMinor`) + a fresh + * LATE-EXIT charge (window-close→now). Pure read; the entry portion is on-chain truth, + * the exit portion is recomputed each scan (it grows until they leave). Returns the sum + * and the currency. A plan without timeframes yields 0. + */ + #windowOwed( + occurrenceId: string, + _subscriptionId: string, + planVersionId: string | null, + ): { totalMinor: number; currency: string | null } { + // Carried early-entry charge from the signed entry payload. + const entryRow = this.#db + .select() + .from(ledgerEvents) + .where(eq(ledgerEvents.identity, occurrenceId)) + .all() + .find((r) => r.type === "vehicle_entry"); + const ep = (entryRow?.payload ?? {}) as { windowOwedMinor?: number; windowCurrency?: string }; + const entryOwed = typeof ep.windowOwedMinor === "number" ? ep.windowOwedMinor : 0; + + // Fresh late-exit charge (window-close → now), priced transiently. + const exitCh = windowCharge(this.#db, planVersionId, new Date().toISOString(), "exit"); + const exitOwed = exitCh?.amountMinor ?? 0; + + const currency = ep.windowCurrency ?? exitCh?.currency ?? null; + return { totalMinor: entryOwed + exitOwed, currency }; + } + + /** Sum of signed `payment` events keyed to this occurrence (what the subscriber has + * already paid toward their window charge). Folds the append-only ledger. */ + #windowPaidMinor(occurrenceId: string): number { + const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all(); + let paid = 0; + for (const r of rows) { + if (r.type !== "payment") continue; + const pl = (r.payload ?? {}) as { amountMinor?: number }; + if (typeof pl.amountMinor === "number") paid += pl.amountMinor; + } + return paid; + } + /** * The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a * fold over the signed ledger. An occurrence is a `vehicle_entry` (whose diff --git a/apps/server/src/subscription-window.ts b/apps/server/src/subscription-window.ts new file mode 100644 index 0000000..09959b4 --- /dev/null +++ b/apps/server/src/subscription-window.ts @@ -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, + }; +} diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 497155c..670ac76 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -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 // booth-exit-flow.md / reopenBarrier server guard. const isOverstay = s?.overstay === true; - // A subscription is prepaid: never charged. The only booth action is an audited - // barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off. - // Allow pay for an unpaid session OR an overstay (new-period top-up) one. - const canPay = !!(shiftReady && s?.found && s.open && (!alreadyPaid || isOverstay) && !isSubscription); + // A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can + // owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns + // it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable + // 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() { if (!s) return; @@ -252,25 +260,33 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose /> - {/* Total — a subscription is prepaid (no amount); show a badge. For an - overstay the amount is the TOP-UP delta, not the whole stay. */} + {/* Total — a subscription is prepaid (no amount) UNLESS it owes an + out-of-window window charge; then show that amount. For an overstay the + amount is the TOP-UP delta, not the whole stay. */}
- {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")} - {isSubscription - ? t("pay.prepaid") - : s.amountMinor != null && s.currency - ? formatMoney(s.amountMinor, s.currency) - : alreadyPaid - ? t("booth.badgePaid") - : t("pay.noTariff")} + {subWindowDue && s.amountMinor != null && s.currency + ? formatMoney(s.amountMinor, s.currency) + : isSubscription + ? t("pay.prepaid") + : s.amountMinor != null && s.currency + ? formatMoney(s.amountMinor, s.currency) + : alreadyPaid + ? t("booth.badgePaid") + : t("pay.noTariff")}
- {/* For a subscription, explain the only available action. */} - {isSubscription && ( + {/* For a subscription with a window charge, explain why it's payable. For a + plain prepaid subscription, explain the assist-open is the only action. */} + {subWindowDue ? ( +
+ {t("pay.windowChargeHint")} +
+ ) : isSubscription && (
{t("pay.subAssistHint")}
diff --git a/apps/web/src/SiteSettings.tsx b/apps/web/src/SiteSettings.tsx index ca3419c..91bf474 100644 --- a/apps/web/src/SiteSettings.tsx +++ b/apps/web/src/SiteSettings.tsx @@ -25,6 +25,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { const [capInput, setCapInput] = useState(""); const [meta, setMeta] = useState>({}); const [exitVoucherDefault, setExitVoucherDefault] = useState(false); + const [reserveSubs, setReserveSubs] = useState(false); const [msg, setMsg] = useState(null); function reload() { @@ -36,6 +37,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { .then((c) => { setCapInput(c.capacity == null ? "" : String(c.capacity)); setExitVoucherDefault(c.exitVoucherDefault); + setReserveSubs(c.reserveSubscriberSpots); const m: Record = {}; for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]); setMeta(m); @@ -49,6 +51,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { const patch: Partial = { capacity: raw === "" ? null : Math.round(Number(raw)), exitVoucherDefault, + reserveSubscriberSpots: reserveSubs, }; // Send each metadata field; "" → null is applied server-side. for (const { key } of META_FIELDS) (patch as Record)[key] = meta[key] ?? ""; @@ -97,6 +100,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { {t("site.printExitDefault")} {t("site.printExitHint")} +
{t("site.parkDetails")}
diff --git a/apps/web/src/SubscriptionManager.tsx b/apps/web/src/SubscriptionManager.tsx index 990ac2b..a5f920d 100644 --- a/apps/web/src/SubscriptionManager.tsx +++ b/apps/web/src/SubscriptionManager.tsx @@ -33,6 +33,7 @@ interface FormState { holderName: string; contact: string; 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) carBound: boolean; // false = unbound (maxConcurrent null) maxConcurrent: string; @@ -52,6 +53,7 @@ function emptyForm(): FormState { holderName: "", contact: "", planId: "", + quantity: "1", tender: "cash", carBound: true, maxConcurrent: "1", @@ -66,6 +68,7 @@ function formFrom(s: Subscription): FormState { holderName: s.holderName ?? "", contact: s.contact ?? "", 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 carBound: s.maxConcurrent != null, 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 // re-sell, so no planId is sent (price/plan stay frozen). planId: planSelected ? f.planId.trim() : null, + quantity: Math.max(1, Math.round(Number(f.quantity) || 1)), tender: f.tender, maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null, validFrom: dateToISO(f.validFrom), @@ -165,10 +169,11 @@ export function SubscriptionManager() { setQuote(null); return; } + const quantity = Math.max(1, Math.round(Number(form.quantity) || 1)); let cancelled = false; setQuoting(true); 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)) .catch(() => !cancelled && setQuote(null)) .finally(() => !cancelled && setQuoting(false)); @@ -177,7 +182,7 @@ export function SubscriptionManager() { cancelled = true; clearTimeout(h); }; - }, [editing, form.planId, form.validFrom, form.validTo]); + }, [editing, form.planId, form.validFrom, form.validTo, form.quantity]); function startNew() { setForm(emptyForm()); @@ -378,6 +383,22 @@ export function SubscriptionManager() { {form.planId || t("subs.noPrice")} )} + {/* 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" && ( + <> + + + setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))} + /> + {t("subs.quantityHint")} + + + )} {/* 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. */} {form.planId.trim() !== "" && editing === "new" && ( @@ -434,7 +455,7 @@ export function SubscriptionManager() { unit: t(PERIOD_KEY[quote.period]), amount: (quote.amountMinor / 100).toLocaleString(), currency: quote.currency, - }) + }) + (quote.quantity && quote.quantity > 1 ? ` (×${quote.quantity})` : "") : t("subs.quotePrompt")} )} diff --git a/apps/web/src/SubscriptionPlansManager.tsx b/apps/web/src/SubscriptionPlansManager.tsx index 2e55b5d..8de1abb 100644 --- a/apps/web/src/SubscriptionPlansManager.tsx +++ b/apps/web/src/SubscriptionPlansManager.tsx @@ -29,10 +29,40 @@ interface PlanForm { period: SubscriptionPeriod; priceMajor: 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 { - 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() { @@ -55,6 +85,20 @@ export function SubscriptionPlansManager() { const major = Number(form.priceMajor); 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") }); + // 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[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 { await createSubscriptionPlan({ planId: form.planId.trim() || undefined, @@ -62,6 +106,7 @@ export function SubscriptionPlansManager() { period: form.period, pricePerPeriodMinor: Math.round(major * 100), currency: form.currency.trim() || DEFAULT_CURRENCY, + timeframes, }); setForm(null); reload(); @@ -80,12 +125,19 @@ export function SubscriptionPlansManager() { /** Publish a new version of an existing plan (pre-fills its identity + last values). */ function newVersionOf(p: SubscriptionPlan) { + const tf = p.timeframes ?? null; + const wd = tf?.weekday; setForm({ planId: p.planId, name: p.name, period: p.period, priceMajor: String(p.pricePerPeriodMinor / 100), 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); } @@ -179,6 +231,48 @@ export function SubscriptionPlansManager() { / {t(PERIOD_KEY[form.period])} + + {/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the + window they're charged the transient tariff for the gap. Off = 24/7. */} +
+ + {form.restrictTimes && ( +
+ + + {t("plans.enterAfter")} + setForm((f) => f && { ...f, wdFrom: e.target.value })} /> + {t("plans.exitBefore")} + setForm((f) => f && { ...f, wdTo: e.target.value })} /> + + + + + + setForm((f) => f && { ...f, graceMin: e.target.value })} /> + {t("plans.graceHint")} + +
+ )} +

{t("plans.timeframesHint")}

+
+ {form.planId &&

{t("plans.newVersionHint")}

}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index d6ba8ac..157ccc7 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -494,6 +494,20 @@ export interface SubscriptionCredential { } 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 * from (so they never type a price). */ export interface SubscriptionPlan { @@ -505,6 +519,8 @@ export interface SubscriptionPlan { currency: string; effectiveFrom: string; active: boolean; + /** Allowed-time windows (tariff bridge); null/absent = 24/7, no time charge. */ + timeframes?: PlanTimeframes | null; } export interface Subscription { @@ -518,6 +534,8 @@ export interface Subscription { /** Which plan + immutable version priced this sale (null for legacy/comp). */ planId: string | null; planVersionId: string | null; + /** Cars covered by this one subscription (price was ×N). Default 1. */ + quantity: number; maxConcurrent: number | null; validFrom: string | null; validTo: string | null; @@ -540,6 +558,8 @@ export type SubscriptionInput = { /** Coverage window. Priced sale: validFrom defaults to now, validTo required. */ validFrom: string | null; validTo: string | null; + /** Cars covered (price ×N). Default 1. */ + quantity?: number; maxConcurrent: number | null; status?: Subscription["status"]; /** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a @@ -549,12 +569,13 @@ export type SubscriptionInput = { 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 { periods: number; amountMinor: number; currency: string; period: SubscriptionPeriod; + quantity?: number; plan: SubscriptionPlan; } @@ -589,6 +610,7 @@ export function createSubscriptionPlan(body: { pricePerPeriodMinor: number; currency: string; effectiveFrom?: string; + timeframes?: PlanTimeframes | null; }): Promise { return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) }); } @@ -600,6 +622,7 @@ export function quoteSubscription(body: { planId: string; validFrom: string | null; validTo: string; + quantity?: number; }): Promise { return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) }); } @@ -779,6 +802,8 @@ export interface SiteConfig { exitVoucherDefault: boolean; /** Site default monthly subscription price (minor units); pre-fills the form. */ subscriptionMonthlyPriceMinor: number | null; + /** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */ + reserveSubscriberSpots: boolean; parkName: string | null; operatorName: string | null; /** NIUS — Albanian tax/identification number. */ diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 25cb4ba..9f7e193 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -387,6 +387,8 @@ export const en: Catalog = { perWeek: "week", perMonth: "month", plan: "Plan", + quantity: "Cars", + quantityHint: "cars covered by this subscription (price ×N)", planNone: "— comp / no charge —", planNoneAvail: "No plans defined — an admin must create one first.", quoting: "pricing…", @@ -466,6 +468,16 @@ export const en: Catalog = { needPrice: "Enter a price greater than zero.", saved: "Plan saved.", 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: { occupancy: "Occupancy:", @@ -476,6 +488,8 @@ export const en: Catalog = { capacityPlaceholder: "e.g. 120", printExitDefault: "Print exit ticket by default", 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)", save: "Save", saved: "Saved.", @@ -659,6 +673,8 @@ export const en: Catalog = { plan: "Plan", prepaid: "PREPAID", 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).", voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.", // payment receipt (transparency slip) diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 2f5995b..14c8684 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -398,6 +398,8 @@ export const sq = { perWeek: "javë", perMonth: "muaj", plan: "Plani", + quantity: "Makina", + quantityHint: "makina të mbuluara nga ky abonim (çmimi ×N)", planNone: "— pa pagesë / falas —", planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.", quoting: "duke llogaritur…", @@ -477,6 +479,16 @@ export const sq = { needPrice: "Shkruaj një çmim më të madh se zero.", saved: "Plani u ruajt.", 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: { occupancy: "Prania:", @@ -487,6 +499,8 @@ export const sq = { capacityPlaceholder: "p.sh. 120", printExitDefault: "Printo biletën e daljes si parazgjedhje", 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)", save: "Ruaj", saved: "U ruajt.", @@ -673,6 +687,8 @@ export const sq = { plan: "Plani", prepaid: "I PARAPAGUAR", 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).", voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.", // payment receipt (transparency slip) diff --git a/packages/db/drizzle/0011_subscription_plan_v2.sql b/packages/db/drizzle/0011_subscription_plan_v2.sql new file mode 100644 index 0000000..9007b41 --- /dev/null +++ b/packages/db/drizzle/0011_subscription_plan_v2.sql @@ -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; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4edd4f3..24a3160 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1781885300000, "tag": "0010_subscription_plans", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1781885400000, + "tag": "0011_subscription_plan_v2", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 9c83d3e..abc5992 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -218,6 +218,14 @@ export const siteConfig = sqliteTable("site_config", { * own price and may differ. null = no site default set. See * wiki/entities/subscription.md. */ 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 * 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 @@ -295,6 +303,11 @@ export const subscriptionPlans = sqliteTable("subscription_plans", { currency: text("currency").notNull(), // Latest version with effectiveFrom ≤ the sale instant prices the sale. 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>(), // Soft-retire (0) without deleting history; active=1 plans are sellable. active: integer("active", { mode: "boolean" }).notNull().default(true), createdBy: text("created_by"), @@ -321,8 +334,11 @@ export const subscriptions = sqliteTable("subscriptions", { // tariffVersionId. planId: text("plan_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. - // null = unbound. Default 1. + // null = unbound. Defaults to `quantity` at sale. maxConcurrent: integer("max_concurrent").default(1), validFrom: text("valid_from"), validTo: text("valid_to"), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6eea2e3..7342dea 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -68,6 +68,29 @@ export const ADMIN_ROLE_ID = "admin"; export type 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 * effectiveFrom ≤ sale instant prices a sale — the tariff-version pattern). The * operator SELLS from this catalog; they never type a price. */ @@ -80,6 +103,8 @@ export interface SubscriptionPlan { readonly currency: string; readonly effectiveFrom: string; readonly active: boolean; + /** Allowed-time windows (tariff bridge). null/absent = 24/7, no time charge. */ + readonly timeframes?: PlanTimeframes | null; readonly createdBy?: string | null; readonly createdAt?: string; } @@ -302,6 +327,9 @@ export const REASON_CODES = [ "sub.refused.outOfWindow", "sub.refused.noSession", "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; export type ReasonCode = (typeof REASON_CODES)[number]; @@ -329,6 +357,7 @@ export const REASON_EN: Record = { "sub.refused.outOfWindow": "subscription refused — {status}/out-of-window", "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.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). */ function hourToMin(hhmm: string): number { const m = /^(\d{2}):(\d{2})$/.exec(hhmm); diff --git a/packages/shared/src/subscription-window.test.ts b/packages/shared/src/subscription-window.test.ts new file mode 100644 index 0000000..dae967e --- /dev/null +++ b/packages/shared/src/subscription-window.test.ts @@ -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(); + }); +}); diff --git a/wiki/concepts/capacity-occupancy.md b/wiki/concepts/capacity-occupancy.md index d4161da..7677890 100644 --- a/wiki/concepts/capacity-occupancy.md +++ b/wiki/concepts/capacity-occupancy.md @@ -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, 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 Refusing at capacity is the **default**, not an absolute. An operator may opt into diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md index 4ec3a85..d64e59b 100644 --- a/wiki/concepts/tariff.md +++ b/wiki/concepts/tariff.md @@ -20,6 +20,12 @@ beyond the host). > 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). +> **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 > 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; diff --git a/wiki/entities/subscription.md b/wiki/entities/subscription.md index 5c79176..6098f1a 100644 --- a/wiki/entities/subscription.md +++ b/wiki/entities/subscription.md @@ -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. - 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 Selling/renewing a subscription is a **financial transaction a common operator makes during their diff --git a/wiki/log.md b/wiki/log.md index e3c5d9d..f78c62e 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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) + [[tariff]] (shared versioned-config pattern). The site default price column is kept only to seed the 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]].