import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db"; import { NoPrinterAvailableError } from "@parking/devices"; import { BOOTH_TILL, type SubscriptionPlan, type SubscriptionQuote, type Tender } from "@parking/shared"; import { requirePermission, roleHasPermissions } from "../auth.js"; import { softDelete } from "../recycle-bin.js"; import { invalidateHolder } from "../event-enrich.js"; import { printSubscriptionCard } from "../booth-print.js"; import type { CredentialCapture } from "../credential-capture.js"; import type { EventLog } from "../event-log.js"; import type { ShiftService } from "../shift-service.js"; import { directionOf } from "../device-resolve.js"; import { priceSubscriptionSpan, resolvePlanVersion } from "../subscription-pricing.js"; // Subscription admin CRUD. A subscription is mutable master data — admins // grant/edit/revoke — but every USE of it is a signed ledger event, so the audit // trail stays append-only (see wiki/entities/subscription.md). A subscription is an // aggregate: the row + its credentials (card/QR) + its bound plates. The API treats // them as one unit (create/update replace the child sets; delete removes all). // // Pricing & THE SALE. priceMinor + period ("monthly") + currency record the recurring // plan (e.g. 10,000 ALL / month). When a subscription is SOLD (created with a price), // the operator collects real money — so we append a SIGNED `payment` ledger event for // the amount actually taken (priceMinor × months for a multi-month prepay), with the // tender the operator chose. That is the ONLY accountability mechanism: without it the // sale leaves no trace in the live feed, the drawer, or the shift Z-report, and the // operator could pocket the cash untraceably (the exact booth-operator-as-adversary // gap this system exists to close). The `subscriptions` row is mutable master data and // is NOT the financial record; the signed payment event is. See wiki/concepts/shift.md. interface Credential { kind: "rf" | "qr"; /** For RF: the physical card/tag id (required). For QR: optional — left blank, the * server AUTO-GENERATES an unguessable code (the customer never picks it). */ value?: string; } interface SubscriptionBody { holderName?: string; contact?: string; /** PRICED SALE: the plan the operator selected. The price is LOOKED UP from the * plan version (periods × per-period price) — the operator never types an amount. * Omit for a free/comp subscription (no plan, no charge). */ planId?: string | null; /** Coverage window. For a priced sale: `validFrom` defaults to now, `validTo` is * REQUIRED (the span priced against the plan). For a comp sub, both optional. */ validFrom?: string | null; validTo?: string | null; /** 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[]; /** Plate binding (optional): bound plates that also serve as identity. */ plates?: string[]; /** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a * plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */ tender?: Tender; /** UPDATE-only CORRECTION: move this sub to a different VERSION of its SAME plan (e.g. * an admin published v2 with different timeframes and wants an existing subscriber on * it, or back on v1). Must be a version of the sub's existing planId; price/currency/ * period stay FROZEN (not a re-sale — only the access rules change going forward). * Gated on `subscription:plan` (plan-management, stronger than subscription:update); * ignored from a non-privileged caller. See wiki/entities/subscription.md. */ planVersionId?: string; } /** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */ interface QuoteBody { planId?: string; validFrom?: string; validTo?: string; quantity?: number; } /** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader * delivers the full string over TCP/IP (the host-in-the-loop path), so length is * free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */ function newQrCode(): string { const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const bytes = randomBytes(15); let out = ""; for (const b of bytes) out += alphabet[b % 32]; return `SUB-${out}`; } export async function subscriptionRoutes( app: FastifyInstance, db: Db, capture: CredentialCapture, eventLog: EventLog, shift: ShiftService, ): Promise { // Reading/looking up subscriptions vs. managing them. Revoke folds into update. const readGuard = requirePermission("subscription:read"); const createGuard = requirePermission("subscription:create"); const updateGuard = requirePermission("subscription:update"); const deleteGuard = requirePermission("subscription:delete"); // Validate the body; returns problems (empty = ok). Shared by create + update. function validate(b: SubscriptionBody): string[] { const errs: string[] = []; if (b.maxConcurrent != null) { if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) { errs.push("maxConcurrent must be a positive integer, or null for unbound"); } } // PRICED SALE: a plan is selected → the span must be valid and price > 0. The // amount is derived from the plan (operator never types it), so there's no // priceMinor to validate. if (b.planId != null && b.planId.trim()) { const from = b.validFrom?.trim() || new Date().toISOString(); const to = b.validTo?.trim(); if (!to) { errs.push("validTo (end date) is required when selling a plan"); } else if (Number.isNaN(Date.parse(to)) || Number.isNaN(Date.parse(from))) { errs.push("validFrom/validTo must be valid ISO-8601 dates"); } else if (Date.parse(to) <= Date.parse(from)) { errs.push("validTo must be after validFrom"); } else { // Resolve the plan version at the SALE instant (now) — the customer buys today's // published plan/price. (validFrom is the coverage start, which may be midnight // today and predate a plan published this afternoon.) const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString()); 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"); } if (b.tender != null && b.tender !== "cash" && b.tender !== "card") { errs.push("tender must be cash|card"); } for (const c of b.credentials ?? []) { if (c.kind !== "rf" && c.kind !== "qr") { errs.push("each credential needs kind (rf|qr)"); break; } // RF must carry the physical card id; QR may be blank (server auto-generates). if (c.kind === "rf" && !c.value?.trim()) { errs.push("an RF credential needs a non-empty value (the card/tag id)"); break; } } if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) { errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)"); } return errs; } function loadAggregate(id: string) { const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get(); if (!sub) return null; const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all(); const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all(); return { ...sub, credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })), plates: plates.map((p) => p.plate), }; } /** Is this credential value already used by ANY subscription? (Global uniqueness — * a value is the lane identity, so it must resolve to one subscription.) */ function valueTaken(value: string): boolean { return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null; } /** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */ function mintQrCode(): string { for (let i = 0; i < 5; i += 1) { const code = newQrCode(); if (!valueTaken(code)) return code; } throw new Error("could not mint a unique QR code"); } // Replace a subscription's child rows (credentials + plates) from the body. QR // credentials with no value are SERVER-GENERATED here (the customer never picks the // code). The generated value is returned via loadAggregate so the UI can print it. function writeChildren(id: string, b: SubscriptionBody) { db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run(); db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run(); for (const c of b.credentials ?? []) { const supplied = c.value?.trim(); // QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a // QR being preserved on edit). const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : ""; if (!value) continue; // guarded by validate(); defensive db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run(); } for (const p of b.plates ?? []) { if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run(); } } /** Resolve the coverage end: an explicit validTo (the span end the operator picked). * Falls back to the existing value on an update that doesn't touch it. */ function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null { if (b.validTo !== undefined) return b.validTo ?? null; return fallback; } /** 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(); // Plan version is resolved at the SALE instant (now), not validFrom (which is the // coverage start and may predate a plan published later today). const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString()); if (!plan) return null; 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 LIVE subscriptions (with their credentials + plates). Soft-deleted ones // live in the recycle bin, not here. app.get("/api/subscriptions", { preHandler: readGuard }, async () => { const rows = db.select().from(subscriptions).where(isNull(subscriptions.deletedAt)).all(); return { subscriptions: rows.map((r) => loadAggregate(r.id)) }; }); // --- Credential capture ("enroll a card") ------------------------------- // The operator picks a reader and presents an RFID card to it; the next read on // that reader is captured for the form instead of opening a barrier. The OTHER // reader keeps serving the live flow. Single-shot + TTL. See credential-capture.ts. // The readers the operator can capture on (entry/exit by their bound relay). app.get("/api/subscriptions/readers", { preHandler: readGuard }, async () => { const rows = db.select().from(devices).where(eq(devices.category, "reader")).all(); return { readers: rows .filter((r) => r.enabled) .map((r) => ({ id: r.id, driverId: r.driverId, direction: directionOf(db, r) })), }; }); // Arm capture on a reader (by devices.id). Operator-or-admin (booth action). app.post<{ Body: { deviceId?: string } }>( "/api/subscriptions/capture/arm", { preHandler: readGuard }, async (req, reply) => { const deviceId = (req.body?.deviceId ?? "").trim(); if (!deviceId) return reply.code(400).send({ error: "deviceId required" }); const reader = db.select().from(devices).where(eq(devices.id, deviceId)).get(); if (!reader || reader.category !== "reader" || !reader.enabled) { return reply.code(404).send({ error: "no such enabled reader" }); } return capture.arm(deviceId); }, ); // Poll the capture state (idle | armed | captured | expired). The form polls this // and, on "captured", reads `value` into the credential field then clears it. app.get("/api/subscriptions/capture", { preHandler: readGuard }, async () => capture.state()); // Operator cancelled / closed the form — disarm and clear any result. app.post("/api/subscriptions/capture/cancel", { preHandler: readGuard }, async () => { capture.cancel(); capture.clear(); return { ok: true }; }); // Create a subscription. // Price a span against a plan WITHOUT writing anything — the live quote the sell form // shows ("3 nights · 2,400 ALL"). Server-computed so the operator can't fudge it. app.post<{ Body: QuoteBody }>("/api/subscriptions/quote", { preHandler: readGuard }, async (req, reply) => { const b = req.body ?? {}; if (!b.planId?.trim()) return reply.code(400).send({ error: "planId is required" }); const validFrom = b.validFrom?.trim() || new Date().toISOString(); const validTo = b.validTo?.trim(); if (!validTo) return reply.code(400).send({ error: "validTo is required" }); if (Number.isNaN(Date.parse(validFrom)) || Number.isNaN(Date.parse(validTo))) { return reply.code(400).send({ error: "validFrom/validTo must be valid ISO-8601 dates" }); } if (Date.parse(validTo) <= Date.parse(validFrom)) { return reply.code(400).send({ error: "validTo must be after validFrom" }); } // Resolve at the sale instant (now), not validFrom — see priceSale. const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString()); if (!plan) return reply.code(404).send({ error: "no active plan for that planId" }); 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) => { const b = req.body ?? {}; const problems = validate(b); if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems }); const id = randomUUID(); // Price is LOOKED UP from the chosen plan (periods × per-period price) — never typed // by the operator. A comp sub (no plan) carries no price. Persist the plan + version // so the sale reprices identically later. const priced = priceSale(b); db.insert(subscriptions) .values({ id, holderName: b.holderName ?? null, contact: b.contact ?? null, priceMinor: priced ? priced.quote.amountMinor : null, period: priced ? priced.plan.period : "month", currency: priced ? priced.quote.currency : null, planId: priced ? priced.plan.planId : null, planVersionId: priced ? priced.plan.id : null, 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", }) .run(); writeChildren(id, b); const sub = loadAggregate(id); // THE SALE: a priced subscription means the operator collected money. Append a // SIGNED `payment` event so the takings show up in the live feed, the drawer, and // the shift Z-report — never an untraceable cash grab. Best-effort wrt the response, // but the append is the whole point, so a failure is logged loudly. const sale = await recordSale(id, priced, b.tender, req.user?.username ?? "?"); // Auto-print the QR card so the operator can hand it to the customer. Best-effort: // a print failure NEVER fails the create (the subscription + its code are saved); // the response carries { printed, printError } so the UI can warn + offer reprint. const printResult = await tryPrintCard(sub); return reply.code(201).send({ ...sub, ...sale, ...printResult }); }); /** * Append the SIGNED `payment` ledger event for a subscription sale, so the money is * accounted for exactly like a parking payment (live feed + drawer + Z-report). The * amount comes from the PLAN quote (periods × per-period price) — never an * operator-typed number. No plan → no sale → nothing appended (free/comp). The event * carries `subscriptionSale: true` + the subscription id + the plan version so the * feed/audit can label it and the price is reproducible. We do NOT hard-require an * open shift (a subscription can be sold outside the booth money path), but the * operator IS recorded and the payment folds into whichever shift window contains its * timestamp — so it can never be silently pocketed. Returns { sale } or {}. */ async function recordSale( id: string, priced: ReturnType, tenderIn: Tender | undefined, operator: string, ): Promise<{ sale?: { amountMinor: number; currency: string | null; tender: Tender; periods: number; inShift: boolean } }> { if (!priced || priced.quote.amountMinor <= 0) return {}; // free/comp — nothing collected const { plan, quote } = priced; const amountMinor = quote.amountMinor; const tender: Tender = tenderIn ?? "cash"; const currency = quote.currency; const inShift = shift.currentOpenShift() != null; try { await eventLog.append({ type: "payment", source: "manual", // Key the payment to the subscription so the feed can resolve the holder label // and the audit can trace WHICH subscription was sold. identity: id, payload: { sessionRef: id, amountMinor, currency, tender, till: BOOTH_TILL, operator, // Flags this `payment` as a subscription SALE (not a parking payment) so the // live feed / activity log can label it distinctly. plan + periods for audit // and reproducible repricing. subscriptionSale: true, permitId: id, 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}×${priced.quantity}car) for ${id} by ${operator}` + (inShift ? "" : " [no open shift]"), ); } catch (err) { // A failed append is serious — the money would be untraceable. Surface it. app.log.error(`subscription-sale payment append FAILED for ${id}: ${(err as Error).message}`); return {}; } return { sale: { amountMinor, currency, tender, periods: quote.periods, inShift } }; } /** The first QR credential's code for a subscription aggregate, or null. */ function qrCodeOf(sub: ReturnType): string | null { const cred = sub?.credentials.find((c) => c.kind === "qr"); return cred?.value ?? null; } /** Best-effort print of a subscription's QR card. Returns a flag + optional error * (never throws). No QR credential → nothing to print (printed:false, no error). */ async function tryPrintCard( sub: ReturnType, ): Promise<{ printed: boolean; printedBy?: string; printError?: string }> { const code = qrCodeOf(sub); if (!sub || !code) return { printed: false }; try { const printedBy = await printSubscriptionCard( db, { code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo }, app.log, ); return { printed: true, printedBy }; } catch (err) { const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message; app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`); return { printed: false, printError }; } } // Update a subscription (replaces fields + child sets). app.put<{ Params: { id: string }; Body: SubscriptionBody }>( "/api/subscriptions/:id", { preHandler: updateGuard }, async (req, reply) => { const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get(); if (!existing) return reply.code(404).send({ error: "subscription not found" }); const b = req.body ?? {}; const problems = validate(b); if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems }); // PLAN-VERSION CORRECTION (opt-in, privileged). Move the sub to a different VERSION // of its SAME plan — e.g. an admin published v2 (different timeframes) and wants this // subscriber on it, or back on v1. Price/currency/period stay frozen (not a re-sale). // Guarded HERE on `subscription:plan` (stronger than the route's subscription:update), // so a plain operator's edit can't move a version; a non-privileged caller sending it // is rejected rather than silently ignored. let planVersionId = existing.planVersionId; if (b.planVersionId !== undefined && b.planVersionId !== existing.planVersionId) { if (!req.user || !roleHasPermissions(req.user.roleId, ["subscription:plan"])) { return reply.code(403).send({ error: "changing the plan version requires the subscription:plan permission" }); } const target = db .select() .from(subscriptionPlans) .where(eq(subscriptionPlans.id, b.planVersionId)) .get(); if (!target) return reply.code(404).send({ error: "plan version not found" }); // Must be a version of the SAME plan — this field corrects the version, never the // plan itself (a different plan = a different price basis = a re-sale). if (target.planId !== existing.planId) { return reply.code(400).send({ error: `plan version belongs to "${target.planId}", not this subscription's plan "${existing.planId}"`, }); } planVersionId = b.planVersionId; req.log.info( `subscription ${req.params.id} plan version ${existing.planVersionId} → ${b.planVersionId} (plan ${existing.planId}) by ${req.user.username ?? "?"}`, ); } // An update is otherwise a MASTER-DATA edit — it never re-sells or re-prices. Price, // plan and currency are FROZEN as the original sale recorded them (a new price means a // new sale = a new subscription). Editable here: holder/contact, car-count, the // validity window, status, credentials/plates, and (privileged) the plan version. db.update(subscriptions) .set({ holderName: b.holderName ?? null, contact: b.contact ?? null, maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent, validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null), validTo: resolveValidTo(b, existing.validTo), status: b.status ?? existing.status, planVersionId, }) .where(eq(subscriptions.id, req.params.id)) .run(); writeChildren(req.params.id, b); // The holder name may have changed — drop the feed-label cache for this sub. invalidateHolder(req.params.id); return loadAggregate(req.params.id); }, ); // Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the // customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if // the subscription is gone; 409 if it has no QR credential; 503 if no printer. app.post<{ Params: { id: string } }>( "/api/subscriptions/:id/print", { preHandler: readGuard }, async (req, reply) => { const sub = loadAggregate(req.params.id); if (!sub) return reply.code(404).send({ error: "subscription not found" }); const code = qrCodeOf(sub); if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" }); try { const printedBy = await printSubscriptionCard( db, { code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo }, app.log, ); return reply.code(200).send({ ok: true, printedBy }); } catch (err) { if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message }); return reply.code(500).send({ error: (err as Error).message }); } }, ); // Revoke (soft): the common case — keeps the subscription + its history, just bars // it. A revoked subscription fails the entry check (see subscription-flow.ts). Use // DELETE only to fully remove one created in error. app.post<{ Params: { id: string } }>( "/api/subscriptions/:id/revoke", { preHandler: updateGuard }, async (req, reply) => { const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run(); if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" }); return loadAggregate(req.params.id); }, ); // Delete a subscription — SOFT (recycle bin). The row + its credential/plate children // are KEPT (stamped deleted) so a restore brings the subscriber back intact; it leaves // the catalog and stops opening the barrier (the entry flow filters deleted). Past // ledger events that reference it are untouched (append-only). Restore/purge from the // recycle bin. (Distinct from /revoke, which BARS but keeps the subscriber visible.) app.delete<{ Params: { id: string } }>( "/api/subscriptions/:id", { preHandler: deleteGuard }, async (req, reply) => { const ok = softDelete(db, "subscription", req.params.id, req.user.sub); if (!ok) return reply.code(404).send({ error: "subscription not found" }); invalidateHolder(req.params.id); return reply.code(204).send(); }, ); }