feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling

Rounds out subscriptions across enrollment, the barrier flow, and the booth.

- RFID credentials enabled with a "Read card" enrollment flow: the operator
  arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
  reader's next read is captured into the form and NOT dispatched to the access
  flow — the OTHER reader keeps serving live entry/exit. Routes:
  /api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
  per-occurrence id (SUBSESS-<short>), not the credential value, with
  permitId in the payload. Direction is decided by the barrier the reader sits
  at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
  admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
  pay/exit modal shows a subscription mode (snapshots + a single audited
  Open-barrier action) to assist a faulty exit reader / missing card;
  reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
  "abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
  verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.

Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 16:26:48 +02:00
parent bba988c4e8
commit b8ddda86e7
16 changed files with 663 additions and 143 deletions
+49 -2
View File
@@ -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<void> {
export async function subscriptionRoutes(
app: FastifyInstance,
db: Db,
capture: CredentialCapture,
): Promise<void> {
// 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 ?? {};