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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<span className="text-term-text">{s.identity}</span>
|
||||
<span className="text-term-text">
|
||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||
</span>
|
||||
<span className="text-term-muted">
|
||||
{t("booth.inAt")} {formatTime(s.enteredAt)}
|
||||
</span>
|
||||
@@ -98,8 +101,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
|
||||
</button>
|
||||
|
||||
{/* 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 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
openShift,
|
||||
paySession,
|
||||
printVoucher,
|
||||
reopenBarrier,
|
||||
type SessionLookup,
|
||||
} from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
@@ -48,7 +49,26 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
|
||||
|
||||
const alreadyPaid = s?.paidAt != null;
|
||||
const canPay = shiftReady && s?.found && s.open && !alreadyPaid;
|
||||
const isSubscription = s?.subscription === true;
|
||||
// A subscription is prepaid: never charged. The only booth action is an audited
|
||||
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
|
||||
const canPay = shiftReady && s?.found && s.open && !alreadyPaid && !isSubscription;
|
||||
|
||||
async function handleOpenBarrier() {
|
||||
if (!s) return;
|
||||
setError(null);
|
||||
setPhase("finishing");
|
||||
try {
|
||||
const r = await reopenBarrier(identity);
|
||||
setResult(r.opened ? t("pay.subBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }));
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||
setPhase("done");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setPhase("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenShift() {
|
||||
setOpeningShift(true);
|
||||
@@ -106,7 +126,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("pay.ticket")} {identity}
|
||||
{isSubscription
|
||||
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
||||
: `${t("pay.ticket")} ${identity}`}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
|
||||
✕
|
||||
@@ -173,27 +195,38 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.statusLabel")}
|
||||
value={alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
||||
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
|
||||
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
||||
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
{/* Total — a subscription is prepaid (no amount); show a badge. */}
|
||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.total")}</span>
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{isSubscription ? t("pay.plan") : t("pay.total")}
|
||||
</span>
|
||||
<span className="text-3xl font-bold text-term-cyan">
|
||||
{s.amountMinor != null && s.currency
|
||||
? formatMoney(s.amountMinor, s.currency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
{isSubscription
|
||||
? t("pay.prepaid")
|
||||
: s.amountMinor != null && s.currency
|
||||
? formatMoney(s.amountMinor, s.currency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* For a subscription, explain the only available action. */}
|
||||
{isSubscription && (
|
||||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
|
||||
{t("pay.subAssistHint")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshots */}
|
||||
<SnapshotStrip identity={identity} />
|
||||
|
||||
{phase !== "done" && (
|
||||
{phase !== "done" && !isSubscription && (
|
||||
<>
|
||||
{/* Tender */}
|
||||
{canPay && (
|
||||
@@ -253,26 +286,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{phase === "paying"
|
||||
? t("pay.takingPayment")
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? t("pay.printingVoucher")
|
||||
: t("pay.opening")
|
||||
: alreadyPaid
|
||||
{isSubscription ? (
|
||||
// Prepaid — the only action is the audited barrier open (assist
|
||||
// a faulty exit reader / missing card). Gated on an open shift.
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenBarrier}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="rounded-term border border-term-cyan bg-term-cyan/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-cyan disabled:opacity-50"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{phase === "paying"
|
||||
? t("pay.takingPayment")
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? t("pay.printVoucher")
|
||||
: t("pay.openBarrier")
|
||||
: voucher
|
||||
? t("pay.payAndVoucher")
|
||||
: t("pay.payAndOpen")}
|
||||
</button>
|
||||
? t("pay.printingVoucher")
|
||||
: t("pay.opening")
|
||||
: alreadyPaid
|
||||
? voucher
|
||||
? t("pay.printVoucher")
|
||||
: t("pay.openBarrier")
|
||||
: voucher
|
||||
? t("pay.payAndVoucher")
|
||||
: t("pay.payAndOpen")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string | "new" | null>(null);
|
||||
const [form, setForm] = useState<FormState>(() => 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<ReaderInfo[]>([]);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | 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() {
|
||||
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||
{/* 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"). */}
|
||||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||
<option value="qr">{t("subs.qr")}</option>
|
||||
<option value="rf" disabled>{t("subs.rfCardTagSoon")}</option>
|
||||
<option value="rf">{t("subs.rfCardTag")}</option>
|
||||
</select>
|
||||
{c.kind === "qr" ? (
|
||||
// QR codes are server-generated. Blank → "will be generated"; an
|
||||
@@ -315,12 +380,44 @@ export function SubscriptionManager() {
|
||||
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span>
|
||||
)
|
||||
) : (
|
||||
<input value={c.value} onChange={(e) => 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.
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} />
|
||||
)}
|
||||
{c.kind === "rf" && (
|
||||
<button type="button" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
|
||||
|
||||
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||||
the credential. The OTHER reader keeps serving the live flow. */}
|
||||
{capture && (
|
||||
<div style={{ marginTop: "0.5rem", padding: "0.6rem 0.75rem", border: "1px solid #0a7", borderRadius: 6, background: "#f0fbf6" }}>
|
||||
{capture.phase === "pick" ? (
|
||||
<>
|
||||
<div style={{ marginBottom: "0.35rem" }}>{t("subs.captureChooseReader")}</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||||
{readers.length === 0 && <span style={{ color: "#a00" }}>{t("subs.captureNoReaders")}</span>}
|
||||
{readers.map((r) => (
|
||||
<button key={r.id} type="button" onClick={() => pickReader(r.id)}>
|
||||
{t(`devices.role.${r.direction}`)} ({r.driverId})
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<span>{capture.status ?? t("subs.captureWaiting")}</span>
|
||||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||
{t("subs.needCredentialOrPlate")}
|
||||
</p>
|
||||
|
||||
@@ -337,6 +337,32 @@ export function createSubscription(body: SubscriptionInput): Promise<Subscriptio
|
||||
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
|
||||
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<CaptureState> {
|
||||
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<Subscription> {
|
||||
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). */
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user