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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<string, Acc>();
|
||||
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
|
||||
|
||||
@@ -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<void> {
|
||||
// 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).
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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<FastifyInsta
|
||||
});
|
||||
app.addHook("onClose", async () => 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<FastifyInsta
|
||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||
await tariffRoutes(app, db);
|
||||
|
||||
// Subscription admin CRUD. See wiki/entities/subscription.md.
|
||||
await subscriptionRoutes(app, db);
|
||||
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
||||
// wiki/entities/subscription.md.
|
||||
await subscriptionRoutes(app, db, credentialCapture);
|
||||
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
eq,
|
||||
ledgerEvents,
|
||||
@@ -26,18 +27,24 @@ import { snapshotAsync } from "./snapshot.js";
|
||||
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
|
||||
// too (card/QR OR plate). When unset, any car may use the subscription's card/QR.
|
||||
//
|
||||
// Direction is inferred from session state for THAT car (the read credential value
|
||||
// is the per-car session key): no open session → ENTRY; open session → EXIT. So a
|
||||
// fleet subscription can have several cars in at once, each its own session, and
|
||||
// anti-passback falls out (a second "entry" on a car already in becomes its exit).
|
||||
// Direction is inferred from the SUBSCRIPTION's open-session state, NOT the specific
|
||||
// credential read — so ANY of a subscription's credentials (QR / RFID / NFC / plate)
|
||||
// may open or close a session. Entry mints a fresh per-occurrence session id (the
|
||||
// ledger `identity`); a read with no open occurrence → ENTRY; with ≥1 open → EXIT the
|
||||
// OLDEST open occurrence (FIFO). A fleet (maxConcurrent > 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<string, number>();
|
||||
const firstIndex = new Map<string, number>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user