diff --git a/apps/server/src/credential-capture.ts b/apps/server/src/credential-capture.ts new file mode 100644 index 0000000..d03b79a --- /dev/null +++ b/apps/server/src/credential-capture.ts @@ -0,0 +1,89 @@ +// Credential capture ("enroll a card"): lets an operator present a physical RFID +// card/chip (or a QR) to ONE chosen reader and have its value captured for a +// subscription credential, instead of typing it. SINGLE-SHOT + short TTL so the +// chosen reader is only "borrowed" for one read / a few seconds; the OTHER reader is +// never affected and keeps serving the live entry/exit flow. +// +// Flow: arm(deviceId) → the reader route checks tryConsume() on each read; the next +// read from that armed reader is captured (NOT dispatched to the access flow — the +// barrier must not open for a card being enrolled) and capture auto-disarms. The +// booth form polls result() until the value appears (or it times out / is cancelled). +// +// In-memory + single-site single-writer (one booth) → no DB, no cross-process +// concerns. See wiki/entities/subscription.md. + +const CAPTURE_TTL_MS = Number(process.env.CAPTURE_TTL_MS ?? 30_000); + +export type CaptureState = + | { status: "idle" } + | { status: "armed"; deviceId: string; armedAt: number; expiresAt: number } + | { status: "captured"; deviceId: string; value: string; capturedAt: number } + | { status: "expired"; deviceId: string }; + +export class CredentialCapture { + #armedDeviceId: string | null = null; + #expiresAt = 0; + #captured: { deviceId: string; value: string; capturedAt: number } | null = null; + #lastExpiredDeviceId: string | null = null; + + /** Arm a single-shot capture on one reader (by its `devices.id`). Replaces any + * prior arming (only one capture at a time). Clears a stale captured/expired + * result so the form starts fresh. */ + arm(deviceId: string): { expiresAt: number } { + this.#armedDeviceId = deviceId; + this.#expiresAt = Date.now() + CAPTURE_TTL_MS; + this.#captured = null; + this.#lastExpiredDeviceId = null; + return { expiresAt: this.#expiresAt }; + } + + /** Cancel any pending arming (operator closed the form / clicked cancel). */ + cancel(): void { + this.#armedDeviceId = null; + this.#expiresAt = 0; + } + + /** + * Called by the reader route on EVERY read. If this reader is the armed one (and + * not expired), capture the value, disarm, and return true → the caller must NOT + * dispatch this read to the access flow. Otherwise false → dispatch normally. + */ + tryConsume(deviceId: string, value: string): boolean { + if (this.#armedDeviceId == null) return false; + if (Date.now() > this.#expiresAt) { + // Window lapsed before a card was presented — disarm, mark expired. + this.#lastExpiredDeviceId = this.#armedDeviceId; + this.#armedDeviceId = null; + this.#expiresAt = 0; + return false; + } + if (deviceId !== this.#armedDeviceId) return false; // a read from the OTHER reader + if (!value) return false; + this.#captured = { deviceId, value, capturedAt: Date.now() }; + this.#armedDeviceId = null; // single-shot + this.#expiresAt = 0; + return true; + } + + /** Current state for the booth form's poll. Lazily transitions armed→expired. */ + state(): CaptureState { + if (this.#captured) return { status: "captured", ...this.#captured }; + if (this.#armedDeviceId != null) { + if (Date.now() > this.#expiresAt) { + this.#lastExpiredDeviceId = this.#armedDeviceId; + this.#armedDeviceId = null; + this.#expiresAt = 0; + return { status: "expired", deviceId: this.#lastExpiredDeviceId }; + } + return { status: "armed", deviceId: this.#armedDeviceId, armedAt: this.#expiresAt - CAPTURE_TTL_MS, expiresAt: this.#expiresAt }; + } + if (this.#lastExpiredDeviceId) return { status: "expired", deviceId: this.#lastExpiredDeviceId }; + return { status: "idle" }; + } + + /** Clear a consumed/expired result once the form has read it. */ + clear(): void { + this.#captured = null; + this.#lastExpiredDeviceId = null; + } +} diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 0f42903..2eb7375 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -30,6 +30,9 @@ interface SessionView { readonly enteredAt: string; readonly open: boolean; // no vehicle_exit yet readonly paidAt: string | null; // latest payment time, if any + /** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to + * exit / re-open without a `payment`. */ + readonly subscription: boolean; readonly graceExitMin: number | null; // from the payment's tariff context, if known // Within the FREE entry-grace window (a quick in-and-out that the tariff prices at // 0). When true the exit opens without a pay-station visit — we mint a $0 payment so @@ -188,10 +191,11 @@ export class ExitFlow { const view = this.#sessionFor(id); if (!view) return { ok: false, reason: "no session for ticket" }; - // No payment → no re-open. The barrier-open action is only for sessions that - // have been paid (or paid-then-exited within grace). An unpaid car takes the - // pay/exit flow instead — enforced here, not just in the UI. - if (view.paidAt == null) { + // Authorization to re-open: a PAID transient (paid, or paid-then-exited within + // grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must + // assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit + // flow instead — enforced here, not just in the UI (the no-unpaid-bypass rule). + if (view.paidAt == null && !view.subscription) { return { ok: false, reason: "session not paid — no barrier open without payment" }; } @@ -431,11 +435,15 @@ export class ExitFlow { } } + const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string }; + const subscription = entryPl.permit === true || entryPl.permitId != null; + return { identity, enteredAt: entry.occurredAt, open: !exited, paidAt, + subscription, graceExitMin, freeGrace, }; diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index 7f064bc..8fdf8aa 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -1,4 +1,4 @@ -import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db"; +import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db"; import { computeFee, type TariffStructure, type Tender } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import type { EventLog } from "./event-log.js"; @@ -53,6 +53,14 @@ export interface ActiveSession { readonly currency: string | null; readonly withinGrace: boolean; readonly graceExpiresAt: string | null; + /** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it + * with snapshots + an always-available "open barrier" (assist a faulty exit reader / + * missing card), and never a pay flow. See wiki/entities/subscription.md. */ + readonly subscription: boolean; + /** The subscription id (on-chain `permitId`), when `subscription` is true. */ + readonly subscriptionId: string | null; + /** The subscriber's holder name (for a friendly label instead of the raw key). */ + readonly subscriptionHolder: string | null; } /** Booth session view: everything the pay/exit modal needs in one read. */ @@ -72,6 +80,10 @@ export interface SessionLookup { readonly withinGrace: boolean; /** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */ readonly graceExpiresAt: string | null; + /** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */ + readonly subscription: boolean; + readonly subscriptionId: string | null; + readonly subscriptionHolder: string | null; } export class PayStation { @@ -167,8 +179,13 @@ export class PayStation { return { identity: id, found: false, open: false, enteredAt: null, exitedAt: null, paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null, + subscription: false, subscriptionId: null, subscriptionHolder: null, }; } + // Subscription occurrence? The entry payload carries permit:true + permitId. + const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string }; + const isSubscription = entryPl.permit === true || entryPl.permitId != null; + const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null; const exitRow = rows.find((r) => r.type === "vehicle_exit"); const open = !exitRow; @@ -185,10 +202,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. + // Amount owed now (best-effort; null if no tariff resolves). Only meaningful while + // open AND transient — a subscription is prepaid, never quoted/charged. let amountMinor: number | null = null; let currency: string | null = null; - if (open) { + if (open && !isSubscription) { try { const q = this.quote(id); amountMinor = q.amountMinor; @@ -202,6 +220,8 @@ export class PayStation { identity: id, found: true, open, enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null, paidAt, amountMinor, currency, withinGrace, graceExpiresAt, + subscription: isSubscription, subscriptionId, + subscriptionHolder: this.#holderOf(subscriptionId), }; } @@ -217,7 +237,14 @@ export class PayStation { const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); // Group the relevant events per identity in one pass. - type Acc = { enteredAt?: string; source: string | null; exitedAt?: string; paidAt?: string; graceExitMin?: number }; + type Acc = { + enteredAt?: string; + source: string | null; + exitedAt?: string; + paidAt?: string; + graceExitMin?: number; + subscriptionId?: string | null; + }; const byId = new Map(); for (const r of rows) { const id = r.identity; @@ -226,6 +253,10 @@ export class PayStation { const a = byId.get(id) ?? { source: r.source ?? null }; a.enteredAt = r.occurredAt; a.source = r.source ?? a.source; + // Subscription occurrence? The entry payload carries permit:true + permitId + // (the on-chain field). Mark it so the booth never tries to charge it. + const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string }; + if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null; byId.set(id, a); } else if (r.type === "vehicle_exit") { const a = byId.get(id); @@ -265,10 +296,13 @@ export class PayStation { if (!open && !withinGrace) continue; if (open && paid && graceExpiresAt != null && !withinGrace) continue; - // Amount owed now: only meaningful for an open + unpaid session. + const isSubscription = a.subscriptionId !== undefined; + + // Amount owed now: only meaningful for an open + unpaid TRANSIENT session. A + // subscription is prepaid — never quote/charge it. let amountMinor: number | null = null; let currency: string | null = null; - if (open && a.paidAt == null) { + if (open && a.paidAt == null && !isSubscription) { try { const q = this.quote(identity); amountMinor = q.amountMinor; @@ -289,6 +323,9 @@ export class PayStation { currency, withinGrace, graceExpiresAt, + subscription: isSubscription, + subscriptionId: a.subscriptionId ?? null, + subscriptionHolder: this.#holderOf(a.subscriptionId ?? null), }); } @@ -297,6 +334,18 @@ export class PayStation { return out; } + /** 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 { + if (!subscriptionId) return null; + try { + const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get(); + return row?.holderName ?? null; + } catch { + return null; + } + } + /** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */ #openEntry(identity: string) { const rows = this.#db diff --git a/apps/server/src/routes/qr-reader.ts b/apps/server/src/routes/qr-reader.ts index 378caee..1077837 100644 --- a/apps/server/src/routes/qr-reader.ts +++ b/apps/server/src/routes/qr-reader.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"; import { eq, devices, type Db } from "@parking/db"; import type { DeviceReadEvent } from "../device-events.js"; import type { ReadDispatcher } from "../read-dispatch.js"; +import type { CredentialCapture } from "../credential-capture.js"; // GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our // host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on @@ -30,6 +31,7 @@ export async function qrReaderRoutes( app: FastifyInstance, db: Db, dispatcher: ReadDispatcher, + capture: CredentialCapture, ): Promise { // Resolve the lane_devices row whose config.serial matches the reader's reported // serial (cjihao). The row id is a normal UUID; the serial is config the admin @@ -59,10 +61,19 @@ export async function qrReaderRoutes( // Map the reader's serial → its assigned lane_devices row id (the dispatcher // resolves the lane from that row). If unassigned, deviceId stays the serial so // the dispatcher simply finds no lane and rejects (status:0) — never crashes. - const deviceId = readerRowIdForSerial(serial) ?? serial; + const matchedRowId = readerRowIdForSerial(serial); + const deviceId = matchedRowId ?? serial; let accepted = false; if (cardid) { + // ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the + // value for the subscription form and do NOT run the access flow (we must not + // open a barrier for a card being enrolled). Single-shot — capture auto-disarms. + // Reads from the OTHER reader are untouched and dispatch normally below. + if (capture.tryConsume(deviceId, cardid)) { + app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`); + accepted = true; // beep "ok" so the operator knows the card was read + } else { const read: DeviceReadEvent = { driverId: "gee-qr-reader", deviceId, @@ -73,10 +84,18 @@ export async function qrReaderRoutes( try { const outcome = await dispatcher.dispatch(read); accepted = outcome.accepted; - if (!accepted) app.log.info(`QR ${cardid} rejected: ${outcome.reason ?? "?"}`); + // Per-read diagnostic: which reader (serial) sent it, which configured device + // it mapped to, and the verdict — so a barrier/serial mismatch is visible in + // the logs (e.g. an entry-side scan resolving to the exit relay). + app.log.info( + `READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` + + `card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` + + `${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`, + ); } catch (err) { app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`); } + } } // Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid). diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts index 6e19892..8d33740 100644 --- a/apps/server/src/routes/subscriptions.ts +++ b/apps/server/src/routes/subscriptions.ts @@ -1,9 +1,11 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { eq, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db"; +import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db"; import { NoPrinterAvailableError } from "@parking/devices"; import { requireRole } from "../auth.js"; import { printSubscriptionCard } from "../booth-print.js"; +import type { CredentialCapture } from "../credential-capture.js"; +import { directionOf } from "../device-resolve.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 @@ -64,7 +66,11 @@ function addMonths(iso: string, months: number): string { return d.toISOString(); } -export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise { +export async function subscriptionRoutes( + app: FastifyInstance, + db: Db, + capture: CredentialCapture, +): Promise { // Admin manages subscriptions; operator/cashier/readonly may LIST (to look one up). const readGuard = requireRole("admin", "operator", "cashier", "readonly"); const writeGuard = requireRole("admin"); @@ -175,6 +181,47 @@ export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise< 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. app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: writeGuard }, async (req, reply) => { const b = req.body ?? {}; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d70fd41..f893659 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -13,6 +13,7 @@ import { PayStation } from "./pay-station.js"; import { SubscriptionFlow } from "./subscription-flow.js"; import { ShiftService } from "./shift-service.js"; import { ReadDispatcher } from "./read-dispatch.js"; +import { CredentialCapture } from "./credential-capture.js"; import { PrinterMonitor } from "./printer-monitor.js"; import { DeviceMonitor } from "./device-monitor.js"; import { buildSigner, buildVerifier } from "./signer.js"; @@ -139,10 +140,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise unsubscribeRead()); + // Credential capture ("enroll a card"): lets the operator present an RFID card to a + // CHOSEN reader to populate a subscription credential, without blocking the other + // reader's live flow. Single-shot + TTL. See credential-capture.ts. + const credentialCapture = new CredentialCapture(); + // GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON - // verdict (host-in-the-loop, synchronous). Routes the read through the dispatcher - // and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md. - await qrReaderRoutes(app, db, readDispatcher); + // verdict (host-in-the-loop, synchronous). The capture service can intercept a read + // on an armed reader for enrollment; otherwise the read routes through the + // dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md. + await qrReaderRoutes(app, db, readDispatcher, credentialCapture); // Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report // (sum payments by tender, print the Z-report). Constructed before the pay routes @@ -159,8 +166,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise 1) thus has several open +// occurrences at once; each read closes one. This decouples exit from the entry +// credential (you can enter with QR and leave with the card). // // NB: the SIGNED ledger payload still carries `permitId` (immutable history — see the -// schema note). The mutable master data / code is "subscription"; the on-chain field -// name is intentionally left as-is so historical events keep verifying. +// schema note); the per-occurrence `identity` is the session key. The mutable master +// data / code is "subscription"; the on-chain field name is left as-is so historical +// events keep verifying. export interface SubscriptionMatch { readonly subscriptionId: string; - /** The specific credential/plate value read — the per-car session key. */ + /** The specific credential/plate value read (for logging/anomalies). NOT the + * session key — sessions are keyed by subscription occurrence, so a different + * credential of the same subscription can close the session it opened. */ readonly carKey: string; readonly via: "card" | "qr" | "plate"; } @@ -105,61 +112,76 @@ export class SubscriptionFlow { return { accepted: false, reason }; } - // Direction: the car's open-session state is the natural verb (in→exit, out→entry). - // The barrier the car is at (resolved.direction) must AGREE — a car at an exit - // barrier that isn't inside (or at an entry barrier while already in) is a - // wrong-barrier / anti-passback signal, refused + logged. A "both" barrier follows - // the session state. - const carOpen = this.#carHasOpenSession(m.carKey); - const inferred: FlowDirection = carOpen ? "exit" : "entry"; - if (resolved.direction !== "both" && resolved.direction !== inferred) { - const reason = `subscription wrong barrier — ${resolved.direction} barrier but car would ${inferred}`; - await this.#reject(m, reason); - return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason }; - } + // Direction: the BARRIER the reader sits at decides the verb — an entry-lane read + // is an ENTRY, an exit-lane read is an EXIT. (The credential is decoupled from the + // session, so we can't and needn't infer from "which credential".) A "both" barrier + // has no physical side, so there we infer from state: open occurrence → exit, else + // entry. This is what lets a FLEET admit several cars (each entry-lane read is an + // entry) yet exit any of them with ANY credential (FIFO). + const open = this.#openOccurrences(m.subscriptionId); + const verb: FlowDirection = + resolved.direction === "entry" + ? "entry" + : resolved.direction === "exit" + ? "exit" + : open.length > 0 + ? "exit" + : "entry"; - if (carOpen) { - // EXIT: this car is already inside → the read is its exit. + const source = m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand"; + + if (verb === "exit") { + // EXIT: close the OLDEST open occurrence (FIFO). Its occurrence id is the session + // key; the credential just read may differ from the one that opened it. If the + // subscription has NOTHING open, an exit read is a no-op anti-passback signal. + const oldest = open[0]; + if (!oldest) { + const reason = "subscription exit with no open session (already out / never entered)"; + await this.#reject(m, reason); + return { accepted: false, direction: "exit", reason }; + } + const occurrenceId = oldest.identity; await this.#log.append({ type: "vehicle_exit", direction: "exit", - source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", - identity: m.carKey, - // `permitId` is the on-chain field name (immutable) — carries the subscription id. - payload: { sessionRef: m.carKey, permitId: m.subscriptionId }, + source, + identity: occurrenceId, + // `permitId` carries the subscription id; `via` records which credential left. + payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, via: m.via }, }); - await this.#open(resolved, "exit", m.carKey, "subscription exit"); - this.#closeCache(m.carKey); + await this.#open(resolved, "exit", occurrenceId, "subscription exit"); + this.#closeCache(occurrenceId); return { accepted: true, direction: "exit" }; } - // ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. - if (sub.maxConcurrent != null) { - const open = this.#subscriptionOpenCount(m.subscriptionId); - if (open >= sub.maxConcurrent) { - const reason = `subscription at capacity (${open}/${sub.maxConcurrent} cars in)`; - await this.#reject(m, reason); - return { accepted: false, direction: "entry", reason }; - } + // ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a + // fresh per-occurrence id so a fleet can have several open at once. + if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) { + const reason = `subscription at capacity (${open.length}/${sub.maxConcurrent} cars in)`; + await this.#reject(m, reason); + return { accepted: false, direction: "entry", reason }; } + // 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)}`; await this.#log.append({ type: "vehicle_entry", direction: "entry", - source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", - identity: m.carKey, + 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: m.carKey, permitId: m.subscriptionId, permit: true }, + payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via }, occurredAt: now, }); - await this.#open(resolved, "entry", m.carKey, "subscription entry"); + await this.#open(resolved, "entry", occurrenceId, "subscription entry"); try { this.#db .insert(sessions) .values({ - id: m.carKey, - identity: m.carKey, + id: occurrenceId, + identity: occurrenceId, source: m.via === "plate" ? "lpr" : "wiegand", subscriptionId: m.subscriptionId, enteredAt: now, @@ -167,38 +189,40 @@ export class SubscriptionFlow { }) .run(); } catch (err) { - this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`); + this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`); } return { accepted: true, direction: "entry" }; } - /** Does this specific car (credential value) have an open session right now? */ - #carHasOpenSession(carKey: string): boolean { - const rows = this.#db - .select() - .from(ledgerEvents) - .where(eq(ledgerEvents.identity, carKey)) - .orderBy(ledgerEvents.index) - .all(); - const entries = rows.filter((r) => r.type === "vehicle_entry").length; - const exits = rows.filter((r) => r.type === "vehicle_exit").length; - return entries > exits; - } - - /** How many of this subscription's cars are inside right now (fold over the ledger). - * The on-chain field is `permitId`, so we match against that. */ - #subscriptionOpenCount(subscriptionId: string): number { - const rows = this.#db - .select() - .from(ledgerEvents) - .where(eq(ledgerEvents.type, "vehicle_entry")) - .all() - .filter((r) => (r.payload as { permitId?: string } | null)?.permitId === subscriptionId); - let open = 0; - for (const entry of rows) { - if (!this.#carHasOpenSession(entry.identity ?? "")) continue; - open += 1; + /** + * The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a + * fold over the signed ledger. An occurrence is a `vehicle_entry` (whose + * `payload.permitId` is this subscription) with no later `vehicle_exit` on the same + * `identity`. Used to (a) infer entry vs. exit for ANY credential of the + * subscription, (b) pick which occurrence a read closes, and (c) enforce + * `maxConcurrent`. The on-chain field is `permitId`, so we match against that. + */ + #openOccurrences(subscriptionId: string): { identity: string; index: number }[] { + const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); + // Net entries−exits per occurrence identity, keeping the entry order (oldest first). + const net = new Map(); + const firstIndex = new Map(); + for (const r of rows) { + const id = r.identity; + if (!id) continue; + const pl = (r.payload ?? {}) as { permitId?: string }; + if (r.type === "vehicle_entry") { + if (pl.permitId !== subscriptionId) continue; + net.set(id, (net.get(id) ?? 0) + 1); + if (!firstIndex.has(id)) firstIndex.set(id, r.index); + } else if (r.type === "vehicle_exit") { + if (!net.has(id)) continue; // not one of this subscription's occurrences + net.set(id, (net.get(id) ?? 0) - 1); + } } + const open: { identity: string; index: number }[] = []; + for (const [id, n] of net) if (n > 0) open.push({ identity: id, index: firstIndex.get(id) ?? 0 }); + open.sort((a, b) => a.index - b.index); // oldest first → FIFO return open; } diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index e093df6..1eb6e6d 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -17,6 +17,7 @@ import { Panel } from "./ui/Panel.js"; // See wiki/concepts/booth-exit-flow.md. function statusBadge(s: ActiveSession): { key: string; cls: string } { + if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" }; if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" }; if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" }; return { key: "booth.badgeUnpaid", cls: "text-term-amber" }; @@ -90,7 +91,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void className="flex flex-1 items-center gap-3 text-left hover:text-term-amber" title={t("booth.openPayExit")} > - {s.identity} + + {s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity} + {t("booth.inAt")} {formatTime(s.enteredAt)} @@ -98,8 +101,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void {t(badge.key)} - {/* Open barrier — PAID sessions only (no payment, no button). */} - {s.paidAt ? ( + {/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An + unpaid transient has no button (no-unpaid-bypass). */} + {s.paidAt || s.subscription ? ( - + ) : ( + + ? t("pay.printingVoucher") + : t("pay.opening") + : alreadyPaid + ? voucher + ? t("pay.printVoucher") + : t("pay.openBarrier") + : voucher + ? t("pay.payAndVoucher") + : t("pay.payAndOpen")} + + )} )} diff --git a/apps/web/src/SubscriptionManager.tsx b/apps/web/src/SubscriptionManager.tsx index bf8a288..2492c5e 100644 --- a/apps/web/src/SubscriptionManager.tsx +++ b/apps/web/src/SubscriptionManager.tsx @@ -1,14 +1,19 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApiError, + armCapture, + cancelCapture, createSubscription, deleteSubscription, + fetchReaders, fetchSiteConfig, fetchSubscriptions, + pollCapture, printSubscription, revokeSubscription, updateSubscription, + type ReaderInfo, type Subscription, type SubscriptionCredential, type SubscriptionInput, @@ -123,6 +128,11 @@ export function SubscriptionManager() { const [editing, setEditing] = useState(null); const [form, setForm] = useState(() => emptyForm()); const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); + // Credential capture ("Read card"): which credential index is being captured, the + // reader picker list, and a live status line. null = no capture in progress. + const [capture, setCapture] = useState<{ credIndex: number; phase: "pick" | "waiting"; status?: string } | null>(null); + const [readers, setReaders] = useState([]); + const pollRef = useRef | null>(null); function reload() { fetchSubscriptions() @@ -203,6 +213,62 @@ export function SubscriptionManager() { setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) })); } + function clearPoll() { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + } + // Stop a capture in progress (cancel on the server + clear local state). + function stopCapture() { + clearPoll(); + void cancelCapture().catch(() => {}); + setCapture(null); + } + // "Read card" on credential i → load readers + show the picker. + async function startCapture(i: number) { + setMsg(null); + try { + const r = await fetchReaders(); + setReaders(r.readers); + setCapture({ credIndex: i, phase: "pick" }); + } catch (e) { + setMsg({ kind: "err", text: (e as Error).message }); + } + } + // Operator picked a reader → arm it and poll until captured / expired. + async function pickReader(deviceId: string) { + const cap = capture; + if (!cap) return; + try { + await armCapture(deviceId); + setCapture({ ...cap, phase: "waiting", status: t("subs.captureWaiting") }); + clearPoll(); + pollRef.current = setInterval(async () => { + try { + const st = await pollCapture(); + if (st.status === "captured") { + clearPoll(); + setCred(cap.credIndex, { value: st.value }); + void cancelCapture().catch(() => {}); // clear the server-side result + setCapture(null); + setMsg({ kind: "ok", text: t("subs.captured", { value: st.value }) }); + } else if (st.status === "expired" || st.status === "idle") { + clearPoll(); + setCapture({ ...cap, phase: "waiting", status: t("subs.captureTimeout") }); + } + } catch { + /* transient poll error — keep polling */ + } + }, 700); + } catch (e) { + setMsg({ kind: "err", text: (e as Error).message }); + setCapture(null); + } + } + // Stop polling if the form closes or the component unmounts. + useEffect(() => clearPoll, []); + // Live coverage preview: when months + validFrom are set, show the end date and // (if priced) the N×monthly total the operator should collect. const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0)); @@ -299,12 +365,11 @@ export function SubscriptionManager() {

