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 enteredAt: string;
|
||||||
readonly open: boolean; // no vehicle_exit yet
|
readonly open: boolean; // no vehicle_exit yet
|
||||||
readonly paidAt: string | null; // latest payment time, if any
|
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
|
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
|
// 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
|
// 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);
|
const view = this.#sessionFor(id);
|
||||||
if (!view) return { ok: false, reason: "no session for ticket" };
|
if (!view) return { ok: false, reason: "no session for ticket" };
|
||||||
// No payment → no re-open. The barrier-open action is only for sessions that
|
// Authorization to re-open: a PAID transient (paid, or paid-then-exited within
|
||||||
// have been paid (or paid-then-exited within grace). An unpaid car takes the
|
// grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must
|
||||||
// pay/exit flow instead — enforced here, not just in the UI.
|
// assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit
|
||||||
if (view.paidAt == null) {
|
// 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" };
|
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 {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
open: !exited,
|
open: !exited,
|
||||||
paidAt,
|
paidAt,
|
||||||
|
subscription,
|
||||||
graceExitMin,
|
graceExitMin,
|
||||||
freeGrace,
|
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 { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
@@ -53,6 +53,14 @@ export interface ActiveSession {
|
|||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
readonly withinGrace: boolean;
|
readonly withinGrace: boolean;
|
||||||
readonly graceExpiresAt: string | null;
|
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. */
|
/** Booth session view: everything the pay/exit modal needs in one read. */
|
||||||
@@ -72,6 +80,10 @@ export interface SessionLookup {
|
|||||||
readonly withinGrace: boolean;
|
readonly withinGrace: boolean;
|
||||||
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||||
readonly graceExpiresAt: string | null;
|
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 {
|
export class PayStation {
|
||||||
@@ -167,8 +179,13 @@ export class PayStation {
|
|||||||
return {
|
return {
|
||||||
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||||
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: 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 exitRow = rows.find((r) => r.type === "vehicle_exit");
|
||||||
const open = !exitRow;
|
const open = !exitRow;
|
||||||
|
|
||||||
@@ -185,10 +202,11 @@ export class PayStation {
|
|||||||
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
|
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
|
||||||
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
|
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 amountMinor: number | null = null;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
if (open) {
|
if (open && !isSubscription) {
|
||||||
try {
|
try {
|
||||||
const q = this.quote(id);
|
const q = this.quote(id);
|
||||||
amountMinor = q.amountMinor;
|
amountMinor = q.amountMinor;
|
||||||
@@ -202,6 +220,8 @@ export class PayStation {
|
|||||||
identity: id, found: true, open,
|
identity: id, found: true, open,
|
||||||
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||||
paidAt, amountMinor, currency, withinGrace, graceExpiresAt,
|
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();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
|
|
||||||
// Group the relevant events per identity in one pass.
|
// 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>();
|
const byId = new Map<string, Acc>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const id = r.identity;
|
const id = r.identity;
|
||||||
@@ -226,6 +253,10 @@ export class PayStation {
|
|||||||
const a = byId.get(id) ?? { source: r.source ?? null };
|
const a = byId.get(id) ?? { source: r.source ?? null };
|
||||||
a.enteredAt = r.occurredAt;
|
a.enteredAt = r.occurredAt;
|
||||||
a.source = r.source ?? a.source;
|
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);
|
byId.set(id, a);
|
||||||
} else if (r.type === "vehicle_exit") {
|
} else if (r.type === "vehicle_exit") {
|
||||||
const a = byId.get(id);
|
const a = byId.get(id);
|
||||||
@@ -265,10 +296,13 @@ export class PayStation {
|
|||||||
if (!open && !withinGrace) continue;
|
if (!open && !withinGrace) continue;
|
||||||
if (open && paid && graceExpiresAt != null && !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 amountMinor: number | null = null;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
if (open && a.paidAt == null) {
|
if (open && a.paidAt == null && !isSubscription) {
|
||||||
try {
|
try {
|
||||||
const q = this.quote(identity);
|
const q = this.quote(identity);
|
||||||
amountMinor = q.amountMinor;
|
amountMinor = q.amountMinor;
|
||||||
@@ -289,6 +323,9 @@ export class PayStation {
|
|||||||
currency,
|
currency,
|
||||||
withinGrace,
|
withinGrace,
|
||||||
graceExpiresAt,
|
graceExpiresAt,
|
||||||
|
subscription: isSubscription,
|
||||||
|
subscriptionId: a.subscriptionId ?? null,
|
||||||
|
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +334,18 @@ export class PayStation {
|
|||||||
return out;
|
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. */
|
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
||||||
#openEntry(identity: string) {
|
#openEntry(identity: string) {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { eq, devices, type Db } from "@parking/db";
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
import type { DeviceReadEvent } from "../device-events.js";
|
import type { DeviceReadEvent } from "../device-events.js";
|
||||||
import type { ReadDispatcher } from "../read-dispatch.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
|
// 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
|
// 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,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
dispatcher: ReadDispatcher,
|
dispatcher: ReadDispatcher,
|
||||||
|
capture: CredentialCapture,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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;
|
let accepted = false;
|
||||||
if (cardid) {
|
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 = {
|
const read: DeviceReadEvent = {
|
||||||
driverId: "gee-qr-reader",
|
driverId: "gee-qr-reader",
|
||||||
deviceId,
|
deviceId,
|
||||||
@@ -73,10 +84,18 @@ export async function qrReaderRoutes(
|
|||||||
try {
|
try {
|
||||||
const outcome = await dispatcher.dispatch(read);
|
const outcome = await dispatcher.dispatch(read);
|
||||||
accepted = outcome.accepted;
|
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) {
|
} catch (err) {
|
||||||
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
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).
|
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
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 { NoPrinterAvailableError } from "@parking/devices";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
import { printSubscriptionCard } from "../booth-print.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
|
// 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
|
// 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();
|
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).
|
// Admin manages subscriptions; operator/cashier/readonly may LIST (to look one up).
|
||||||
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
const writeGuard = requireRole("admin");
|
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)) };
|
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.
|
// Create a subscription.
|
||||||
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: writeGuard }, async (req, reply) => {
|
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: writeGuard }, async (req, reply) => {
|
||||||
const b = req.body ?? {};
|
const b = req.body ?? {};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PayStation } from "./pay-station.js";
|
|||||||
import { SubscriptionFlow } from "./subscription-flow.js";
|
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
import { ShiftService } from "./shift-service.js";
|
import { ShiftService } from "./shift-service.js";
|
||||||
import { ReadDispatcher } from "./read-dispatch.js";
|
import { ReadDispatcher } from "./read-dispatch.js";
|
||||||
|
import { CredentialCapture } from "./credential-capture.js";
|
||||||
import { PrinterMonitor } from "./printer-monitor.js";
|
import { PrinterMonitor } from "./printer-monitor.js";
|
||||||
import { DeviceMonitor } from "./device-monitor.js";
|
import { DeviceMonitor } from "./device-monitor.js";
|
||||||
import { buildSigner, buildVerifier } from "./signer.js";
|
import { buildSigner, buildVerifier } from "./signer.js";
|
||||||
@@ -139,10 +140,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeRead());
|
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
|
// 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
|
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
||||||
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
// on an armed reader for enrollment; otherwise the read routes through the
|
||||||
await qrReaderRoutes(app, db, readDispatcher);
|
// 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
|
// 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
|
// (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.
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
await tariffRoutes(app, db);
|
await tariffRoutes(app, db);
|
||||||
|
|
||||||
// Subscription admin CRUD. See wiki/entities/subscription.md.
|
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
||||||
await subscriptionRoutes(app, db);
|
// wiki/entities/subscription.md.
|
||||||
|
await subscriptionRoutes(app, db, credentialCapture);
|
||||||
|
|
||||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||||
await shiftRoutes(app, shiftService);
|
await shiftRoutes(app, shiftService);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
eq,
|
eq,
|
||||||
ledgerEvents,
|
ledgerEvents,
|
||||||
@@ -26,18 +27,24 @@ import { snapshotAsync } from "./snapshot.js";
|
|||||||
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
|
// - 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.
|
// 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
|
// Direction is inferred from the SUBSCRIPTION's open-session state, NOT the specific
|
||||||
// is the per-car session key): no open session → ENTRY; open session → EXIT. So a
|
// credential read — so ANY of a subscription's credentials (QR / RFID / NFC / plate)
|
||||||
// fleet subscription can have several cars in at once, each its own session, and
|
// may open or close a session. Entry mints a fresh per-occurrence session id (the
|
||||||
// anti-passback falls out (a second "entry" on a car already in becomes its exit).
|
// 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
|
// 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
|
// schema note); the per-occurrence `identity` is the session key. The mutable master
|
||||||
// name is intentionally left as-is so historical events keep verifying.
|
// data / code is "subscription"; the on-chain field name is left as-is so historical
|
||||||
|
// events keep verifying.
|
||||||
|
|
||||||
export interface SubscriptionMatch {
|
export interface SubscriptionMatch {
|
||||||
readonly subscriptionId: string;
|
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 carKey: string;
|
||||||
readonly via: "card" | "qr" | "plate";
|
readonly via: "card" | "qr" | "plate";
|
||||||
}
|
}
|
||||||
@@ -105,61 +112,76 @@ export class SubscriptionFlow {
|
|||||||
return { accepted: false, reason };
|
return { accepted: false, reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction: the car's open-session state is the natural verb (in→exit, out→entry).
|
// Direction: the BARRIER the reader sits at decides the verb — an entry-lane read
|
||||||
// The barrier the car is at (resolved.direction) must AGREE — a car at an exit
|
// is an ENTRY, an exit-lane read is an EXIT. (The credential is decoupled from the
|
||||||
// barrier that isn't inside (or at an entry barrier while already in) is a
|
// session, so we can't and needn't infer from "which credential".) A "both" barrier
|
||||||
// wrong-barrier / anti-passback signal, refused + logged. A "both" barrier follows
|
// has no physical side, so there we infer from state: open occurrence → exit, else
|
||||||
// the session state.
|
// entry. This is what lets a FLEET admit several cars (each entry-lane read is an
|
||||||
const carOpen = this.#carHasOpenSession(m.carKey);
|
// entry) yet exit any of them with ANY credential (FIFO).
|
||||||
const inferred: FlowDirection = carOpen ? "exit" : "entry";
|
const open = this.#openOccurrences(m.subscriptionId);
|
||||||
if (resolved.direction !== "both" && resolved.direction !== inferred) {
|
const verb: FlowDirection =
|
||||||
const reason = `subscription wrong barrier — ${resolved.direction} barrier but car would ${inferred}`;
|
resolved.direction === "entry"
|
||||||
await this.#reject(m, reason);
|
? "entry"
|
||||||
return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason };
|
: resolved.direction === "exit"
|
||||||
}
|
? "exit"
|
||||||
|
: open.length > 0
|
||||||
|
? "exit"
|
||||||
|
: "entry";
|
||||||
|
|
||||||
if (carOpen) {
|
const source = m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand";
|
||||||
// EXIT: this car is already inside → the read is its exit.
|
|
||||||
|
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({
|
await this.#log.append({
|
||||||
type: "vehicle_exit",
|
type: "vehicle_exit",
|
||||||
direction: "exit",
|
direction: "exit",
|
||||||
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
source,
|
||||||
identity: m.carKey,
|
identity: occurrenceId,
|
||||||
// `permitId` is the on-chain field name (immutable) — carries the subscription id.
|
// `permitId` carries the subscription id; `via` records which credential left.
|
||||||
payload: { sessionRef: m.carKey, permitId: m.subscriptionId },
|
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, via: m.via },
|
||||||
});
|
});
|
||||||
await this.#open(resolved, "exit", m.carKey, "subscription exit");
|
await this.#open(resolved, "exit", occurrenceId, "subscription exit");
|
||||||
this.#closeCache(m.carKey);
|
this.#closeCache(occurrenceId);
|
||||||
return { accepted: true, direction: "exit" };
|
return { accepted: true, direction: "exit" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
|
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a
|
||||||
if (sub.maxConcurrent != null) {
|
// fresh per-occurrence id so a fleet can have several open at once.
|
||||||
const open = this.#subscriptionOpenCount(m.subscriptionId);
|
if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) {
|
||||||
if (open >= sub.maxConcurrent) {
|
const reason = `subscription at capacity (${open.length}/${sub.maxConcurrent} cars in)`;
|
||||||
const reason = `subscription at capacity (${open}/${sub.maxConcurrent} cars in)`;
|
await this.#reject(m, reason);
|
||||||
await this.#reject(m, reason);
|
return { accepted: false, direction: "entry", 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({
|
await this.#log.append({
|
||||||
type: "vehicle_entry",
|
type: "vehicle_entry",
|
||||||
direction: "entry",
|
direction: "entry",
|
||||||
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
source,
|
||||||
identity: m.carKey,
|
identity: occurrenceId,
|
||||||
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
|
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
|
||||||
// `permitId`/`permit` are the on-chain field names (immutable).
|
// `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,
|
occurredAt: now,
|
||||||
});
|
});
|
||||||
await this.#open(resolved, "entry", m.carKey, "subscription entry");
|
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
|
||||||
try {
|
try {
|
||||||
this.#db
|
this.#db
|
||||||
.insert(sessions)
|
.insert(sessions)
|
||||||
.values({
|
.values({
|
||||||
id: m.carKey,
|
id: occurrenceId,
|
||||||
identity: m.carKey,
|
identity: occurrenceId,
|
||||||
source: m.via === "plate" ? "lpr" : "wiegand",
|
source: m.via === "plate" ? "lpr" : "wiegand",
|
||||||
subscriptionId: m.subscriptionId,
|
subscriptionId: m.subscriptionId,
|
||||||
enteredAt: now,
|
enteredAt: now,
|
||||||
@@ -167,38 +189,40 @@ export class SubscriptionFlow {
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
} catch (err) {
|
} 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" };
|
return { accepted: true, direction: "entry" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this specific car (credential value) have an open session right now? */
|
/**
|
||||||
#carHasOpenSession(carKey: string): boolean {
|
* The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
|
||||||
const rows = this.#db
|
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose
|
||||||
.select()
|
* `payload.permitId` is this subscription) with no later `vehicle_exit` on the same
|
||||||
.from(ledgerEvents)
|
* `identity`. Used to (a) infer entry vs. exit for ANY credential of the
|
||||||
.where(eq(ledgerEvents.identity, carKey))
|
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
||||||
.orderBy(ledgerEvents.index)
|
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
||||||
.all();
|
*/
|
||||||
const entries = rows.filter((r) => r.type === "vehicle_entry").length;
|
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
||||||
const exits = rows.filter((r) => r.type === "vehicle_exit").length;
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
return entries > exits;
|
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
||||||
}
|
const net = new Map<string, number>();
|
||||||
|
const firstIndex = new Map<string, number>();
|
||||||
/** How many of this subscription's cars are inside right now (fold over the ledger).
|
for (const r of rows) {
|
||||||
* The on-chain field is `permitId`, so we match against that. */
|
const id = r.identity;
|
||||||
#subscriptionOpenCount(subscriptionId: string): number {
|
if (!id) continue;
|
||||||
const rows = this.#db
|
const pl = (r.payload ?? {}) as { permitId?: string };
|
||||||
.select()
|
if (r.type === "vehicle_entry") {
|
||||||
.from(ledgerEvents)
|
if (pl.permitId !== subscriptionId) continue;
|
||||||
.where(eq(ledgerEvents.type, "vehicle_entry"))
|
net.set(id, (net.get(id) ?? 0) + 1);
|
||||||
.all()
|
if (!firstIndex.has(id)) firstIndex.set(id, r.index);
|
||||||
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === subscriptionId);
|
} else if (r.type === "vehicle_exit") {
|
||||||
let open = 0;
|
if (!net.has(id)) continue; // not one of this subscription's occurrences
|
||||||
for (const entry of rows) {
|
net.set(id, (net.get(id) ?? 0) - 1);
|
||||||
if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
|
}
|
||||||
open += 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;
|
return open;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Panel } from "./ui/Panel.js";
|
|||||||
// See wiki/concepts/booth-exit-flow.md.
|
// See wiki/concepts/booth-exit-flow.md.
|
||||||
|
|
||||||
function statusBadge(s: ActiveSession): { key: string; cls: string } {
|
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.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
||||||
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
||||||
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
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"
|
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||||
title={t("booth.openPayExit")}
|
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">
|
<span className="text-term-muted">
|
||||||
{t("booth.inAt")} {formatTime(s.enteredAt)}
|
{t("booth.inAt")} {formatTime(s.enteredAt)}
|
||||||
</span>
|
</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>
|
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Open barrier — PAID sessions only (no payment, no button). */}
|
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An
|
||||||
{s.paidAt ? (
|
unpaid transient has no button (no-unpaid-bypass). */}
|
||||||
|
{s.paidAt || s.subscription ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={reopen.isPending || !shiftReady}
|
disabled={reopen.isPending || !shiftReady}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
openShift,
|
openShift,
|
||||||
paySession,
|
paySession,
|
||||||
printVoucher,
|
printVoucher,
|
||||||
|
reopenBarrier,
|
||||||
type SessionLookup,
|
type SessionLookup,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { qk } from "./lib/query.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 voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
|
||||||
|
|
||||||
const alreadyPaid = s?.paidAt != null;
|
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() {
|
async function handleOpenShift() {
|
||||||
setOpeningShift(true);
|
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">
|
<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">
|
<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.Title>
|
||||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
|
<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
|
<Row
|
||||||
label={t("pay.statusLabel")}
|
label={t("pay.statusLabel")}
|
||||||
value={alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
||||||
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
|
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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">
|
<span className="text-3xl font-bold text-term-cyan">
|
||||||
{s.amountMinor != null && s.currency
|
{isSubscription
|
||||||
? formatMoney(s.amountMinor, s.currency)
|
? t("pay.prepaid")
|
||||||
: alreadyPaid
|
: s.amountMinor != null && s.currency
|
||||||
? t("booth.badgePaid")
|
? formatMoney(s.amountMinor, s.currency)
|
||||||
: t("pay.noTariff")}
|
: alreadyPaid
|
||||||
|
? t("booth.badgePaid")
|
||||||
|
: t("pay.noTariff")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* Snapshots */}
|
||||||
<SnapshotStrip identity={identity} />
|
<SnapshotStrip identity={identity} />
|
||||||
|
|
||||||
{phase !== "done" && (
|
{phase !== "done" && !isSubscription && (
|
||||||
<>
|
<>
|
||||||
{/* Tender */}
|
{/* Tender */}
|
||||||
{canPay && (
|
{canPay && (
|
||||||
@@ -253,26 +286,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
>
|
>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{isSubscription ? (
|
||||||
type="button"
|
// Prepaid — the only action is the audited barrier open (assist
|
||||||
onClick={handlePayAndExit}
|
// a faulty exit reader / missing card). Gated on an open shift.
|
||||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={handleOpenBarrier}
|
||||||
{phase === "paying"
|
disabled={!shiftReady || phase === "finishing"}
|
||||||
? t("pay.takingPayment")
|
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"
|
>
|
||||||
? voucher
|
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||||
? t("pay.printingVoucher")
|
</button>
|
||||||
: t("pay.opening")
|
) : (
|
||||||
: alreadyPaid
|
<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
|
? voucher
|
||||||
? t("pay.printVoucher")
|
? t("pay.printingVoucher")
|
||||||
: t("pay.openBarrier")
|
: t("pay.opening")
|
||||||
: voucher
|
: alreadyPaid
|
||||||
? t("pay.payAndVoucher")
|
? voucher
|
||||||
: t("pay.payAndOpen")}
|
? t("pay.printVoucher")
|
||||||
</button>
|
: t("pay.openBarrier")
|
||||||
|
: voucher
|
||||||
|
? t("pay.payAndVoucher")
|
||||||
|
: t("pay.payAndOpen")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
armCapture,
|
||||||
|
cancelCapture,
|
||||||
createSubscription,
|
createSubscription,
|
||||||
deleteSubscription,
|
deleteSubscription,
|
||||||
|
fetchReaders,
|
||||||
fetchSiteConfig,
|
fetchSiteConfig,
|
||||||
fetchSubscriptions,
|
fetchSubscriptions,
|
||||||
|
pollCapture,
|
||||||
printSubscription,
|
printSubscription,
|
||||||
revokeSubscription,
|
revokeSubscription,
|
||||||
updateSubscription,
|
updateSubscription,
|
||||||
|
type ReaderInfo,
|
||||||
type Subscription,
|
type Subscription,
|
||||||
type SubscriptionCredential,
|
type SubscriptionCredential,
|
||||||
type SubscriptionInput,
|
type SubscriptionInput,
|
||||||
@@ -123,6 +128,11 @@ export function SubscriptionManager() {
|
|||||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(() => emptyForm());
|
const [form, setForm] = useState<FormState>(() => emptyForm());
|
||||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
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() {
|
function reload() {
|
||||||
fetchSubscriptions()
|
fetchSubscriptions()
|
||||||
@@ -203,6 +213,62 @@ export function SubscriptionManager() {
|
|||||||
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
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
|
// Live coverage preview: when months + validFrom are set, show the end date and
|
||||||
// (if priced) the N×monthly total the operator should collect.
|
// (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));
|
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>
|
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4>
|
||||||
{form.credentials.map((c, i) => (
|
{form.credentials.map((c, i) => (
|
||||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||||
{/* Operator chooses the credential type. Only QR is live today; RFID
|
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||||
is shown disabled ("soon") so the choice is visible — the backend
|
(read off a card via "Read card"). */}
|
||||||
already accepts both, so re-enabling RFID is just dropping `disabled`. */}
|
|
||||||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||||
<option value="qr">{t("subs.qr")}</option>
|
<option value="qr">{t("subs.qr")}</option>
|
||||||
<option value="rf" disabled>{t("subs.rfCardTagSoon")}</option>
|
<option value="rf">{t("subs.rfCardTag")}</option>
|
||||||
</select>
|
</select>
|
||||||
{c.kind === "qr" ? (
|
{c.kind === "qr" ? (
|
||||||
// QR codes are server-generated. Blank → "will be generated"; an
|
// 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>
|
<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>
|
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
|
<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" }}>
|
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||||
{t("subs.needCredentialOrPlate")}
|
{t("subs.needCredentialOrPlate")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -337,6 +337,32 @@ export function createSubscription(body: SubscriptionInput): Promise<Subscriptio
|
|||||||
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
|
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
|
||||||
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
|
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> {
|
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
|
||||||
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
@@ -481,6 +507,10 @@ export interface SessionLookup {
|
|||||||
currency: string | null;
|
currency: string | null;
|
||||||
withinGrace: boolean;
|
withinGrace: boolean;
|
||||||
graceExpiresAt: string | null;
|
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). */
|
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||||
@@ -500,6 +530,10 @@ export interface ActiveSession {
|
|||||||
currency: string | null;
|
currency: string | null;
|
||||||
withinGrace: boolean;
|
withinGrace: boolean;
|
||||||
graceExpiresAt: string | null;
|
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). */
|
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const en: Catalog = {
|
|||||||
badgeExiting: "exiting",
|
badgeExiting: "exiting",
|
||||||
badgePaid: "paid",
|
badgePaid: "paid",
|
||||||
badgeUnpaid: "unpaid",
|
badgeUnpaid: "unpaid",
|
||||||
|
badgeSubscription: "subscription",
|
||||||
evtEntry: "ENTRY",
|
evtEntry: "ENTRY",
|
||||||
evtExit: "EXIT",
|
evtExit: "EXIT",
|
||||||
evtPay: "PAY",
|
evtPay: "PAY",
|
||||||
@@ -156,6 +157,13 @@ export const en: Catalog = {
|
|||||||
credentialsCardQr: "Credentials (card / QR)",
|
credentialsCardQr: "Credentials (card / QR)",
|
||||||
rfCardTag: "RF card/tag",
|
rfCardTag: "RF card/tag",
|
||||||
rfCardTagSoon: "RF card/tag (soon)",
|
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",
|
qr: "QR",
|
||||||
qrAutoGen: "QR code is auto-generated on save",
|
qrAutoGen: "QR code is auto-generated on save",
|
||||||
credentialValue: "credential value",
|
credentialValue: "credential value",
|
||||||
@@ -268,6 +276,11 @@ export const en: Catalog = {
|
|||||||
lookingUp: "looking up…",
|
lookingUp: "looking up…",
|
||||||
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||||
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
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.",
|
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
|
||||||
noSnapshots: "no snapshots",
|
noSnapshots: "no snapshots",
|
||||||
loadingSnapshots: "loading snapshots…",
|
loadingSnapshots: "loading snapshots…",
|
||||||
|
|||||||
@@ -79,13 +79,14 @@ export const sq = {
|
|||||||
inAt: "në",
|
inAt: "në",
|
||||||
openPayExit: "Hap pagesën / daljen",
|
openPayExit: "Hap pagesën / daljen",
|
||||||
openBarrier: "Hap barrierën",
|
openBarrier: "Hap barrierën",
|
||||||
openBarrierTitle: "Hapje barriere me ndërhyrje njerëzore (e regjistruar)",
|
openBarrierTitle: "Hap barrierën manualisht",
|
||||||
barrierOpened: "barriera u hap",
|
barrierOpened: "barriera u hap",
|
||||||
openManually: "hape me dorë",
|
openManually: "hape me dorë",
|
||||||
// session row badges
|
// session row badges
|
||||||
badgeExiting: "duke dalë",
|
badgeExiting: "duke dalë",
|
||||||
badgePaid: "paguar",
|
badgePaid: "paguar",
|
||||||
badgeUnpaid: "papaguar",
|
badgeUnpaid: "papaguar",
|
||||||
|
badgeSubscription: "abonim",
|
||||||
// event types (live feed labels)
|
// event types (live feed labels)
|
||||||
evtEntry: "HYRJE",
|
evtEntry: "HYRJE",
|
||||||
evtExit: "DALJE",
|
evtExit: "DALJE",
|
||||||
@@ -158,6 +159,13 @@ export const sq = {
|
|||||||
credentialsCardQr: "Kredencialet (kartë / QR)",
|
credentialsCardQr: "Kredencialet (kartë / QR)",
|
||||||
rfCardTag: "Kartë/etiketë RF",
|
rfCardTag: "Kartë/etiketë RF",
|
||||||
rfCardTagSoon: "Kartë/etiketë RF (së shpejti)",
|
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",
|
qr: "QR",
|
||||||
qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje",
|
qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje",
|
||||||
credentialValue: "vlera e kredencialit",
|
credentialValue: "vlera e kredencialit",
|
||||||
@@ -270,6 +278,11 @@ export const sq = {
|
|||||||
lookingUp: "Duke kërkuar…",
|
lookingUp: "Duke kërkuar…",
|
||||||
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||||
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
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.",
|
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||||
// snapshots
|
// snapshots
|
||||||
noSnapshots: "asnjë foto",
|
noSnapshots: "asnjë foto",
|
||||||
|
|||||||
@@ -95,12 +95,23 @@ For an active session, the operator can open the barrier as a **human interventi
|
|||||||
> case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any
|
> case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any
|
||||||
> paid session that still slips through.
|
> paid session that still slips through.
|
||||||
|
|
||||||
**Guard — no payment, no button.** The "Open barrier" action is shown/active **only for sessions that
|
**Guard — paid OR subscription, else no button.** The "Open barrier" action is shown/active for a
|
||||||
have a payment** (paid, or paid-and-exited-in-grace). An **unpaid** open session has **no barrier-open
|
session that **has a payment** (paid, or paid-and-exited-in-grace) **OR is a [[subscription]]
|
||||||
affordance at all** — the row routes to the [[#operator-flow|pay/exit modal]] instead. The
|
occurrence** (prepaid — the operator must be able to assist a subscriber when the exit reader / card
|
||||||
no-unpaid-bypass rule is enforced structurally: the button simply does not exist for an unpaid car.
|
fails). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to
|
||||||
(A future reason-required *force exit* for genuine disputes would be a separately-audited path — see
|
the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally
|
||||||
Open.)
|
(server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force
|
||||||
|
exit* for genuine disputes would be a separately-audited path — see Open.
|
||||||
|
|
||||||
|
### Subscription occurrences in the booth (built 2026-06-18)
|
||||||
|
|
||||||
|
A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by
|
||||||
|
the **holder name**, not the raw `SUBSESS-…` key). Opening it shows the **pay/exit modal in
|
||||||
|
subscription mode**: entry/duration + **PREPAID** (no amount — it is **never quoted or charged**),
|
||||||
|
the entry/exit **snapshots**, and a single **Open barrier** action (the audited re-pulse). This is
|
||||||
|
exactly the assist path for a **faulty exit reader or a missing/forgotten card/QR**. The session
|
||||||
|
view (`lookup` / `activeSessions` in `pay-station.ts`) carries `subscription`, `subscriptionId`,
|
||||||
|
`subscriptionHolder`, derived from the entry payload's `permit:true` / `permitId`.
|
||||||
|
|
||||||
This single mechanism covers both edge cases: a **damaged ticket / dead scanner** (find the still-open
|
This single mechanism covers both edge cases: a **damaged ticket / dead scanner** (find the still-open
|
||||||
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
|
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
|
||||||
|
|||||||
@@ -104,10 +104,11 @@ LPR/ANPR plate identity** (the plate binding below):
|
|||||||
**`GS ( k`** (model-2, error-correction M) — added to the Rongta driver
|
**`GS ( k`** (model-2, error-correction M) — added to the Rongta driver
|
||||||
(`printSubscriptionCard`), no image/bitmap dependency (same approach as the Code128 ticket).
|
(`printSubscriptionCard`), no image/bitmap dependency (same approach as the Code128 ticket).
|
||||||
- **RF tag / chip / card — selectable later, NOT live yet.** An RFID/proximity credential, read
|
- **RF tag / chip / card — selectable later, NOT live yet.** An RFID/proximity credential, read
|
||||||
**host-side** (reader → host → `pulseOpen`). The data model + backend **already accept `kind:'rf'`**
|
**host-side** (reader → host → `pulseOpen`). **LIVE since 2026-06-18** — the operator selects RFID
|
||||||
(no migration needed to enable it); only the UI constrains the operator to QR for now — the RFID
|
and **reads the card off a physical reader** (see "Enrolling a card" below) rather than typing the
|
||||||
option is shown **disabled ("soon")** so the choice is visible. A Wiegand-out reader keeps a future
|
number. The GEE readers are combo QR + RFID (ID/IC/NFC), so the same device captures both. A
|
||||||
autonomous path open ([[entry-exit-readers]]); the [[dingtian-relay]] has no onboard card list.
|
Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]); the
|
||||||
|
[[dingtian-relay]] has no onboard card list.
|
||||||
- **Plate (LPR/ANPR) — NOT YET IMPLEMENTED.** When plate-bound (below), a matching plate read is an
|
- **Plate (LPR/ANPR) — NOT YET IMPLEMENTED.** When plate-bound (below), a matching plate read is an
|
||||||
accepted identity too. The vision/ANPR service that produces plate reads is future work
|
accepted identity too. The vision/ANPR service that produces plate reads is future work
|
||||||
([[opencv-anpr-service]] / [[lpr-camera]]); until it exists, plate binding has no live source.
|
([[opencv-anpr-service]] / [[lpr-camera]]); until it exists, plate binding has no live source.
|
||||||
@@ -115,6 +116,41 @@ LPR/ANPR plate identity** (the plate binding below):
|
|||||||
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
|
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
|
||||||
already in the model) and whose value is the credential id.
|
already in the model) and whose value is the credential id.
|
||||||
|
|
||||||
|
### Enrolling a card — "Read card" capture (built 2026-06-18)
|
||||||
|
|
||||||
|
RFID values are awkward to type, so the operator **presents the physical card to a chosen reader**
|
||||||
|
and the system captures it into the credential. The catch is that the readers are also serving live
|
||||||
|
traffic, so capture must **borrow one reader briefly without blocking the other**:
|
||||||
|
|
||||||
|
- **`CredentialCapture`** (in-memory, single-site): the operator picks a reader and **arms** it
|
||||||
|
(`POST /api/subscriptions/capture/arm {deviceId}`). It is **single-shot + a ~30 s TTL**.
|
||||||
|
- In the reader route (`qr-reader.ts`), each read first checks `tryConsume(deviceId, value)`: if
|
||||||
|
**this** reader is the armed one, the value is captured and the read is **NOT dispatched to the
|
||||||
|
access flow** (no barrier opens for a card being enrolled), then capture auto-disarms. A read on
|
||||||
|
**any other reader dispatches normally** — the live entry/exit flow on the other barrier is never
|
||||||
|
blocked. (Accepted trade: while armed, a real customer at the *armed* reader is captured instead of
|
||||||
|
admitted — kept tiny by single-shot + TTL.)
|
||||||
|
- The booth form **polls** `GET /api/subscriptions/capture` (idle | armed | captured | expired);
|
||||||
|
on `captured` it drops the value into the RFID field. `POST …/capture/cancel` disarms.
|
||||||
|
- Verified end-to-end (12/12): captured-not-dispatched (no ledger write), single-shot, the other
|
||||||
|
reader still drives a live `vehicle_exit` while armed, value retrievable, cancel/expiry.
|
||||||
|
|
||||||
|
> The same mechanism would work to capture a **QR** too, but QR codes are server-generated + printed,
|
||||||
|
> so capture is RFID-only in practice (QR has nothing to read off a card).
|
||||||
|
|
||||||
|
### Multiple credentials, and entry decoupled from exit (2026-06-18)
|
||||||
|
|
||||||
|
A subscription is a one-to-many aggregate: it may hold **several credentials at once** — e.g. a QR
|
||||||
|
**and** an RFID card (and later NFC). Each is its own `subscription_credentials` row; any of them
|
||||||
|
resolves the same subscription at the barrier. (NFC works today as an `rf` credential on the combo
|
||||||
|
GEE reader; a distinct `nfc` `kind` is a small future labelling-only addition.)
|
||||||
|
|
||||||
|
Crucially, **entry and exit are NOT bound to the same credential.** Originally the session was keyed
|
||||||
|
by the exact credential value read, so you had to leave with whatever you arrived with — an
|
||||||
|
*accidental* coupling. Now sessions are keyed by a **subscription occurrence** (`SUBSESS-<subId>-<uuid>`),
|
||||||
|
so you can **enter with the QR and exit with the card**. The mechanics (barrier-decides-direction,
|
||||||
|
FIFO close, fleet support) are in "As-built" below.
|
||||||
|
|
||||||
## Two optional, independent bindings — confirmed 2026-06-15
|
## Two optional, independent bindings — confirmed 2026-06-15
|
||||||
|
|
||||||
A subscription has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
|
A subscription has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
|
||||||
@@ -196,11 +232,21 @@ Tables (mutable master data; every *use* still produces a signed `vehicle_entry`
|
|||||||
(`read-dispatch.ts`): a credential read routes to the subscription flow if it **matches a
|
(`read-dispatch.ts`): a credential read routes to the subscription flow if it **matches a
|
||||||
subscription** (card/QR credential, or a bound plate) — otherwise to the transient exit flow.
|
subscription** (card/QR credential, or a bound plate) — otherwise to the transient exit flow.
|
||||||
|
|
||||||
- **Direction inferred from session state for that car** — no open session → ENTRY (check
|
- **Any credential opens/closes — sessions keyed by SUBSCRIPTION, not credential** (changed
|
||||||
`maxConcurrent`, sign `vehicle_entry`, open); an open session → EXIT (sign `vehicle_exit`, open,
|
2026-06-18). A subscriber can **enter with their QR and exit with their RFID card** (or any mix).
|
||||||
close). A fleet has one session per car; anti-passback falls out.
|
Entry mints a fresh **per-occurrence** session id (`SUBSESS-<subId>-<uuid>`, the ledger `identity`)
|
||||||
- **`maxConcurrent`** enforced as a fold over the signed ledger (the on-chain `permitId` payload is
|
with `payload.permitId = subId`; the credential read is decoupled from the session key. See "Entry
|
||||||
the match key). Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events.
|
decoupled from exit" below.
|
||||||
|
- **Direction = the BARRIER the reader sits at.** An entry-lane read is an ENTRY, an exit-lane read
|
||||||
|
is an EXIT; a `"both"` barrier infers from open state (open occurrence → exit, else entry). This is
|
||||||
|
what lets a **fleet** (`maxConcurrent > 1`) admit several cars (each entry-lane read is an entry)
|
||||||
|
*and* exit any of them with any credential.
|
||||||
|
- **Exit closes the OLDEST open occurrence (FIFO).** Per-car identity within a fleet isn't tracked
|
||||||
|
(it never was, once credentials are shared) — a read closes one occurrence, oldest first. An exit
|
||||||
|
read with nothing open is a no-op anti-passback signal (signed `anomaly`).
|
||||||
|
- **`maxConcurrent`** enforced as a fold over the signed ledger by occurrence (`payload.permitId`
|
||||||
|
match). Refusals (revoked / out-of-window / at-capacity / exit-with-nothing-open) are signed
|
||||||
|
`anomaly` events.
|
||||||
- **Admin CRUD** (`apps/server/src/routes/subscriptions.ts` + `apps/web/src/SubscriptionManager.tsx`):
|
- **Admin CRUD** (`apps/server/src/routes/subscriptions.ts` + `apps/web/src/SubscriptionManager.tsx`):
|
||||||
a subscription is an **aggregate** (row + credentials + bound plates + price). `GET
|
a subscription is an **aggregate** (row + credentials + bound plates + price). `GET
|
||||||
/api/subscriptions` (any signed-in role — for lookup), `POST/PUT/DELETE /api/subscriptions[/:id]` +
|
/api/subscriptions` (any signed-in role — for lookup), `POST/PUT/DELETE /api/subscriptions[/:id]` +
|
||||||
|
|||||||
+12
@@ -832,3 +832,15 @@ QR credentials are now AUTO-GENERATED server-side (`SUB-<15×base32>`, crypto-ra
|
|||||||
## [2026-06-18] feat | Subscription QR card — printed on creation + reprint, real QR rendering
|
## [2026-06-18] feat | Subscription QR card — printed on creation + reprint, real QR rendering
|
||||||
|
|
||||||
The auto-generated subscription QR is now PRINTED so the operator can hand it to the customer. Added real 2D QR rendering to the [[rongta-printer]] driver via ESC/POS `GS ( k` (model 2, EC level M; firmware-rendered, no bitmap dep) — new `PrinterDevice.printSubscriptionCard(SubscriptionCardData)`; the card is park header → scannable QR of the code → code text (hand-key fallback) → holder + validity. Server: `printSubscriptionCard()` in booth-print.ts (booth-receipt printer, failover to dispenser); create AUTO-PRINTS best-effort (a print failure never fails the create — response returns `{printed, printError}`); new `POST /api/subscriptions/:id/print` reprint (operator-or-admin; 409 if no QR credential, 503 if no printer). Web: SubscriptionManager surfaces the print outcome on save and a "Print code" button per QR subscription; api types + i18n (sq/en). Verified on the wire via buildServer+inject + a TCP capture (9/9: auto-print, well-formed GS ( k QR bytes with the embedded code, reprint re-sends, no-QR→409). Updated [[subscription]] + [[rongta-printer]]. No migration.
|
The auto-generated subscription QR is now PRINTED so the operator can hand it to the customer. Added real 2D QR rendering to the [[rongta-printer]] driver via ESC/POS `GS ( k` (model 2, EC level M; firmware-rendered, no bitmap dep) — new `PrinterDevice.printSubscriptionCard(SubscriptionCardData)`; the card is park header → scannable QR of the code → code text (hand-key fallback) → holder + validity. Server: `printSubscriptionCard()` in booth-print.ts (booth-receipt printer, failover to dispenser); create AUTO-PRINTS best-effort (a print failure never fails the create — response returns `{printed, printError}`); new `POST /api/subscriptions/:id/print` reprint (operator-or-admin; 409 if no QR credential, 503 if no printer). Web: SubscriptionManager surfaces the print outcome on save and a "Print code" button per QR subscription; api types + i18n (sq/en). Verified on the wire via buildServer+inject + a TCP capture (9/9: auto-print, well-formed GS ( k QR bytes with the embedded code, reprint re-sends, no-QR→409). Updated [[subscription]] + [[rongta-printer]]. No migration.
|
||||||
|
|
||||||
|
## [2026-06-18] feat | Subscription RFID enrollment — "Read card" capture on a chosen reader
|
||||||
|
|
||||||
|
Enabled RFID subscription credentials with a card-enrollment flow. The operator picks a reader and presents the physical card; the value is captured into the credential instead of being typed. New in-memory `CredentialCapture` (single-shot + ~30s TTL): `arm(deviceId)`; `routes/qr-reader.ts` checks `tryConsume()` on each read — an armed reader's read is captured and NOT dispatched (no barrier for an enrolled card), then auto-disarms; reads on the OTHER reader dispatch normally, so its live entry/exit flow is never blocked. Routes (operator/admin): `GET /api/subscriptions/readers` (picker), `POST /capture/arm`, `GET /capture` (poll: idle|armed|captured|expired), `POST /capture/cancel`. Web: RFID re-enabled in the form (was disabled "soon"); "Read card" → reader picker → arm → poll → fills the value; i18n (sq/en). The GEE readers are combo QR+RFID (ID/IC/NFC), same endpoint, so one device captures both. Verified via buildServer+inject + reader-scan simulation (12/12: captured-not-dispatched, single-shot, other reader still drives a live vehicle_exit while armed, value retrievable, cancel). Updated [[subscription]]. No migration.
|
||||||
|
|
||||||
|
## [2026-06-18] feat | Subscriptions — enter with one credential, exit with another (+ FIFO fleets)
|
||||||
|
|
||||||
|
Decoupled subscription exit from the entry credential. Previously the session was keyed by the exact credential value read (an accidental coupling → must exit with the same QR/RFID you entered with). Now sessions are keyed by a per-occurrence id (`SUBSESS-<subId>-<uuid>`, the ledger `identity`; `payload.permitId`=subId), so ANY of a subscription's credentials (QR/RFID/NFC/plate) opens or closes. Direction is now decided by the BARRIER the reader sits at (entry-lane→entry, exit-lane→exit; a "both" barrier infers from open state) — this lets a FLEET (maxConcurrent>1) admit several cars (each entry-lane read is an entry) yet exit any of them with any credential; exit closes the OLDEST open occurrence (FIFO). Per-car identity within a fleet isn't tracked (never was once credentials are shared). `#openOccurrences()` replaced `#carHasOpenSession`/`#subscriptionOpenCount`. Exit with nothing open → signed anomaly (anti-passback). Verified 11/11 (enter-QR/exit-RFID + reverse, fleet 2-in mixed-credential FIFO out, capacity, anti-passback, chain intact). Updated [[subscription]] (multi-credential + entry-decoupled-from-exit). No migration.
|
||||||
|
|
||||||
|
## [2026-06-18] fix | Subscription occurrences in the booth — prepaid, barrier-open assist (not transient)
|
||||||
|
|
||||||
|
A subscription occurrence (SUBSESS-…) showed in Active Sessions but was wrongly treated as an unpaid transient: the modal tried to quote/charge it and the "open barrier" button only appeared for PAID sessions, so a subscriber with a faulty exit reader / missing card couldn't be assisted. Fix: `pay-station.ts` lookup/activeSessions now flag `subscription`/`subscriptionId`/`subscriptionHolder` from the entry payload (permit:true/permitId) and DON'T quote a subscription (amountMinor null). `exit-flow.ts` reopenBarrier now authorizes `paidAt != null || subscription` (prepaid). UI: the pay modal renders a SUBSCRIPTION mode (PREPAID badge, snapshots, single Open-barrier action, no tender/voucher) and the active row badges "abonim" + shows the holder name; both labelled by holder, not the raw key. Also SHORTENED the occurrence id (was SUBSESS-<subId>-<uuid>, ~80 chars) to `SUBSESS-<12hex>` — the subscriptionId lives in the payload (which every fold matches on), so it needn't be embedded in the key. Verified 9/9 (subscription flagged + not charged in lookup/active, reopen works without payment, unpaid-transient guard intact). Updated [[booth-exit-flow]]. No migration.
|
||||||
|
|||||||
Reference in New Issue
Block a user