{t("subs.credentials")}

{form.credentials.map((c, i) => (
- {/* Operator chooses the credential type. Only QR is live today; RFID - is shown disabled ("soon") so the choice is visible — the backend - already accepts both, so re-enabling RFID is just dropping `disabled`. */} + {/* Operator chooses the credential type: QR (auto-generated) or RFID + (read off a card via "Read card"). */} {c.kind === "qr" ? ( // QR codes are server-generated. Blank → "will be generated"; an @@ -315,12 +380,44 @@ export function SubscriptionManager() { {t("subs.qrAutoGen")} ) ) : ( - setCred(i, { value: e.target.value })} placeholder={t("subs.credentialValue")} style={{ flex: 1 }} /> + // RFID: the value is read off a physical card (or typed). "Read card" + // arms a chosen reader and fills the captured value. + setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} /> + )} + {c.kind === "rf" && ( + )}
))} + + {/* Capture panel: pick a reader, present the card; the captured value fills + the credential. The OTHER reader keeps serving the live flow. */} + {capture && ( +
+ {capture.phase === "pick" ? ( + <> +
{t("subs.captureChooseReader")}
+
+ {readers.length === 0 && {t("subs.captureNoReaders")}} + {readers.map((r) => ( + + ))} + +
+ + ) : ( +
+ {capture.status ?? t("subs.captureWaiting")} + +
+ )} +
+ )} +

{t("subs.needCredentialOrPlate")}

diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 429c8aa..c500d5b 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -337,6 +337,32 @@ export function createSubscription(body: SubscriptionInput): Promise { return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" }); } + +// --- Credential capture ("enroll a card" on a chosen reader) --------------- + +export interface ReaderInfo { + id: string; + driverId: string; + direction: "entry" | "exit" | "both"; +} +export type CaptureState = + | { status: "idle" } + | { status: "armed"; deviceId: string; armedAt: number; expiresAt: number } + | { status: "captured"; deviceId: string; value: string; capturedAt: number } + | { status: "expired"; deviceId: string }; + +export function fetchReaders(): Promise<{ readers: ReaderInfo[] }> { + return apiFetch("/api/subscriptions/readers"); +} +export function armCapture(deviceId: string): Promise<{ expiresAt: number }> { + return apiFetch("/api/subscriptions/capture/arm", { method: "POST", body: JSON.stringify({ deviceId }) }); +} +export function pollCapture(): Promise { + return apiFetch("/api/subscriptions/capture"); +} +export function cancelCapture(): Promise<{ ok: boolean }> { + return apiFetch("/api/subscriptions/capture/cancel", { method: "POST" }); +} export function updateSubscription(id: string, body: SubscriptionInput): Promise { return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) }); } @@ -481,6 +507,10 @@ export interface SessionLookup { currency: string | null; withinGrace: boolean; graceExpiresAt: string | null; + /** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */ + subscription: boolean; + subscriptionId: string | null; + subscriptionHolder: string | null; } /** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */ @@ -500,6 +530,10 @@ export interface ActiveSession { currency: string | null; withinGrace: boolean; graceExpiresAt: string | null; + /** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */ + subscription: boolean; + subscriptionId: string | null; + subscriptionHolder: string | null; } /** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */ diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index c57f3f2..b7dbea9 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -85,6 +85,7 @@ export const en: Catalog = { badgeExiting: "exiting", badgePaid: "paid", badgeUnpaid: "unpaid", + badgeSubscription: "subscription", evtEntry: "ENTRY", evtExit: "EXIT", evtPay: "PAY", @@ -156,6 +157,13 @@ export const en: Catalog = { credentialsCardQr: "Credentials (card / QR)", rfCardTag: "RF card/tag", rfCardTagSoon: "RF card/tag (soon)", + rfPlaceholder: "card number (or read the card)", + readCard: "Read card", + captureChooseReader: "Choose a reader, then present the card:", + captureNoReaders: "No readers configured.", + captureWaiting: "Present the card to the reader…", + captureTimeout: "Timed out with no card read. Try again.", + captured: "Card read: {{value}}", qr: "QR", qrAutoGen: "QR code is auto-generated on save", credentialValue: "credential value", @@ -268,6 +276,11 @@ export const en: Catalog = { lookingUp: "looking up…", paidBarrierOpened: "Paid — barrier opened. Car may exit.", paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.", + subscription: "SUBSCRIPTION", + plan: "Plan", + prepaid: "PREPAID", + subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.", + subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).", voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.", noSnapshots: "no snapshots", loadingSnapshots: "loading snapshots…", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index f19936b..28ab522 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -79,13 +79,14 @@ export const sq = { inAt: "në", openPayExit: "Hap pagesën / daljen", openBarrier: "Hap barrierën", - openBarrierTitle: "Hapje barriere me ndërhyrje njerëzore (e regjistruar)", + openBarrierTitle: "Hap barrierën manualisht", barrierOpened: "barriera u hap", openManually: "hape me dorë", // session row badges badgeExiting: "duke dalë", badgePaid: "paguar", badgeUnpaid: "papaguar", + badgeSubscription: "abonim", // event types (live feed labels) evtEntry: "HYRJE", evtExit: "DALJE", @@ -158,6 +159,13 @@ export const sq = { credentialsCardQr: "Kredencialet (kartë / QR)", rfCardTag: "Kartë/etiketë RF", rfCardTagSoon: "Kartë/etiketë RF (së shpejti)", + rfPlaceholder: "numri i kartës (ose lexo kartën)", + readCard: "Lexo kartën", + captureChooseReader: "Zgjidh lexuesin, pastaj afro kartën:", + captureNoReaders: "Asnjë lexues i konfiguruar.", + captureWaiting: "Afro kartën te lexuesi…", + captureTimeout: "Skadoi pa lexuar kartë. Provo sërish.", + captured: "Karta u lexua: {{value}}", qr: "QR", qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje", credentialValue: "vlera e kredencialit", @@ -270,6 +278,11 @@ export const sq = { lookingUp: "Duke kërkuar…", paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.", paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.", + subscription: "ABONIM", + 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ë.", + subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).", voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.", // snapshots noSnapshots: "asnjë foto", diff --git a/wiki/concepts/booth-exit-flow.md b/wiki/concepts/booth-exit-flow.md index da2a6d1..a98e005 100644 --- a/wiki/concepts/booth-exit-flow.md +++ b/wiki/concepts/booth-exit-flow.md @@ -95,12 +95,23 @@ For an active session, the operator can open the barrier as a **human interventi > case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any > paid session that still slips through. -**Guard — no payment, no button.** The "Open barrier" action is shown/active **only for sessions that -have a payment** (paid, or paid-and-exited-in-grace). An **unpaid** open session has **no barrier-open -affordance at all** — the row routes to the [[#operator-flow|pay/exit modal]] instead. The -no-unpaid-bypass rule is enforced structurally: the button simply does not exist for an unpaid car. -(A future reason-required *force exit* for genuine disputes would be a separately-audited path — see -Open.) +**Guard — paid OR subscription, else no button.** The "Open barrier" action is shown/active for a +session that **has a payment** (paid, or paid-and-exited-in-grace) **OR is a [[subscription]] +occurrence** (prepaid — the operator must be able to assist a subscriber when the exit reader / card +fails). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to +the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally +(server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force +exit* for genuine disputes would be a separately-audited path — see Open. + +### Subscription occurrences in the booth (built 2026-06-18) + +A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by +the **holder name**, not the raw `SUBSESS-…` key). Opening it shows the **pay/exit modal in +subscription mode**: entry/duration + **PREPAID** (no amount — it is **never quoted or charged**), +the entry/exit **snapshots**, and a single **Open barrier** action (the audited re-pulse). This is +exactly the assist path for a **faulty exit reader or a missing/forgotten card/QR**. The session +view (`lookup` / `activeSessions` in `pay-station.ts`) carries `subscription`, `subscriptionId`, +`subscriptionHolder`, derived from the entry payload's `permit:true` / `permitId`. This single mechanism covers both edge cases: a **damaged ticket / dead scanner** (find the still-open session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a diff --git a/wiki/entities/subscription.md b/wiki/entities/subscription.md index efb20c7..1254a94 100644 --- a/wiki/entities/subscription.md +++ b/wiki/entities/subscription.md @@ -104,10 +104,11 @@ LPR/ANPR plate identity** (the plate binding below): **`GS ( k`** (model-2, error-correction M) — added to the Rongta driver (`printSubscriptionCard`), no image/bitmap dependency (same approach as the Code128 ticket). - **RF tag / chip / card — selectable later, NOT live yet.** An RFID/proximity credential, read - **host-side** (reader → host → `pulseOpen`). The data model + backend **already accept `kind:'rf'`** - (no migration needed to enable it); only the UI constrains the operator to QR for now — the RFID - option is shown **disabled ("soon")** so the choice is visible. A Wiegand-out reader keeps a future - autonomous path open ([[entry-exit-readers]]); the [[dingtian-relay]] has no onboard card list. + **host-side** (reader → host → `pulseOpen`). **LIVE since 2026-06-18** — the operator selects RFID + and **reads the card off a physical reader** (see "Enrolling a card" below) rather than typing the + number. The GEE readers are combo QR + RFID (ID/IC/NFC), so the same device captures both. A + Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]); the + [[dingtian-relay]] has no onboard card list. - **Plate (LPR/ANPR) — NOT YET IMPLEMENTED.** When plate-bound (below), a matching plate read is an accepted identity too. The vision/ANPR service that produces plate reads is future work ([[opencv-anpr-service]] / [[lpr-camera]]); until it exists, plate binding has no live source. @@ -115,6 +116,41 @@ LPR/ANPR plate identity** (the plate binding below): Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource` already in the model) and whose value is the credential id. +### Enrolling a card — "Read card" capture (built 2026-06-18) + +RFID values are awkward to type, so the operator **presents the physical card to a chosen reader** +and the system captures it into the credential. The catch is that the readers are also serving live +traffic, so capture must **borrow one reader briefly without blocking the other**: + +- **`CredentialCapture`** (in-memory, single-site): the operator picks a reader and **arms** it + (`POST /api/subscriptions/capture/arm {deviceId}`). It is **single-shot + a ~30 s TTL**. +- In the reader route (`qr-reader.ts`), each read first checks `tryConsume(deviceId, value)`: if + **this** reader is the armed one, the value is captured and the read is **NOT dispatched to the + access flow** (no barrier opens for a card being enrolled), then capture auto-disarms. A read on + **any other reader dispatches normally** — the live entry/exit flow on the other barrier is never + blocked. (Accepted trade: while armed, a real customer at the *armed* reader is captured instead of + admitted — kept tiny by single-shot + TTL.) +- The booth form **polls** `GET /api/subscriptions/capture` (idle | armed | captured | expired); + on `captured` it drops the value into the RFID field. `POST …/capture/cancel` disarms. +- Verified end-to-end (12/12): captured-not-dispatched (no ledger write), single-shot, the other + reader still drives a live `vehicle_exit` while armed, value retrievable, cancel/expiry. + +> The same mechanism would work to capture a **QR** too, but QR codes are server-generated + printed, +> so capture is RFID-only in practice (QR has nothing to read off a card). + +### Multiple credentials, and entry decoupled from exit (2026-06-18) + +A subscription is a one-to-many aggregate: it may hold **several credentials at once** — e.g. a QR +**and** an RFID card (and later NFC). Each is its own `subscription_credentials` row; any of them +resolves the same subscription at the barrier. (NFC works today as an `rf` credential on the combo +GEE reader; a distinct `nfc` `kind` is a small future labelling-only addition.) + +Crucially, **entry and exit are NOT bound to the same credential.** Originally the session was keyed +by the exact credential value read, so you had to leave with whatever you arrived with — an +*accidental* coupling. Now sessions are keyed by a **subscription occurrence** (`SUBSESS--`), +so you can **enter with the QR and exit with the card**. The mechanics (barrier-decides-direction, +FIFO close, fleet support) are in "As-built" below. + ## Two optional, independent bindings — confirmed 2026-06-15 A subscription has **two constraints the admin may or may not apply**, orthogonally. Either, both, or @@ -196,11 +232,21 @@ Tables (mutable master data; every *use* still produces a signed `vehicle_entry` (`read-dispatch.ts`): a credential read routes to the subscription flow if it **matches a subscription** (card/QR credential, or a bound plate) — otherwise to the transient exit flow. -- **Direction inferred from session state for that car** — no open session → ENTRY (check - `maxConcurrent`, sign `vehicle_entry`, open); an open session → EXIT (sign `vehicle_exit`, open, - close). A fleet has one session per car; anti-passback falls out. -- **`maxConcurrent`** enforced as a fold over the signed ledger (the on-chain `permitId` payload is - the match key). Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events. +- **Any credential opens/closes — sessions keyed by SUBSCRIPTION, not credential** (changed + 2026-06-18). A subscriber can **enter with their QR and exit with their RFID card** (or any mix). + Entry mints a fresh **per-occurrence** session id (`SUBSESS--`, the ledger `identity`) + with `payload.permitId = subId`; the credential read is decoupled from the session key. See "Entry + decoupled from exit" below. +- **Direction = the BARRIER the reader sits at.** An entry-lane read is an ENTRY, an exit-lane read + is an EXIT; a `"both"` barrier infers from open state (open occurrence → exit, else entry). This is + what lets a **fleet** (`maxConcurrent > 1`) admit several cars (each entry-lane read is an entry) + *and* exit any of them with any credential. +- **Exit closes the OLDEST open occurrence (FIFO).** Per-car identity within a fleet isn't tracked + (it never was, once credentials are shared) — a read closes one occurrence, oldest first. An exit + read with nothing open is a no-op anti-passback signal (signed `anomaly`). +- **`maxConcurrent`** enforced as a fold over the signed ledger by occurrence (`payload.permitId` + match). Refusals (revoked / out-of-window / at-capacity / exit-with-nothing-open) are signed + `anomaly` events. - **Admin CRUD** (`apps/server/src/routes/subscriptions.ts` + `apps/web/src/SubscriptionManager.tsx`): a subscription is an **aggregate** (row + credentials + bound plates + price). `GET /api/subscriptions` (any signed-in role — for lookup), `POST/PUT/DELETE /api/subscriptions[/:id]` + diff --git a/wiki/log.md b/wiki/log.md index 10ef13d..f96aa25 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -832,3 +832,15 @@ QR credentials are now AUTO-GENERATED server-side (`SUB-<15×base32>`, crypto-ra ## [2026-06-18] feat | Subscription QR card — printed on creation + reprint, real QR rendering The auto-generated subscription QR is now PRINTED so the operator can hand it to the customer. Added real 2D QR rendering to the [[rongta-printer]] driver via ESC/POS `GS ( k` (model 2, EC level M; firmware-rendered, no bitmap dep) — new `PrinterDevice.printSubscriptionCard(SubscriptionCardData)`; the card is park header → scannable QR of the code → code text (hand-key fallback) → holder + validity. Server: `printSubscriptionCard()` in booth-print.ts (booth-receipt printer, failover to dispenser); create AUTO-PRINTS best-effort (a print failure never fails the create — response returns `{printed, printError}`); new `POST /api/subscriptions/:id/print` reprint (operator-or-admin; 409 if no QR credential, 503 if no printer). Web: SubscriptionManager surfaces the print outcome on save and a "Print code" button per QR subscription; api types + i18n (sq/en). Verified on the wire via buildServer+inject + a TCP capture (9/9: auto-print, well-formed GS ( k QR bytes with the embedded code, reprint re-sends, no-QR→409). Updated [[subscription]] + [[rongta-printer]]. No migration. + +## [2026-06-18] feat | Subscription RFID enrollment — "Read card" capture on a chosen reader + +Enabled RFID subscription credentials with a card-enrollment flow. The operator picks a reader and presents the physical card; the value is captured into the credential instead of being typed. New in-memory `CredentialCapture` (single-shot + ~30s TTL): `arm(deviceId)`; `routes/qr-reader.ts` checks `tryConsume()` on each read — an armed reader's read is captured and NOT dispatched (no barrier for an enrolled card), then auto-disarms; reads on the OTHER reader dispatch normally, so its live entry/exit flow is never blocked. Routes (operator/admin): `GET /api/subscriptions/readers` (picker), `POST /capture/arm`, `GET /capture` (poll: idle|armed|captured|expired), `POST /capture/cancel`. Web: RFID re-enabled in the form (was disabled "soon"); "Read card" → reader picker → arm → poll → fills the value; i18n (sq/en). The GEE readers are combo QR+RFID (ID/IC/NFC), same endpoint, so one device captures both. Verified via buildServer+inject + reader-scan simulation (12/12: captured-not-dispatched, single-shot, other reader still drives a live vehicle_exit while armed, value retrievable, cancel). Updated [[subscription]]. No migration. + +## [2026-06-18] feat | Subscriptions — enter with one credential, exit with another (+ FIFO fleets) + +Decoupled subscription exit from the entry credential. Previously the session was keyed by the exact credential value read (an accidental coupling → must exit with the same QR/RFID you entered with). Now sessions are keyed by a per-occurrence id (`SUBSESS--`, the ledger `identity`; `payload.permitId`=subId), so ANY of a subscription's credentials (QR/RFID/NFC/plate) opens or closes. Direction is now decided by the BARRIER the reader sits at (entry-lane→entry, exit-lane→exit; a "both" barrier infers from open state) — this lets a FLEET (maxConcurrent>1) admit several cars (each entry-lane read is an entry) yet exit any of them with any credential; exit closes the OLDEST open occurrence (FIFO). Per-car identity within a fleet isn't tracked (never was once credentials are shared). `#openOccurrences()` replaced `#carHasOpenSession`/`#subscriptionOpenCount`. Exit with nothing open → signed anomaly (anti-passback). Verified 11/11 (enter-QR/exit-RFID + reverse, fleet 2-in mixed-credential FIFO out, capacity, anti-passback, chain intact). Updated [[subscription]] (multi-credential + entry-decoupled-from-exit). No migration. + +## [2026-06-18] fix | Subscription occurrences in the booth — prepaid, barrier-open assist (not transient) + +A subscription occurrence (SUBSESS-…) showed in Active Sessions but was wrongly treated as an unpaid transient: the modal tried to quote/charge it and the "open barrier" button only appeared for PAID sessions, so a subscriber with a faulty exit reader / missing card couldn't be assisted. Fix: `pay-station.ts` lookup/activeSessions now flag `subscription`/`subscriptionId`/`subscriptionHolder` from the entry payload (permit:true/permitId) and DON'T quote a subscription (amountMinor null). `exit-flow.ts` reopenBarrier now authorizes `paidAt != null || subscription` (prepaid). UI: the pay modal renders a SUBSCRIPTION mode (PREPAID badge, snapshots, single Open-barrier action, no tender/voucher) and the active row badges "abonim" + shows the holder name; both labelled by holder, not the raw key. Also SHORTENED the occurrence id (was SUBSESS--, ~80 chars) to `SUBSESS-<12hex>` — the subscriptionId lives in the payload (which every fold matches on), so it needn't be embedded in the key. Verified 9/9 (subscription flagged + not charged in lookup/active, reopen works without payment, unpaid-transient guard intact). Updated [[booth-exit-flow]]. No migration.