5 Commits

Author SHA1 Message Date
julian b8ddda86e7 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
2026-06-18 16:26:48 +02:00
julian bba988c4e8 feat(subscription): QR credentials — operator-choose (QR-only now), auto-generate, multi-month, printed card
Builds out subscription credentials on top of the rename.

- Operator chooses the credential type; only QR is live (RFID shown disabled
  "soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
  globally-unique-checked) — the customer/operator never picks the value.
  RF stays operator-entered (the physical card id). Reader output decided =
  TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
  validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
  (GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
  create (best-effort — never fails the create; returns {printed,printError})
  + reprint via POST /api/subscriptions/:id/print and a "Print code" button.

Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 14:48:38 +02:00
julian 5697137c52 feat(subscription): rename permit→subscription + monthly pricing
The "permit/lejet" feature is really a subscription. Full rename of the
mutable master data, plus a recurring monthly price.

- DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions,
  permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id.
- Pricing: per-subscription priceMinor + period(monthly) + currency, with a
  site default (site_config.subscription_monthly_price_minor) pre-filling the form.
- Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts
  (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en).
- The signed ledger `permitId` payload is intentionally kept — immutable
  hash-chained history; renaming it would break verification of past events.

Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed
payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber).

Also carries the device-footer UI surface (api DeviceStatus, router mount,
i18n devices) due to shared-file overlap with the preceding footer commit.

Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions
preserved). Live DB migrated. Full monorepo builds clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:15:04 +02:00
julian ca8c7f2fa2 fix(exit): stuck active session — paid ticket with no vehicle_exit
A paid car that left via a manual barrier re-open kept no vehicle_exit, so
activeSessions() saw it as permanently open and the grace-expiry eviction
(which only ran for exited sessions) never fired — it lingered forever
(ticket T-397815c0).

- reopenBarrier() now signs a vehicle_exit (source:manual) when the session
  is still open, closing it; still no second exit when already exited
  (phantom re-close — no double-count).
- activeSessions() ages out a PAID open session past grace even with no exit
  (unpaid open sessions never age out — a car owing money stays). Pure
  display filter; the signed log is untouched.

Verified both fixes + chain integrity on a fresh DB. A one-off corrective
vehicle_exit was appended to the live ledger to clear T-397815c0.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:14:45 +02:00
julian f87e4c0d6b feat(devices): live device-status footer across all categories
Generalise printer-only monitoring to every configured device. New
DeviceMonitor polls all enabled devices each tick (default 8s): printers
via rich readStatus(), relays/readers/cameras via the generic healthCheck()
reachability probe, flattened to one traffic-light (ready/degraded/offline)
+ detail, deduped (emit on change only), fail-toward-offline.

- device-status bus event + GET /api/devices/status snapshot.
- Pushed over the existing /api/ws (hello carries the initial set;
  device-status frame per change).
- Web: live-store devices map, WS handler, DeviceFooter chip-per-device
  (role label not vendor; click a degraded/offline chip for an issues panel).

Verified roleKind resolution + change-only emit on a fresh DB.

Note: the footer's UI surface (api type, router mount, i18n devices) rides
in the subsequent subscription commit due to shared-file overlap.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:14:36 +02:00
55 changed files with 2838 additions and 936 deletions
+26
View File
@@ -79,3 +79,29 @@ export async function printExitVoucher(
logger.info(`exit voucher for ${ticketId} printed on ${printedBy}`); logger.info(`exit voucher for ${ticketId} printed on ${printedBy}`);
return printedBy; return printedBy;
} }
/**
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
* a scannable QR of the credential code + holder/validity, so the operator can hand
* it to the customer. Used on subscription creation and on a "reprint" action.
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
*/
export async function printSubscriptionCard(
db: Db,
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const data = {
code: card.code,
holderName: card.holderName ?? null,
validFrom: card.validFrom ?? null,
validTo: card.validTo ?? null,
header: ticketHeader(db),
};
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printSubscriptionCard(data),
);
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
return printedBy;
}
+89
View File
@@ -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;
}
}
+40 -2
View File
@@ -17,7 +17,7 @@ export interface DeviceInputEvent {
} }
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader. // A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
// Drives identity-based flows (exit validation, permits, pay-station lookup). `kind` // Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
// mirrors IdentitySource. See parking-session.md. // mirrors IdentitySource. See parking-session.md.
export interface DeviceReadEvent { export interface DeviceReadEvent {
readonly driverId: string; readonly driverId: string;
@@ -35,7 +35,7 @@ export interface DeviceReadEvent {
export interface ReadOutcome { export interface ReadOutcome {
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */ /** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
readonly accepted: boolean; readonly accepted: boolean;
/** Which way it went, when known (permit/exit infer this). */ /** Which way it went, when known (subscription/exit infer this). */
readonly direction?: "entry" | "exit"; readonly direction?: "entry" | "exit";
/** Human-readable reason (for logs / the reader UI), esp. on reject. */ /** Human-readable reason (for logs / the reader UI), esp. on reject. */
readonly reason?: string; readonly reason?: string;
@@ -49,6 +49,33 @@ export interface PrinterStatusEvent {
readonly status: PrinterStatus; readonly status: PrinterStatus;
} }
/**
* The unified live status of ANY configured device — what the booth footer shows.
* Every enabled device is polled: printers via their rich `readStatus()`
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
* reachability probe. `state` is the common traffic-light; `detail` carries the
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
* and wiki/concepts/device-status-monitoring.md.
*/
export interface DeviceStatusEvent {
readonly deviceId: string; // devices id
readonly driverId: string;
readonly category: "access" | "reader" | "camera" | "printer";
/**
* The device's ROLE descriptor for the footer label — NOT the vendor. A
* direction-style token the client localises and pairs with the category, so the
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
* - undetermined: null (chip shows the category alone)
*/
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
readonly state: "ready" | "degraded" | "offline";
readonly detail?: string;
readonly checkedAt: string; // ISO-8601
}
class DeviceEventBus extends EventEmitter { class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void { emitInput(event: DeviceInputEvent): void {
this.emit("input", event); this.emit("input", event);
@@ -76,6 +103,17 @@ class DeviceEventBus extends EventEmitter {
return () => this.off("printer-status", cb); return () => this.off("printer-status", cb);
} }
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
* (all categories — relays, readers, cameras, printers). Drives the booth
* device-status footer over the WS. */
emitDeviceStatus(event: DeviceStatusEvent): void {
this.emit("device-status", event);
}
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
this.on("device-status", cb);
return () => this.off("device-status", cb);
}
/** /**
* Emitted AFTER a signed business event is appended to the ledger (entry, exit, * Emitted AFTER a signed business event is appended to the ledger (entry, exit,
* payment, void, …). The payload is the persisted row — business facts only, no * payment, void, …). The payload is the persisted row — business facts only, no
+156
View File
@@ -0,0 +1,156 @@
import type { FastifyBaseLogger } from "fastify";
import { devices, type Db, type DeviceRow } from "@parking/db";
import { isMonitorable, registry } from "@parking/devices";
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
import { directionOf, relaysOf } from "./device-resolve.js";
// Unified live DEVICE monitor — the source for the booth's device-status footer.
// Every enabled, configured device is probed on an interval, regardless of
// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the
// same capability the PrinterMonitor uses), and a relay/reader/camera via the
// generic healthCheck() reachability probe every Device implements. The result is
// flattened to a common traffic-light (ready | degraded | offline) + a detail
// string, cached per device id, and emitted on the bus ONLY when it changes.
//
// This is device-agnostic (talks to the adapter interfaces, never a driver SDK)
// and read-only — polling a device never drives a relay or mutates the ledger.
// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md.
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
/**
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
* tokens the client localises next to the category:
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
* than one direction; null if it declares none yet
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt)
*/
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) {
case "reader":
case "camera": {
const d = directionOf(db, row); // entry | exit | both
return d;
}
case "access": {
const dirs = new Set(relaysOf(row).map((r) => r.direction));
if (dirs.size === 0) return null;
if (dirs.size > 1) return "mixed";
const only = [...dirs][0]; // entry | exit | both
return only ?? null;
}
case "printer": {
const role = (row.config as { role?: string }).role;
if (role === "booth-receipt") return "booth";
if (role === "entry-dispenser") return "lane";
return null;
}
default:
return null;
}
}
export class DeviceMonitor {
readonly #db: Db;
readonly #log: FastifyBaseLogger;
readonly #pollMs: number;
/** Latest unified status per device id. */
readonly #latest = new Map<string, DeviceStatusEvent>();
#timer: ReturnType<typeof setInterval> | null = null;
#ticking = false;
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS) {
this.#db = db;
this.#log = log;
this.#pollMs = pollMs;
}
/** Begin polling. Idempotent. */
start(): void {
if (this.#timer) return;
void this.#tick(); // immediate first pass so the footer fills without a wait
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
this.#timer.unref?.();
this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`);
}
stop(): void {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
}
/** Current snapshot for the API / a freshly-connected WS client. */
snapshot(): DeviceStatusEvent[] {
return [...this.#latest.values()];
}
async #tick(): Promise<void> {
if (this.#ticking) return; // never overlap polls
this.#ticking = true;
try {
// Re-read the device set each tick so a newly-assigned/removed device is
// picked up without a restart.
const rows = await this.#db.select().from(devices).all();
const enabled = rows.filter((r) => r.enabled);
const present = new Set(enabled.map((r) => r.id));
// Drop devices that are gone/disabled (so the footer doesn't show stale ones).
for (const id of [...this.#latest.keys()]) {
if (!present.has(id)) this.#latest.delete(id);
}
await Promise.all(enabled.map((r) => this.#poll(r)));
} catch (err) {
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
} finally {
this.#ticking = false;
}
}
async #poll(row: DeviceRow): Promise<void> {
const cfg = (row.config ?? {}) as Record<string, unknown>;
const base = {
deviceId: row.id,
driverId: row.driverId,
category: row.category,
roleKind: roleKindOf(this.#db, row),
};
let next: DeviceStatusEvent;
const driver = registry.get(row.driverId);
if (!driver) {
// Configured against a driver that's no longer registered — surface it,
// don't silently hide it.
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
} else {
try {
const device = driver.create(cfg as never);
// Printers expose richer paper/cover/cutter status; everything else uses
// the generic reachability probe. Both flatten to the same traffic-light.
if (isMonitorable(device)) {
const s = await device.readStatus();
next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt };
} else {
const h = await device.healthCheck();
next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() };
}
} catch (err) {
// A probe that throws (build error, timeout) reads as offline — never crash
// the tick, and fail toward "there's a problem" rather than false-healthy.
next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() };
}
}
const prev = this.#latest.get(row.id);
this.#latest.set(row.id, next);
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
this.#log.info(
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${row.id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
);
deviceEvents.emitDeviceStatus(next);
}
}
}
+2 -2
View File
@@ -73,9 +73,9 @@ export class EntryFlow {
async #runEntry(resolved: ResolvedRelay): Promise<void> { async #runEntry(resolved: ResolvedRelay): Promise<void> {
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry: // CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT // no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
// gated here (their flow ignores site-full; their own maxConcurrent applies), so // gated here (their flow ignores site-full; their own maxConcurrent applies), so
// subscribers aren't locked out. "Full" is a soft policy seam for valet over- // they aren't locked out. "Full" is a soft policy seam for valet over-
// capacity later. See wiki/concepts/capacity-occupancy.md. // capacity later. See wiki/concepts/capacity-occupancy.md.
const occ = getOccupancy(this.#db); const occ = getOccupancy(this.#db);
if (occ.full) { if (occ.full) {
+41 -12
View File
@@ -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
@@ -170,10 +173,16 @@ export class ExitFlow {
* opens the barrier with a signed trace. * opens the barrier with a signed trace.
* *
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule; * Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
* the UI also hides the button). Unlike exitForBooth this does NOT sign a * the UI also hides the button). It re-pulses the exit relay and signs an `anomaly`
* `vehicle_exit` (the session may already be exited; a second exit would
* double-count occupancy). It re-pulses the exit relay and signs an `anomaly`
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight. * ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
*
* CLOSING THE SESSION (fix 2026-06-18): if the session is still OPEN (no
* `vehicle_exit` yet), the manual re-open *is* this car leaving — so we also sign a
* `vehicle_exit` (attributed as human-intervention). Without it the paid session
* would linger in the Active Sessions list FOREVER, since the grace-expiry eviction
* only applies to already-exited sessions (the T-397815c0 bug). If the session is
* already CLOSED (a prior exit exists — the phantom re-close case), we do NOT sign a
* second exit (that would double-count occupancy): anomaly only, as before.
* See wiki/concepts/booth-exit-flow.md. * See wiki/concepts/booth-exit-flow.md.
*/ */
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> { async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
@@ -182,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" };
} }
@@ -195,7 +205,7 @@ export class ExitFlow {
try { try {
const resolved = firstRelayByDirection(this.#db, "exit"); const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the audited anomaly FIRST (the intervention is recorded whether or not // Sign the audited anomaly FIRST (the intervention is recorded whether or not
// the physical open succeeds) — never a second vehicle_exit. // the physical open succeeds).
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
identity: id, identity: id,
@@ -207,6 +217,16 @@ export class ExitFlow {
}, },
}); });
// Close an OPEN session: the re-open is the exit. Sign the vehicle_exit so the
// session leaves the active list + occupancy settles. Skip when already exited
// (no double-count). Recorded as a human-intervention exit for the audit trail.
if (view.open) {
await this.#signExit(id, "manual");
this.#closeSessionCache(id);
this.#fireExitSnapshot(id);
this.#logger.info(`barrier re-open also closed open session ${id} (human-intervention exit)`);
}
if (!resolved) { if (!resolved) {
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`); this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
return { ok: true, opened: false, reason: "no exit barrier configured — open manually" }; return { ok: true, opened: false, reason: "no exit barrier configured — open manually" };
@@ -230,7 +250,7 @@ export class ExitFlow {
} }
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the /** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
* read dispatcher from the reader's binding, which has ruled out a permit match). */ * read dispatcher from the reader's binding, which has ruled out a subscription match). */
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> { async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const key = `${e.deviceId}:${e.value}`; const key = `${e.deviceId}:${e.value}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
@@ -321,14 +341,19 @@ export class ExitFlow {
return { accepted: true, direction: "exit" }; return { accepted: true, direction: "exit" };
} }
/** Append the signed vehicle_exit. `source` defaults to "ticket" (booth/manual). */ /** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
async #signExit(identity: string, source: "ticket" | "lpr" = "ticket"): Promise<void> { * or "manual" (a human-intervention barrier re-open that closes an open session —
* see reopenBarrier). */
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
await this.#log.append({ await this.#log.append({
type: "vehicle_exit", type: "vehicle_exit",
direction: "exit", direction: "exit",
source, source,
identity, identity,
payload: { sessionRef: identity }, payload: {
sessionRef: identity,
...(source === "manual" ? { reason: "human-intervention exit (manual barrier open)" } : {}),
},
}); });
} }
@@ -410,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,
}; };
+67 -8
View File
@@ -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);
@@ -250,15 +281,28 @@ export class PayStation {
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString() ? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
: null; : null;
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt); const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
const paid = a.paidAt != null;
// ACTIVE = still inside, OR exited but still within the (unconfirmed) grace window. // ACTIVE membership:
// An exited session past grace is presumed truly gone → omitted. // - exited + within grace → still shown (barrier unconfirmed, may be present);
// - exited + past grace → presumed gone, omitted;
// - open + UNPAID → always shown (a car owing money never ages out —
// it's genuinely still inside until it pays, however long that takes);
// - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
// manual barrier re-open before that path closed the session, or a historical
// session like T-397815c0) it would otherwise linger forever. The signed log
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
if (!open && !withinGrace) continue; 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 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;
@@ -279,6 +323,9 @@ export class PayStation {
currency, currency,
withinGrace, withinGrace,
graceExpiresAt, graceExpiresAt,
subscription: isSubscription,
subscriptionId: a.subscriptionId ?? null,
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
}); });
} }
@@ -287,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
-222
View File
@@ -1,222 +0,0 @@
import { eq, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
// Reached from the read dispatcher when a read matches a permit (not an open ticket).
// See wiki/entities/permit.md.
//
// Two optional, independent bindings:
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
// permit's cars may be inside at once; enforced over the session projection.
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
// too (card/QR OR plate). When unset, any car may use the permit'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 permit 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).
export interface PermitMatch {
readonly permitId: string;
/** The specific credential/plate value read — the per-car session key. */
readonly carKey: string;
readonly via: "card" | "qr" | "plate";
}
export class PermitFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** Resolve a read to a permit (by card/QR credential, or by a bound plate), or null. */
match(e: DeviceReadEvent): PermitMatch | null {
// Card / QR / generic credential value.
const cred = this.#db
.select()
.from(permitCredentials)
.where(eq(permitCredentials.value, e.value))
.get();
if (cred) {
return { permitId: cred.permitId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a permit's bound plate is an identity.
if (e.kind === "plate") {
const plate = this.#db.select().from(permitPlates).where(eq(permitPlates.plate, e.value)).get();
if (plate) return { permitId: plate.permitId, carKey: e.value, via: "plate" };
}
return null;
}
/** Run the permit entry/exit for a matched read at a barrier. `resolved` is the
* reader's bound relay; its direction constrains, "both" defers to session state. */
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
const key = `${m.permitId}:${m.carKey}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#run(resolved, e, m);
} catch (err) {
this.#logger.error(`permit-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
if (!permit) return { accepted: false, reason: "permit not found" };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
const invalid =
permit.status !== "active" ||
(permit.validFrom != null && now < permit.validFrom) ||
(permit.validTo != null && now > permit.validTo);
if (invalid) {
const reason = `permit ${permit.status}/out-of-window`;
await this.#reject(m, reason);
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 = `permit wrong barrier — ${resolved.direction} barrier but car would ${inferred}`;
await this.#reject(m, reason);
return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason };
}
if (carOpen) {
// EXIT: this car is already inside → the read is its exit.
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey,
payload: { sessionRef: m.carKey, permitId: m.permitId },
});
await this.#open(resolved, "exit", m.carKey, "permit exit");
this.#closeCache(m.carKey);
return { accepted: true, direction: "exit" };
}
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
if (permit.maxConcurrent != null) {
const open = this.#permitOpenCount(m.permitId);
if (open >= permit.maxConcurrent) {
const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`;
await this.#reject(m, reason);
return { accepted: false, direction: "entry", reason };
}
}
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey,
// No ticket, no fee — the permit IS the authorization. Recorded for audit.
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true },
occurredAt: now,
});
await this.#open(resolved, "entry", m.carKey, "permit entry");
try {
this.#db
.insert(sessions)
.values({ id: m.carKey, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" })
.run();
} catch (err) {
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(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 permit's cars are inside right now (fold over the ledger). */
#permitOpenCount(permitId: 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 === permitId);
let open = 0;
for (const entry of rows) {
if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
open += 1;
}
return open;
}
async #reject(m: PermitMatch, reason: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity: m.carKey,
payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true },
});
this.#logger.warn(`permit refused (${m.carKey}): ${reason}`);
}
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
void snapshotAsync({
db: this.#db,
direction: dir,
identity: carKey,
logger: this.#logger,
}).catch((err) => this.#logger.error(`permit snapshot error: ${(err as Error).message}`));
}
#closeCache(carKey: string): void {
try {
this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`);
}
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
}
+14 -14
View File
@@ -2,32 +2,32 @@ import { devices, eq, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { ExitFlow } from "./exit-flow.js"; import type { ExitFlow } from "./exit-flow.js";
import type { PermitFlow } from "./permit-flow.js"; import type { SubscriptionFlow } from "./subscription-flow.js";
import { relayForDevice } from "./device-resolve.js"; import { relayForDevice } from "./device-resolve.js";
// Routes a credential read (ticket scan / plate / card) to the right flow. A read // Routes a credential read (ticket scan / plate / card) to the right flow. A read
// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the // can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
// credential is (decision 2026-06-15): // credential is (decision 2026-06-15):
// - matches a permit (card/QR/bound plate) → PERMIT flow, // - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
// - else → transient EXIT flow (open ticket session → exit, else reject+log). // - else → transient EXIT flow (open ticket session → exit, else reject+log).
// //
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read // The reader is BOUND to a controller relay (config.controllerId + relay), so a read
// resolves to exactly the barrier it sits at, and the direction is inherited from // resolves to exactly the barrier it sits at, and the direction is inherited from
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so // that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit" // it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
// reader the exit side; "both" defers to the flow's own inference (permit: session // reader the exit side; "both" defers to the flow's own inference (subscription:
// state; transient: exit). // session state; transient: exit).
export class ReadDispatcher { export class ReadDispatcher {
readonly #db: Db; readonly #db: Db;
readonly #exit: ExitFlow; readonly #exit: ExitFlow;
readonly #permit: PermitFlow; readonly #subscription: SubscriptionFlow;
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) { constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db; this.#db = db;
this.#exit = exit; this.#exit = exit;
this.#permit = permit; this.#subscription = subscription;
this.#logger = logger; this.#logger = logger;
} }
@@ -41,13 +41,13 @@ export class ReadDispatcher {
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" }; return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
} }
const permit = this.#permit.match(e); const sub = this.#subscription.match(e);
if (permit) { if (sub) {
return this.#permit.run(resolved, e, permit); return this.#subscription.run(resolved, e, sub);
} }
// Not a permit → transient ticket exit. An ENTRY reader can't produce a transient // Not a subscription → transient ticket exit. An ENTRY reader can't produce a
// exit (transient entry is the button flow, not a reader), so reject+log rather // transient exit (transient entry is the button flow, not a reader), so reject+log
// than treat an entry scan as an exit. // rather than treat an entry scan as an exit.
if (resolved.direction === "entry") { if (resolved.direction === "entry") {
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" }; return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
} }
+21
View File
@@ -0,0 +1,21 @@
import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js";
import type { DeviceMonitor } from "../device-monitor.js";
// Unified device-status snapshot for the booth footer. The DeviceMonitor polls all
// configured devices (relays/readers/cameras via healthCheck, printers via their
// rich readStatus) in the background; this exposes its cache. Live updates ride the
// booth WebSocket (kind:"device-status") — this REST route is the initial load /
// fallback. Any authenticated role may read (operational, not a setup action).
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
export async function deviceStatusRoutes(
app: FastifyInstance,
monitor: DeviceMonitor,
): Promise<void> {
const guard = requireRole("admin", "operator", "cashier", "readonly");
app.get("/api/devices/status", { preHandler: guard }, async () => ({
devices: monitor.snapshot(),
}));
}
-160
View File
@@ -1,160 +0,0 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, permitCredentials, permitPlates, permits, type Db } from "@parking/db";
import { requireRole } from "../auth.js";
// Permit (subscription) admin CRUD. A permit is mutable master data — admins
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
// trail stays append-only (see wiki/entities/permit.md). A permit is an aggregate:
// the permit row + its credentials (card/QR) + its bound plates. The API treats them
// as one unit (create/update replace the child sets; delete removes all).
interface Credential {
kind: "rf" | "qr";
value: string;
}
interface PermitBody {
holderName?: string;
contact?: string;
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
maxConcurrent?: number | null;
validFrom?: string | null;
validTo?: string | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
}
export async function permitRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Admin manages permits; operator/cashier/readonly may LIST (to look one up).
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
const writeGuard = requireRole("admin");
// Validate the body; returns problems (empty = ok). Shared by create + update.
function validate(b: PermitBody): string[] {
const errs: string[] = [];
if (b.maxConcurrent != null) {
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
errs.push("maxConcurrent must be a positive integer, or null for unbound");
}
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
for (const c of b.credentials ?? []) {
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
errs.push("each credential needs kind (rf|qr) and a non-empty value");
break;
}
}
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
errs.push("a permit needs at least one credential or one bound plate (else nothing identifies it)");
}
return errs;
}
function loadAggregate(id: string) {
const permit = db.select().from(permits).where(eq(permits.id, id)).get();
if (!permit) return null;
const credentials = db.select().from(permitCredentials).where(eq(permitCredentials.permitId, id)).all();
const plates = db.select().from(permitPlates).where(eq(permitPlates.permitId, id)).all();
return {
...permit,
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
plates: plates.map((p) => p.plate),
};
}
// Replace a permit's child rows (credentials + plates) from the body.
function writeChildren(id: string, b: PermitBody) {
db.delete(permitCredentials).where(eq(permitCredentials.permitId, id)).run();
db.delete(permitPlates).where(eq(permitPlates.permitId, id)).run();
for (const c of b.credentials ?? []) {
db.insert(permitCredentials).values({ id: randomUUID(), permitId: id, kind: c.kind, value: c.value.trim() }).run();
}
for (const p of b.plates ?? []) {
if (p.trim()) db.insert(permitPlates).values({ id: randomUUID(), permitId: id, plate: p.trim() }).run();
}
}
// List all permits (with their credentials + plates).
app.get("/api/permits", { preHandler: readGuard }, async () => {
const rows = db.select().from(permits).all();
return { permits: rows.map((r) => loadAggregate(r.id)) };
});
// Create a permit.
app.post<{ Body: PermitBody }>("/api/permits", { preHandler: writeGuard }, async (req, reply) => {
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
const id = randomUUID();
db.insert(permits)
.values({
id,
holderName: b.holderName ?? null,
contact: b.contact ?? null,
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: b.validTo ?? null,
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
return reply.code(201).send(loadAggregate(id));
});
// Update a permit (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: PermitBody }>(
"/api/permits/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(permits).where(eq(permits.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "permit not found" });
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
db.update(permits)
.set({
holderName: b.holderName ?? null,
contact: b.contact ?? null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: b.validTo ?? null,
status: b.status ?? existing.status,
})
.where(eq(permits.id, req.params.id))
.run();
writeChildren(req.params.id, b);
return loadAggregate(req.params.id);
},
);
// Revoke (soft): the common case — keeps the permit + its history, just bars it.
// A revoked permit fails the entry check (see permit-flow.ts). Use DELETE only to
// fully remove a permit created in error.
app.post<{ Params: { id: string } }>(
"/api/permits/:id/revoke",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.update(permits).set({ status: "revoked" }).where(eq(permits.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
return loadAggregate(req.params.id);
},
);
// Hard delete a permit + its child rows. (Past ledger events that reference it
// are untouched — the audit trail is append-only and independent of this row.)
app.delete<{ Params: { id: string } }>(
"/api/permits/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.delete(permits).where(eq(permits.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
db.delete(permitCredentials).where(eq(permitCredentials.permitId, req.params.id)).run();
db.delete(permitPlates).where(eq(permitPlates.permitId, req.params.id)).run();
return reply.code(204).send();
},
);
}
+21 -2
View File
@@ -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,11 +84,19 @@ 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).
// output 0 = Access (drive the reader's access line on a valid read). // output 0 = Access (drive the reader's access line on a valid read).
+17 -5
View File
@@ -23,18 +23,23 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
capacity?: number | null; capacity?: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */ /** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault?: boolean; exitVoucherDefault?: boolean;
/** Site default monthly subscription price in minor units (pre-fills the form). */
subscriptionMonthlyPriceMinor?: number | null;
} }
/** Shape returned by GET/PUT: capacity + the booth flag + every metadata field. */ /** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean } & Record< * + every metadata field. */
TextField, type SiteConfig = {
string | null capacity: number | null;
>; exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = { const out = {
capacity: row?.capacity ?? null, capacity: row?.capacity ?? null,
exitVoucherDefault: row?.exitVoucherDefault ?? false, exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
} as SiteConfig; } as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out; return out;
@@ -79,6 +84,13 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
patch.exitVoucherDefault = body.exitVoucherDefault; patch.exitVoucherDefault = body.exitVoucherDefault;
} }
if ("subscriptionMonthlyPriceMinor" in body) {
const p = body.subscriptionMonthlyPriceMinor;
if (p != null && (!Number.isInteger(p) || p < 0)) {
return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" });
}
patch.subscriptionMonthlyPriceMinor = p ?? null;
}
for (const f of TEXT_FIELDS) { for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]); if (f in body) patch[f] = normText(body[f]);
} }
+366
View File
@@ -0,0 +1,366 @@
import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
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
// trail stays append-only (see wiki/entities/subscription.md). A subscription is an
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
// them as one unit (create/update replace the child sets; delete removes all).
//
// Pricing: priceMinor + period ("monthly") + currency record the recurring plan
// (e.g. 10,000 ALL / month). Collecting the fee into the ledger/shift is deferred —
// here we just store the agreed price and the coverage window.
interface Credential {
kind: "rf" | "qr";
/** For RF: the physical card/tag id (required). For QR: optional — left blank, the
* server AUTO-GENERATES an unguessable code (the customer never picks it). */
value?: string;
}
interface SubscriptionBody {
holderName?: string;
contact?: string;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = no price set. */
priceMinor?: number | null;
period?: "monthly";
/** ISO-4217 currency of priceMinor (e.g. "ALL"). */
currency?: string | null;
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
maxConcurrent?: number | null;
validFrom?: string | null;
validTo?: string | null;
/** Months paid for. When set (with validFrom), validTo = validFrom + months — the
* multi-month case (e.g. 3 months). Takes precedence over an explicit validTo. */
months?: number | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
}
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
* delivers the full string over TCP/IP (the host-in-the-loop path), so length is
* free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */
function newQrCode(): string {
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const bytes = randomBytes(15);
let out = "";
for (const b of bytes) out += alphabet[b % 32];
return `SUB-${out}`;
}
/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo →
* Feb 28/29). Returns ISO. */
function addMonths(iso: string, months: number): string {
const d = new Date(iso);
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
// If the month rolled past (e.g. day 31 → next month had fewer days), clamp back.
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString();
}
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");
// Validate the body; returns problems (empty = ok). Shared by create + update.
function validate(b: SubscriptionBody): string[] {
const errs: string[] = [];
if (b.maxConcurrent != null) {
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
errs.push("maxConcurrent must be a positive integer, or null for unbound");
}
}
if (b.priceMinor != null) {
if (!Number.isInteger(b.priceMinor) || b.priceMinor < 0) {
errs.push("priceMinor must be a non-negative integer (minor units), or null");
}
if (!b.currency?.trim()) {
errs.push("currency is required when a price is set");
}
}
if (b.period != null && b.period !== "monthly") {
errs.push("period must be 'monthly' (the only period supported today)");
}
if (b.months != null) {
if (!Number.isInteger(b.months) || b.months < 1) {
errs.push("months must be a positive integer");
}
if (!b.validFrom?.trim()) {
errs.push("validFrom is required when months is set (validTo = validFrom + months)");
}
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
for (const c of b.credentials ?? []) {
if (c.kind !== "rf" && c.kind !== "qr") {
errs.push("each credential needs kind (rf|qr)");
break;
}
// RF must carry the physical card id; QR may be blank (server auto-generates).
if (c.kind === "rf" && !c.value?.trim()) {
errs.push("an RF credential needs a non-empty value (the card/tag id)");
break;
}
}
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
}
return errs;
}
function loadAggregate(id: string) {
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
if (!sub) return null;
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
return {
...sub,
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
plates: plates.map((p) => p.plate),
};
}
/** Is this credential value already used by ANY subscription? (Global uniqueness —
* a value is the lane identity, so it must resolve to one subscription.) */
function valueTaken(value: string): boolean {
return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null;
}
/** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */
function mintQrCode(): string {
for (let i = 0; i < 5; i += 1) {
const code = newQrCode();
if (!valueTaken(code)) return code;
}
throw new Error("could not mint a unique QR code");
}
// Replace a subscription's child rows (credentials + plates) from the body. QR
// credentials with no value are SERVER-GENERATED here (the customer never picks the
// code). The generated value is returned via loadAggregate so the UI can print it.
function writeChildren(id: string, b: SubscriptionBody) {
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
for (const c of b.credentials ?? []) {
const supplied = c.value?.trim();
// QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a
// QR being preserved on edit).
const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : "";
if (!value) continue; // guarded by validate(); defensive
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run();
}
for (const p of b.plates ?? []) {
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
}
}
/** Resolve the coverage end: months (validFrom + months) wins over an explicit validTo. */
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
if (b.months != null && b.validFrom?.trim()) return addMonths(b.validFrom.trim(), b.months);
if (b.validTo !== undefined) return b.validTo ?? null;
return fallback;
}
// List all subscriptions (with their credentials + plates).
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
const rows = db.select().from(subscriptions).all();
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 ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
const id = randomUUID();
db.insert(subscriptions)
.values({
id,
holderName: b.holderName ?? null,
contact: b.contact ?? null,
priceMinor: b.priceMinor ?? null,
period: b.period ?? "monthly",
currency: b.priceMinor != null ? (b.currency ?? null) : null,
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: resolveValidTo(b, null),
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
const sub = loadAggregate(id);
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
// a print failure NEVER fails the create (the subscription + its code are saved);
// the response carries { printed, printError } so the UI can warn + offer reprint.
const printResult = await tryPrintCard(sub);
return reply.code(201).send({ ...sub, ...printResult });
});
/** The first QR credential's code for a subscription aggregate, or null. */
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
const cred = sub?.credentials.find((c) => c.kind === "qr");
return cred?.value ?? null;
}
/** Best-effort print of a subscription's QR card. Returns a flag + optional error
* (never throws). No QR credential → nothing to print (printed:false, no error). */
async function tryPrintCard(
sub: ReturnType<typeof loadAggregate>,
): Promise<{ printed: boolean; printedBy?: string; printError?: string }> {
const code = qrCodeOf(sub);
if (!sub || !code) return { printed: false };
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return { printed: true, printedBy };
} catch (err) {
const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`);
return { printed: false, printError };
}
}
// Update a subscription (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
"/api/subscriptions/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "subscription not found" });
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
db.update(subscriptions)
.set({
holderName: b.holderName ?? null,
contact: b.contact ?? null,
priceMinor: b.priceMinor === undefined ? existing.priceMinor : b.priceMinor,
period: b.period ?? existing.period,
currency:
b.priceMinor === undefined
? existing.currency
: b.priceMinor != null
? (b.currency ?? null)
: null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: resolveValidTo(b, existing.validTo),
status: b.status ?? existing.status,
})
.where(eq(subscriptions.id, req.params.id))
.run();
writeChildren(req.params.id, b);
return loadAggregate(req.params.id);
},
);
// Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the
// customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if
// the subscription is gone; 409 if it has no QR credential; 503 if no printer.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/print",
{ preHandler: readGuard },
async (req, reply) => {
const sub = loadAggregate(req.params.id);
if (!sub) return reply.code(404).send({ error: "subscription not found" });
const code = qrCodeOf(sub);
if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" });
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
// Revoke (soft): the common case — keeps the subscription + its history, just bars
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
// DELETE only to fully remove one created in error.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/revoke",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
return loadAggregate(req.params.id);
},
);
// Hard delete a subscription + its child rows. (Past ledger events that reference it
// are untouched — the audit trail is append-only and independent of this row.)
app.delete<{ Params: { id: string } }>(
"/api/subscriptions/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
return reply.code(204).send();
},
);
}
+14 -5
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db"; import type { Db } from "@parking/db";
import type { Role } from "@parking/shared"; import type { Role } from "@parking/shared";
import { deviceEvents } from "../device-events.js"; import { deviceEvents } from "../device-events.js";
import type { DeviceMonitor } from "../device-monitor.js";
import { getOccupancy } from "../occupancy.js"; import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives // Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
@@ -49,11 +50,12 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
} }
type OutMsg = type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy> } | { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> } | { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { kind: "printer-status"; event: unknown }; | { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown };
export async function wsRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> {
app.get( app.get(
"/api/ws", "/api/ws",
{ {
@@ -83,8 +85,9 @@ export async function wsRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
}; };
// Initial snapshot so the client renders immediately, before any event. // Initial snapshot so the client renders immediately, before any event:
send({ kind: "hello", occupancy: getOccupancy(db) }); // occupancy AND the current device-status set (for the footer).
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() });
// Subscribe to the live buses. Each handler recomputes occupancy from the // Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative. // ledger (cheap fold) so the pushed count is always authoritative.
@@ -94,10 +97,16 @@ export async function wsRoutes(app: FastifyInstance, db: Db): Promise<void> {
const offPrinter = deviceEvents.onPrinterStatus((event) => { const offPrinter = deviceEvents.onPrinterStatus((event) => {
send({ kind: "printer-status", event }); send({ kind: "printer-status", event });
}); });
// Unified device status (all categories) for the booth footer — pushed on
// change; the initial set rode the hello above.
const offDevice = deviceEvents.onDeviceStatus((event) => {
send({ kind: "device-status", event });
});
socket.on("close", () => { socket.on("close", () => {
offLedger(); offLedger();
offPrinter(); offPrinter();
offDevice();
}); });
}, },
); );
+32 -12
View File
@@ -10,16 +10,18 @@ import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js"; import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js"; import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js"; import { PayStation } from "./pay-station.js";
import { PermitFlow } from "./permit-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 { buildSigner, buildVerifier } from "./signer.js"; import { buildSigner, buildVerifier } from "./signer.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.js"; import { payRoutes } from "./routes/pay.js";
import { permitRoutes } from "./routes/permits.js"; import { subscriptionRoutes } from "./routes/subscriptions.js";
import { qrReaderRoutes } from "./routes/qr-reader.js"; import { qrReaderRoutes } from "./routes/qr-reader.js";
import { shiftRoutes } from "./routes/shift.js"; import { shiftRoutes } from "./routes/shift.js";
import { siteRoutes } from "./routes/site.js"; import { siteRoutes } from "./routes/site.js";
@@ -27,6 +29,7 @@ import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js"; import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js"; import { setupRoutes } from "./routes/setup.js";
import { deviceStatusRoutes } from "./routes/device-status.js";
import { wsRoutes } from "./routes/ws.js"; import { wsRoutes } from "./routes/ws.js";
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify // The backend is Fastify (Node). Hardware drivers live as isolated Fastify
@@ -86,6 +89,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onReady", async () => printerMonitor.start()); app.addHook("onReady", async () => printerMonitor.start());
app.addHook("onClose", async () => printerMonitor.stop()); app.addHook("onClose", async () => printerMonitor.stop());
// Unified device-status monitor: polls EVERY configured device (relays/readers/
// cameras via healthCheck, printers via rich readStatus) and feeds the booth's
// device-status footer over the WS. Read-only — never drives a relay.
// See wiki/concepts/device-status-monitoring.md.
const deviceMonitor = new DeviceMonitor(db, app.log);
await deviceStatusRoutes(app, deviceMonitor);
app.addHook("onReady", async () => deviceMonitor.start());
app.addHook("onClose", async () => deviceMonitor.stop());
// Append-only signed business LEDGER (ledger_events). Holds only business facts // Append-only signed business LEDGER (ledger_events). Holds only business facts
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw // (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED // button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
@@ -101,7 +113,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Live booth feed: server-pushed ledger + occupancy + printer-status over a // Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts. // single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db); await wsRoutes(app, db, deviceMonitor);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts. // Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db); await snapshotRoutes(app, db);
@@ -117,20 +129,27 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onClose", async () => unsubscribeEntry()); app.addHook("onClose", async () => unsubscribeEntry());
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the // Read-driven flows: a credential read (ticket scan / plate / card) routes via the
// dispatcher to either the PERMIT flow (if it matches a permit) or the transient // dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md. // transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
// parking-session.md.
const exitFlow = new ExitFlow(db, eventLog, app.log); const exitFlow = new ExitFlow(db, eventLog, app.log);
const permitFlow = new PermitFlow(db, eventLog, app.log); const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log);
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log); const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
const unsubscribeRead = deviceEvents.onRead((e) => { const unsubscribeRead = deviceEvents.onRead((e) => {
void readDispatcher.dispatch(e); void readDispatcher.dispatch(e);
}); });
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
@@ -147,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);
// Permit (subscription) admin CRUD. See wiki/entities/permit.md. // Subscription admin CRUD + credential capture (arm/poll/cancel). See
await permitRoutes(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 -1
View File
@@ -19,7 +19,7 @@ import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
interface SnapshotJob { interface SnapshotJob {
readonly db: Db; readonly db: Db;
readonly direction: FlowDirection; readonly direction: FlowDirection;
/** Session/credential ref (ticket id, plate, permit car key) — links to the ledger. */ /** Session/credential ref (ticket id, plate, subscription car key) — links to the ledger. */
readonly identity: string; readonly identity: string;
readonly logger: FastifyBaseLogger; readonly logger: FastifyBaseLogger;
} }
+271
View File
@@ -0,0 +1,271 @@
import { randomUUID } from "node:crypto";
import {
eq,
ledgerEvents,
sessions,
subscriptionCredentials,
subscriptionPlates,
subscriptions,
type Db,
type DeviceRow,
} from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
// paying per stay (they're on a recurring plan). Reached from the read dispatcher
// when a read matches a subscription (not an open ticket). See
// wiki/entities/subscription.md.
//
// Two optional, independent bindings:
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
// subscription's cars may be inside at once; enforced over the session projection.
// - 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 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 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 (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";
}
export class SubscriptionFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** Resolve a read to a subscription (by card/QR credential, or a bound plate), or null. */
match(e: DeviceReadEvent): SubscriptionMatch | null {
// Card / QR / generic credential value.
const cred = this.#db
.select()
.from(subscriptionCredentials)
.where(eq(subscriptionCredentials.value, e.value))
.get();
if (cred) {
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
if (e.kind === "plate") {
const plate = this.#db.select().from(subscriptionPlates).where(eq(subscriptionPlates.plate, e.value)).get();
if (plate) return { subscriptionId: plate.subscriptionId, carKey: e.value, via: "plate" };
}
return null;
}
/** Run the subscription entry/exit for a matched read at a barrier. `resolved` is the
* reader's bound relay; its direction constrains, "both" defers to session state. */
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
const key = `${m.subscriptionId}:${m.carKey}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#run(resolved, e, m);
} catch (err) {
this.#logger.error(`subscription-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
if (!sub) return { accepted: false, reason: "subscription not found" };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
const invalid =
sub.status !== "active" ||
(sub.validFrom != null && now < sub.validFrom) ||
(sub.validTo != null && now > sub.validTo);
if (invalid) {
const reason = `subscription ${sub.status}/out-of-window`;
await this.#reject(m, reason);
return { accepted: false, 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";
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,
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", occurrenceId, "subscription exit");
this.#closeCache(occurrenceId);
return { accepted: true, direction: "exit" };
}
// 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,
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: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via },
occurredAt: now,
});
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
try {
this.#db
.insert(sessions)
.values({
id: occurrenceId,
identity: occurrenceId,
source: m.via === "plate" ? "lpr" : "wiegand",
subscriptionId: m.subscriptionId,
enteredAt: now,
state: "open",
})
.run();
} catch (err) {
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
}
return { accepted: true, direction: "entry" };
}
/**
* 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;
}
async #reject(m: SubscriptionMatch, reason: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity: m.carKey,
// `permitId`/`permitRefused` are the on-chain field names (immutable).
payload: { reason: `subscription refused — ${reason}`, permitId: m.subscriptionId, permitRefused: true },
});
this.#logger.warn(`subscription refused (${m.carKey}): ${reason}`);
}
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
void snapshotAsync({
db: this.#db,
direction: dir,
identity: carKey,
logger: this.#logger,
}).catch((err) => this.#logger.error(`subscription snapshot error: ${(err as Error).message}`));
}
#closeCache(carKey: string): void {
try {
this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`);
}
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
}
+7 -3
View File
@@ -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}
+54 -8
View File
@@ -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,16 +195,20 @@ 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
? t("pay.prepaid")
: s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency) ? formatMoney(s.amountMinor, s.currency)
: alreadyPaid : alreadyPaid
? t("booth.badgePaid") ? t("booth.badgePaid")
@@ -190,10 +216,17 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</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,6 +286,18 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
> >
{t("common.cancel")} {t("common.cancel")}
</button> </button>
{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 <button
type="button" type="button"
onClick={handlePayAndExit} onClick={handlePayAndExit}
@@ -273,6 +318,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
? t("pay.payAndVoucher") ? t("pay.payAndVoucher")
: t("pay.payAndOpen")} : t("pay.payAndOpen")}
</button> </button>
)}
</> </>
)} )}
</div> </div>
-191
View File
@@ -1,191 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createPermit,
deletePermit,
fetchPermits,
revokePermit,
updatePermit,
type Permit,
type PermitCredential,
type PermitInput,
} from "./api.js";
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
// credentials (card/QR) and bound plates. A permit is mutable master data; every
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
interface FormState {
holderName: string;
contact: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
validTo: string;
credentials: PermitCredential[];
platesText: string; // comma/space separated
}
function emptyForm(): FormState {
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
}
function formFrom(p: Permit): FormState {
return {
holderName: p.holderName ?? "",
contact: p.contact ?? "",
carBound: p.maxConcurrent != null,
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
validFrom: p.validFrom ?? "",
validTo: p.validTo ?? "",
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
platesText: p.plates.join(", "),
};
}
const STATUS_KEY: Record<Permit["status"], string> = {
active: "permits.statusActive",
suspended: "permits.statusSuspended",
revoked: "permits.statusRevoked",
};
function toInput(f: FormState): PermitInput {
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
validTo: f.validTo.trim() || null,
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
export function PermitManager() {
const { t } = useTranslation();
const [permits, setPermits] = useState<Permit[] | null>(null);
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);
function reload() {
fetchPermits()
.then((r) => setPermits(r.permits))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(reload, []);
function startNew() {
setForm(emptyForm());
setEditing("new");
setMsg(null);
}
function startEdit(p: Permit) {
setForm(formFrom(p));
setEditing(p.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") await createPermit(toInput(form));
else if (editing) await updatePermit(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: t("permits.permitSaved") });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doRevoke(p: Permit) {
if (!confirm(t("permits.confirmRevoke", { name: p.holderName ?? p.id }))) return;
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(p: Permit) {
if (!confirm(t("permits.confirmDelete", { name: p.holderName ?? p.id }))) return;
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<PermitCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
if (!permits) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>{t("permits.title")}</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{permits.map((p) => (
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{p.holderName ?? t("permits.unnamed")}</strong>
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[p.status])}</span>
<span style={{ color: "#666" }}>
{p.maxConcurrent == null ? t("permits.unbound") : t("permits.car", { count: p.maxConcurrent })} ·{" "}
{p.credentials.length} {t("permits.cred")} · {t("permits.plates", { count: p.plates.length })}
</span>
<span style={{ flex: 1 }} />
<button type="button" onClick={() => startEdit(p)}>{t("permits.edit")}</button>
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>{t("permits.revoke")}</button>}
<button type="button" onClick={() => doDelete(p)}>{t("permits.delete")}</button>
</li>
))}
{permits.length === 0 && <li style={{ color: "#777" }}>{t("permits.noPermitsYet")}</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>{t("permits.addPermit")}</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("permits.newPermit") : t("permits.editPermit")}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>{t("permits.holderName")}</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>{t("permits.contact")}</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>{t("permits.carLimit")}</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("permits.limitCarsInAtOnce")}
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>{t("permits.validFrom")}</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
<label>{t("permits.validTo")}</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
<label>{t("permits.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("permits.commaSeparatedOptional")} />
</div>
<h4 style={{ marginBottom: "0.25rem" }}>{t("permits.credentialsCardQr")}</h4>
{form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="rf">{t("permits.rfCardTag")}</option>
<option value="qr">{t("permits.qr")}</option>
</select>
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("permits.credentialValue")} style={{ flex: 1 }} />
<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: "rf", value: "" }] }))}>{t("permits.addCredential")}</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
{t("permits.needCredentialOrPlate")}
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>{t("permits.save")}</button>
<button type="button" onClick={() => setEditing(null)}>{t("permits.cancel")}</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+434
View File
@@ -0,0 +1,434 @@
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,
} from "./api.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
// subscription is mutable master data; every USE of it is a signed ledger event
// elsewhere. See wiki/entities/subscription.md.
const DEFAULT_CURRENCY = "ALL";
interface FormState {
holderName: string;
contact: string;
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
currency: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
validTo: string;
credentials: SubscriptionCredential[];
platesText: string; // comma/space separated
}
/** Today (UTC date, yyyy-mm-dd) for a sensible default validFrom on new subs. */
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
return {
holderName: "",
contact: "",
priceMajor: defaultPriceMajor,
currency,
carBound: true,
maxConcurrent: "1",
validFrom: todayISODate(),
months: "1",
validTo: "",
credentials: [{ kind: "qr", value: "" }],
platesText: "",
};
}
function formFrom(s: Subscription): FormState {
return {
holderName: s.holderName ?? "",
contact: s.contact ?? "",
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
currency: s.currency ?? DEFAULT_CURRENCY,
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
validFrom: s.validFrom ?? "",
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
validTo: s.validTo ?? "",
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
platesText: s.plates.join(", "),
};
}
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
* server's addMonths so the form can preview the coverage end. */
function addMonthsDate(date: string, months: number): string | null {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return null;
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString().slice(0, 10);
}
const STATUS_KEY: Record<Subscription["status"], string> = {
active: "subs.statusActive",
suspended: "subs.statusSuspended",
revoked: "subs.statusRevoked",
};
function toInput(f: FormState): SubscriptionInput {
const major = Number(f.priceMajor);
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
priceMinor: priceSet ? Math.round(major * 100) : null,
period: "monthly",
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
// months (with validFrom) drives validTo server-side; else send the explicit end.
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
validTo: f.validTo.trim() || null,
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
// server auto-generates the code. RF (and pre-existing QR) keep their value.
credentials: f.credentials
.filter((c) => c.kind === "qr" || c.value.trim())
.map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
function priceLabel(s: Subscription, t: (k: string) => string): string {
if (s.priceMinor == null) return t("subs.noPrice");
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
}
export function SubscriptionManager() {
const { t } = useTranslation();
const [subs, setSubs] = useState<Subscription[] | null>(null);
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
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()
.then((r) => setSubs(r.subscriptions))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(() => {
reload();
// Pull the site default monthly price to pre-fill new subscriptions.
fetchSiteConfig()
.then((c) => {
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
})
.catch(() => {
/* non-fatal — the form just won't pre-fill */
});
}, []);
function startNew() {
setForm(emptyForm(defaultPriceMajor));
setEditing("new");
setMsg(null);
}
function startEdit(s: Subscription) {
setForm(formFrom(s));
setEditing(s.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") {
const created = await createSubscription(toInput(form));
setEditing(null);
reload();
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
// operator can use "Print code" to retry).
if (created.printed) {
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
} else if (created.printError) {
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
} else {
setMsg({ kind: "ok", text: t("subs.saved") });
}
return;
}
if (editing) await updateSubscription(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: t("subs.saved") });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doPrint(s: Subscription) {
setMsg(null);
try {
const r = await printSubscription(s.id);
setMsg({ kind: "ok", text: t("subs.printedOn", { printer: r.printedBy }) });
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
async function doRevoke(s: Subscription) {
if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return;
await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(s: Subscription) {
if (!confirm(t("subs.confirmDelete", { name: s.holderName ?? s.id }))) return;
await deleteSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<SubscriptionCredential>) {
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));
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
const totalDue =
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
: null;
const coverageHint = coverageEnd
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
: null;
if (!subs) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>{t("subs.title")}</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{subs.map((s) => (
<li key={s.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{s.holderName ?? t("subs.unnamed")}</strong>
<span style={{ color: s.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[s.status])}</span>
<span style={{ color: "#0a7", fontVariantNumeric: "tabular-nums" }}>{priceLabel(s, t)}</span>
<span style={{ color: "#666" }}>
{s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "}
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
</span>
<span style={{ flex: 1 }} />
{/* Print code — only when the subscription has a QR credential to encode. */}
{s.credentials.some((c) => c.kind === "qr") && (
<button type="button" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
)}
<button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
{s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
<button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
</li>
))}
{subs.length === 0 && <li style={{ color: "#777" }}>{t("subs.noneYet")}</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>{t("subs.add")}</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("subs.new") : t("subs.editTitle")}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>{t("subs.holderName")}</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>{t("subs.contact")}</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>{t("subs.monthlyPrice")}</label>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<input
value={form.priceMajor}
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
inputMode="decimal"
placeholder={t("subs.pricePlaceholder")}
style={{ width: 110 }}
/>
<input value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} />
<span style={{ color: "#888" }}>/ {t("subs.perMonth")}</span>
</span>
<label>{t("subs.carLimit")}</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>{t("subs.validFrom")}</label>
<input type="date" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
<label>{t("subs.months")}</label>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
<input
value={form.months}
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
inputMode="numeric"
placeholder="1"
style={{ width: 50 }}
/>
<span style={{ color: "#888" }}>{t("subs.monthsHint")}</span>
{/* Live preview of the coverage end + the N×price total. */}
{coverageHint && <span style={{ color: "#0a7" }}>{coverageHint}</span>}
</span>
<label>{t("subs.validToOverride")}</label>
<input type="date" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
<label>{t("subs.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div>
<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: 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">{t("subs.rfCardTag")}</option>
</select>
{c.kind === "qr" ? (
// QR codes are server-generated. Blank → "will be generated"; an
// existing code is shown read-only (it can be printed; never typed).
c.value.trim() ? (
<input value={c.value} readOnly style={{ flex: 1, fontFamily: "ui-monospace, monospace", background: "#f6f6f6" }} />
) : (
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span>
)
) : (
// 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>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>{t("subs.save")}</button>
<button type="button" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+107 -16
View File
@@ -277,41 +277,100 @@ export function publishTariffVersion(body: {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
} }
// --- Permits -------------------------------------------------------------- // --- Subscriptions --------------------------------------------------------
export interface PermitCredential { export interface SubscriptionCredential {
kind: "rf" | "qr"; kind: "rf" | "qr";
value: string; value: string;
} }
export interface Permit { export interface Subscription {
id: string; id: string;
holderName: string | null; holderName: string | null;
contact: string | null; contact: string | null;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null; maxConcurrent: number | null;
validFrom: string | null; validFrom: string | null;
validTo: string | null; validTo: string | null;
status: "active" | "suspended" | "revoked"; status: "active" | "suspended" | "revoked";
credentials: PermitCredential[]; credentials: SubscriptionCredential[];
plates: string[]; plates: string[];
} }
export type PermitInput = Omit<Permit, "id" | "status"> & { /** A credential as SENT to the server: a QR value may be omitted/blank → the server
status?: Permit["status"]; * auto-generates an unguessable code. RF must carry the card id. */
export interface SubscriptionCredentialInput {
kind: "rf" | "qr";
value?: string;
}
export type SubscriptionInput = {
holderName: string | null;
contact: string | null;
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
months?: number | null;
status?: Subscription["status"];
credentials: SubscriptionCredentialInput[];
plates: string[];
}; };
export function fetchPermits(): Promise<{ permits: Permit[] }> { /** The create response = the saved subscription + the auto-print outcome. */
return apiFetch("/api/permits"); export type SubscriptionCreated = Subscription & {
printed: boolean;
printedBy?: string;
printError?: string;
};
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
return apiFetch("/api/subscriptions");
} }
export function createPermit(body: PermitInput): Promise<Permit> { export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
} }
export function updatePermit(id: string, body: PermitInput): Promise<Permit> { /** Re-print a subscription's QR card (failed auto-print / lost card). */
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) }); export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
} }
export function revokePermit(id: string): Promise<Permit> {
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" }); // --- Credential capture ("enroll a card" on a chosen reader) ---------------
export interface ReaderInfo {
id: string;
driverId: string;
direction: "entry" | "exit" | "both";
} }
export function deletePermit(id: string): Promise<void> { export type CaptureState =
return apiFetch(`/api/permits/${id}`, { method: "DELETE" }); | { 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) });
}
export function revokeSubscription(id: string): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" });
}
export function deleteSubscription(id: string): Promise<void> {
return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" });
} }
// --- Shifts --------------------------------------------------------------- // --- Shifts ---------------------------------------------------------------
@@ -378,6 +437,8 @@ export interface SiteConfig {
capacity: number | null; capacity: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */ /** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault: boolean; exitVoucherDefault: boolean;
/** Site default monthly subscription price (minor units); pre-fills the form. */
subscriptionMonthlyPriceMinor: number | null;
parkName: string | null; parkName: string | null;
operatorName: string | null; operatorName: string | null;
/** NIUS — Albanian tax/identification number. */ /** NIUS — Albanian tax/identification number. */
@@ -391,6 +452,28 @@ export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy"); return apiFetch("/api/occupancy");
} }
// --- Device status (the booth footer) -------------------------------------
/** Live status of one configured device — mirrors the server's DeviceStatusEvent.
* Every enabled device is polled (printers via rich readStatus, the rest via
* healthCheck) and flattened to one traffic-light. Pushed over the WS; the REST
* snapshot below is the initial load / fallback. */
export interface DeviceStatus {
deviceId: string;
driverId: string;
category: "access" | "reader" | "camera" | "printer";
/** Role/direction token for the footer label (NOT the vendor) — the client
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
state: "ready" | "degraded" | "offline";
detail?: string;
checkedAt: string;
}
export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
return apiFetch("/api/devices/status");
}
// --- Ledger events (the signed audit trail; read-only) -------------------- // --- Ledger events (the signed audit trail; read-only) --------------------
/** A persisted ledger row. Re-exported from shared so UI code has one source of /** A persisted ledger row. Re-exported from shared so UI code has one source of
@@ -424,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). */
@@ -443,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). */
+67 -11
View File
@@ -24,7 +24,7 @@ export const en: Catalog = {
shift: "Shift", shift: "Shift",
setup: "Setup", setup: "Setup",
tariff: "Tariff", tariff: "Tariff",
permits: "Permits", subscriptions: "Subscriptions",
site: "Site", site: "Site",
}, },
status: { status: {
@@ -32,6 +32,33 @@ export const en: Catalog = {
connecting: "CONNECTING", connecting: "CONNECTING",
offline: "OFFLINE", offline: "OFFLINE",
}, },
devices: {
footerTitle: "Devices",
none: "No devices configured.",
catAccess: "Barrier",
catReader: "Reader",
catCamera: "Camera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Reader entry").
role: {
entry: "entry",
exit: "exit",
both: "entry/exit",
mixed: "mixed",
lane: "lane",
booth: "booth",
},
state: {
ready: "ready",
degraded: "degraded",
offline: "offline",
},
allOk: "all ready",
issuesCount: "{{count}} with issues",
issuesTitle: "Device issues",
clickForIssues: "Click for details",
checkedAt: "checked {{time}}",
},
booth: { booth: {
processTicket: "Process ticket", processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…", scanPlaceholder: "Scan or type ticket number…",
@@ -58,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",
@@ -92,41 +120,64 @@ export const en: Catalog = {
publishing: "Publishing…", publishing: "Publishing…",
publishedOk: "New tariff version published — it's now the active rate card.", publishedOk: "New tariff version published — it's now the active rate card.",
}, },
permits: { subs: {
title: "Permits", title: "Subscriptions",
unnamed: "(unnamed)", unnamed: "(unnamed)",
unbound: "unbound", unbound: "unbound",
car_one: "{{count}} car", car_one: "{{count}} car",
car_other: "{{count}} cars", car_other: "{{count}} cars",
cred: "cred", cred: "cred",
plates: "{{count}} plate(s)", plates: "{{count}} plate(s)",
noPrice: "no price",
perMonth: "month",
monthlyPrice: "Monthly price",
pricePlaceholder: "e.g. 10000",
edit: "Edit", edit: "Edit",
revoke: "Revoke", revoke: "Revoke",
delete: "Delete", delete: "Delete",
noPermitsYet: "No permits yet.", noneYet: "No subscriptions yet.",
addPermit: "+ Add permit", add: "+ Add subscription",
newPermit: "New permit", new: "New subscription",
editPermit: "Edit permit", editTitle: "Edit subscription",
holderName: "Holder name", holderName: "Holder name",
contact: "Contact", contact: "Contact",
carLimit: "Car limit", carLimit: "Car limit",
limitCarsInAtOnce: "limit cars in at once", limitCarsInAtOnce: "limit cars in at once",
validFrom: "Valid from", validFrom: "Valid from",
validTo: "Valid to", validTo: "Valid to",
months: "Months",
monthsHint: "months paid",
coverageHint: "until {{end}}",
totalDue: "total {{total}}",
validToOverride: "Valid to (manual)",
isoDateOptional: "ISO date (optional)", isoDateOptional: "ISO date (optional)",
boundPlates: "Bound plates", boundPlates: "Bound plates",
commaSeparatedOptional: "comma-separated (optional)", commaSeparatedOptional: "comma-separated (optional)",
credentials: "Credentials",
credentialsCardQr: "Credentials (card / QR)", credentialsCardQr: "Credentials (card / QR)",
rfCardTag: "RF card/tag", 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", qr: "QR",
qrAutoGen: "QR code is auto-generated on save",
credentialValue: "credential value", credentialValue: "credential value",
addCredential: "+ credential", addCredential: "+ credential",
needCredentialOrPlate: "A permit needs at least one credential OR one bound plate.", needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.",
save: "Save", save: "Save",
cancel: "Cancel", cancel: "Cancel",
permitSaved: "Permit saved.", saved: "Subscription saved.",
confirmRevoke: "Revoke permit for {{name}}? It will be refused at the barrier.", savedPrinted: "Subscription saved — QR code printed.",
confirmDelete: "Delete permit for {{name}}? (Past events are kept.)", savedPrintFailed: "Subscription saved, but printing failed ({{error}}). Use \"Print code\".",
printCode: "Print code",
printedOn: "Code printed on {{printer}}.",
confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
statusActive: "active", statusActive: "active",
statusSuspended: "suspended", statusSuspended: "suspended",
statusRevoked: "revoked", statusRevoked: "revoked",
@@ -225,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…",
+72 -16
View File
@@ -24,7 +24,7 @@ export const sq = {
shift: "Turni", shift: "Turni",
setup: "Konfigurimi", setup: "Konfigurimi",
tariff: "Tarifa", tariff: "Tarifa",
permits: "Lejet", subscriptions: "Abonimet",
site: "Vendi", site: "Vendi",
}, },
status: { status: {
@@ -32,16 +32,43 @@ export const sq = {
connecting: "DUKE U LIDHUR", connecting: "DUKE U LIDHUR",
offline: "JASHTË LINJE", offline: "JASHTË LINJE",
}, },
devices: {
footerTitle: "Pajisjet",
none: "Asnjë pajisje e konfiguruar.",
catAccess: "Barriera",
catReader: "Lexuesi",
catCamera: "Kamera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
role: {
entry: "hyrje",
exit: "dalje",
both: "hyrje/dalje",
mixed: "i përzier",
lane: "korsia",
booth: "kabina",
},
state: {
ready: "gati",
degraded: "i dëmtuar",
offline: "jashtë linje",
},
allOk: "të gjitha gati",
issuesCount: "{{count}} me probleme",
issuesTitle: "Problemet e pajisjeve",
clickForIssues: "Kliko për detajet",
checkedAt: "kontrolluar {{time}}",
},
booth: { booth: {
processTicket: "Proceso biletën", processTicket: "Proceso biletën",
scanPlaceholder: "Skano ose shkruaj numrin e biletës…", scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
open: "Hap", open: "Hap",
occupancy: "Zënia", occupancy: "Prania",
occUnavailable: "zënia e padisponueshme", occUnavailable: "zënia e padisponueshme",
inside: "brenda", inside: "brenda",
of: "nga", of: "nga",
uncapped: "pa kufi", uncapped: "pa kufi",
free: "lirë", free: "Vende të lira",
lotFull: "● parkimi plot", lotFull: "● parkimi plot",
liveFeed: "Aktiviteti live", liveFeed: "Aktiviteti live",
events: "ngjarje", events: "ngjarje",
@@ -52,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",
@@ -94,47 +122,70 @@ export const sq = {
publishing: "Duke publikuar…", publishing: "Duke publikuar…",
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.", publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
}, },
permits: { subs: {
title: "Lejet", title: "Abonimet",
unnamed: "(pa emër)", unnamed: "(pa emër)",
unbound: "pa kufizim", unbound: "pa kufizim",
car_one: "{{count}} makinë", car_one: "{{count}} makinë",
car_other: "{{count}} makina", car_other: "{{count}} makina",
cred: "kredencial", cred: "kredencial",
plates: "{{count}} targë(a)", plates: "{{count}} targë(a)",
noPrice: "pa çmim",
perMonth: "muaj",
monthlyPrice: "Çmimi mujor",
pricePlaceholder: "p.sh. 10000",
edit: "Ndrysho", edit: "Ndrysho",
revoke: "Anulo", revoke: "Anulo",
delete: "Fshij", delete: "Fshij",
noPermitsYet: "Asnjë leje ende.", noneYet: "Asnjë abonim ende.",
addPermit: "+ Shto leje", add: "+ Shto abonim",
newPermit: "Leje e re", new: "Abonim i ri",
editPermit: "Ndrysho lejen", editTitle: "Ndrysho abonimin",
holderName: "Emri i mbajtësit", holderName: "Emri i mbajtësit",
contact: "Kontakti", contact: "Kontakti",
carLimit: "Kufiri i makinave", carLimit: "Kufiri i makinave",
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht", limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
validFrom: "Vlen nga", validFrom: "Vlen nga",
validTo: "Vlen deri", validTo: "Vlen deri",
months: "Muaj",
monthsHint: "muaj të paguar",
coverageHint: "deri më {{end}}",
totalDue: "gjithsej {{total}}",
validToOverride: "Vlen deri (manual)",
isoDateOptional: "Datë ISO (opsionale)", isoDateOptional: "Datë ISO (opsionale)",
boundPlates: "Targat e lidhura", boundPlates: "Targat e lidhura",
commaSeparatedOptional: "të ndara me presje (opsionale)", commaSeparatedOptional: "të ndara me presje (opsionale)",
credentials: "Kredencialet",
credentialsCardQr: "Kredencialet (kartë / QR)", credentialsCardQr: "Kredencialet (kartë / QR)",
rfCardTag: "Kartë/etiketë RF", 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", qr: "QR",
qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje",
credentialValue: "vlera e kredencialit", credentialValue: "vlera e kredencialit",
addCredential: "+ kredencial", addCredential: "+ kredencial",
needCredentialOrPlate: "Një leje kërkon të paktën një kredencial OSE një targë të lidhur.", needCredentialOrPlate: "Një abonim kërkon të paktën një kredencial OSE një targë të lidhur.",
save: "Ruaj", save: "Ruaj",
cancel: "Anulo", cancel: "Anulo",
permitSaved: "Leja u ruajt.", saved: "Abonimi u ruajt.",
confirmRevoke: "Të anulohet leja për {{name}}? Do të refuzohet te barriera.", savedPrinted: "Abonimi u ruajt — kodi QR u printua.",
confirmDelete: "Të fshihet leja për {{name}}? (Ngjarjet e kaluara ruhen.)", savedPrintFailed: "Abonimi u ruajt, por printimi dështoi ({{error}}). Përdor \"Printo kodin\".",
statusActive: "aktive", printCode: "Printo kodin",
printedOn: "Kodi u printua te {{printer}}.",
confirmRevoke: "Të anulohet abonimi për {{name}}? Do të refuzohet te barriera.",
confirmDelete: "Të fshihet abonimi për {{name}}? (Ngjarjet e kaluara ruhen.)",
statusActive: "aktiv",
statusSuspended: "pezulluar", statusSuspended: "pezulluar",
statusRevoked: "anuluar", statusRevoked: "anuluar",
}, },
site: { site: {
occupancy: "Zënia:", occupancy: "Prania:",
noCapacitySet: "(pa kapacitet të caktuar)", noCapacitySet: "(pa kapacitet të caktuar)",
free: "lirë", free: "lirë",
full: "PLOT", full: "PLOT",
@@ -227,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",
+19 -2
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { LedgerEvent, Occupancy } from "../api.js"; import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
// CLIENT state for the live booth feed — deliberately small. Server data (the // CLIENT state for the live booth feed — deliberately small. Server data (the
// authoritative event list, occupancy totals) is owned by TanStack Query; this // authoritative event list, occupancy totals) is owned by TanStack Query; this
@@ -20,16 +20,31 @@ interface LiveState {
occupancy: Occupancy | null; occupancy: Occupancy | null;
/** Newest-first tail of recently pushed ledger events (for the live ticker). */ /** Newest-first tail of recently pushed ledger events (for the live ticker). */
feed: LedgerEvent[]; feed: LedgerEvent[];
/** Live device status keyed by device id (for the footer): set from the WS
* hello snapshot, then upserted per device on each device-status push. */
devices: Record<string, DeviceStatus>;
setStatus: (s: WsStatus) => void; setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void; setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void; pushEvent: (e: LedgerEvent) => void;
/** Replace the whole device-status set (WS hello / reconnect snapshot). */
setDevices: (list: DeviceStatus[]) => void;
/** Upsert one device's status (a device-status push). */
upsertDevice: (d: DeviceStatus) => void;
reset: () => void; reset: () => void;
} }
/** Index a device-status list by device id. */
function byId(list: DeviceStatus[]): Record<string, DeviceStatus> {
const m: Record<string, DeviceStatus> = {};
for (const d of list) m[d.deviceId] = d;
return m;
}
export const useLiveStore = create<LiveState>((set) => ({ export const useLiveStore = create<LiveState>((set) => ({
status: "connecting", status: "connecting",
occupancy: null, occupancy: null,
feed: [], feed: [],
devices: {},
setStatus: (status) => set({ status }), setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }), setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) => pushEvent: (e) =>
@@ -37,5 +52,7 @@ export const useLiveStore = create<LiveState>((set) => ({
// Newest first; de-dupe by id (a reconnect can replay) and cap the length. // Newest first; de-dupe by id (a reconnect can replay) and cap the length.
feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED), feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED),
})), })),
reset: () => set({ status: "connecting", occupancy: null, feed: [] }), setDevices: (list) => set({ devices: byId(list) }),
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }),
})); }));
+1
View File
@@ -26,4 +26,5 @@ export const qk = {
activeSessions: ["active-sessions"] as const, activeSessions: ["active-sessions"] as const,
siteConfig: ["site-config"] as const, siteConfig: ["site-config"] as const,
shift: ["shift"] as const, shift: ["shift"] as const,
deviceStatus: ["device-status"] as const,
} as const; } as const;
+9 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { LedgerEvent, Occupancy } from "../api.js"; import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js"; import { qk } from "./query.js";
import { useLiveStore } from "./live-store.js"; import { useLiveStore } from "./live-store.js";
@@ -13,9 +13,10 @@ import { useLiveStore } from "./live-store.js";
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */ /** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage = type WsMessage =
| { kind: "hello"; occupancy: Occupancy } | { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy } | { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown }; | { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus };
/** Build the ws:// or wss:// URL for the same origin the SPA is served from. */ /** Build the ws:// or wss:// URL for the same origin the SPA is served from. */
function wsUrl(): string { function wsUrl(): string {
@@ -25,7 +26,7 @@ function wsUrl(): string {
export function useLiveFeed(): void { export function useLiveFeed(): void {
const qc = useQueryClient(); const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent } = useLiveStore(); const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode // Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount. // double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null); const sockRef = useRef<WebSocket | null>(null);
@@ -55,6 +56,10 @@ export function useLiveFeed(): void {
} }
if (msg.kind === "hello") { if (msg.kind === "hello") {
setOccupancy(msg.occupancy); setOccupancy(msg.occupancy);
// Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices);
} else if (msg.kind === "device-status") {
upsertDevice(msg.event);
} else if (msg.kind === "ledger") { } else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy); setOccupancy(msg.occupancy);
pushEvent(msg.event); pushEvent(msg.event);
+9 -6
View File
@@ -15,11 +15,12 @@ import { qk, queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js"; import { setLanguage } from "./lib/i18n/index.js";
import { useLiveFeed } from "./lib/use-live-feed.js"; import { useLiveFeed } from "./lib/use-live-feed.js";
import { useShift } from "./lib/use-shift.js"; import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js";
import { StatusDot } from "./ui/StatusDot.js"; import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js"; import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js"; import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js"; import { TariffComposer } from "./TariffComposer.js";
import { PermitManager } from "./PermitManager.js"; import { SubscriptionManager } from "./SubscriptionManager.js";
import { ShiftControl } from "./ShiftControl.js"; import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js"; import { SiteSettings } from "./SiteSettings.js";
@@ -164,7 +165,7 @@ function RootLayout() {
<NavLink to="/shift" label={t("nav.shift")} /> <NavLink to="/shift" label={t("nav.shift")} />
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />} {isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />} {isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
{isAdmin && <NavLink to="/permits" label={t("nav.permits")} />} {isAdmin && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
{isAdmin && <NavLink to="/site" label={t("nav.site")} />} {isAdmin && <NavLink to="/site" label={t("nav.site")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
@@ -189,6 +190,8 @@ function RootLayout() {
<main className="min-h-0 flex-1 overflow-auto p-3"> <main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet /> <Outlet />
</main> </main>
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
{user && <DeviceFooter />}
</div> </div>
); );
} }
@@ -233,11 +236,11 @@ const tariffRoute = createRoute({
beforeLoad: ({ context }) => adminOnly(context), beforeLoad: ({ context }) => adminOnly(context),
component: () => <TariffComposer />, component: () => <TariffComposer />,
}); });
const permitsRoute = createRoute({ const subscriptionsRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/permits", path: "/subscriptions",
beforeLoad: ({ context }) => adminOnly(context), beforeLoad: ({ context }) => adminOnly(context),
component: () => <PermitManager />, component: () => <SubscriptionManager />,
}); });
const siteRoute = createRoute({ const siteRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@@ -252,7 +255,7 @@ const routeTree = rootRoute.addChildren([
shiftRoute, shiftRoute,
setupRoute, setupRoute,
tariffRoute, tariffRoute,
permitsRoute, subscriptionsRoute,
siteRoute, siteRoute,
]); ]);
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
import { qk } from "../lib/query.js";
import { useLiveStore } from "../lib/live-store.js";
// Fixed device-status footer for the booth chrome. One compact chip per configured
// device — relays, readers, cameras, printers — labelled by ROLE, never vendor
// (e.g. "Lexuesi hyrje", "Printer kabina", "Kamera dalje"), with a traffic-light
// dot. Fault detail does NOT pollute the footer: clicking opens a small panel that
// lists the degraded/offline devices and their issues. Status is fed by the
// DeviceMonitor over the WS (snapshot on connect + per-device pushes, held in the
// live store); a REST snapshot seeds it / fills in if the WS is briefly down.
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
const DOT: Record<DeviceStatus["state"], string> = {
ready: "bg-term-green",
degraded: "bg-term-amber",
offline: "bg-term-red",
};
const TEXT: Record<DeviceStatus["state"], string> = {
ready: "text-term-text",
degraded: "text-term-amber",
offline: "text-term-red",
};
/** i18n key for a device category. */
const CATEGORY_KEY: Record<DeviceStatus["category"], string> = {
access: "devices.catAccess",
reader: "devices.catReader",
camera: "devices.catCamera",
printer: "devices.catPrinter",
};
/** i18n key for the role/direction token (null = no suffix). */
function roleKey(roleKind: DeviceStatus["roleKind"]): string | null {
return roleKind ? `devices.role.${roleKind}` : null;
}
/** Stable display order: access (barrier) first, then readers, cameras, printers. */
const ORDER: Record<DeviceStatus["category"], number> = {
access: 0,
reader: 1,
camera: 2,
printer: 3,
};
/** "Lexuesi hyrje" — category word + localised role/direction (when known). */
function useLabel() {
const { t } = useTranslation();
return (d: DeviceStatus) => {
const cat = t(CATEGORY_KEY[d.category]);
const rk = roleKey(d.roleKind);
return rk ? `${cat} ${t(rk)}` : cat;
};
}
function sortDevices(list: DeviceStatus[]): DeviceStatus[] {
return [...list].sort(
(a, b) => ORDER[a.category] - ORDER[b.category] || (a.roleKind ?? "").localeCompare(b.roleKind ?? ""),
);
}
export function DeviceFooter() {
const { t } = useTranslation();
const label = useLabel();
// Seed/fallback from REST; the WS keeps the live store authoritative thereafter.
const seed = useQuery({ queryKey: qk.deviceStatus, queryFn: fetchDeviceStatus });
const live = useLiveStore((s) => s.devices);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLElement>(null);
// Close the issues panel on an outside click or Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
// Prefer the live store (WS); fall back to the REST snapshot before the first push.
const fromLive = Object.values(live);
const devices = sortDevices(fromLive.length > 0 ? fromLive : seed.data?.devices ?? []);
const problems = devices.filter((d) => d.state !== "ready");
return (
<footer
ref={rootRef}
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[11px]"
>
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
{t("devices.footerTitle")}
</span>
<div className="flex items-center gap-1.5 overflow-x-auto">
{devices.length === 0 ? (
<span className="text-term-muted">{t("devices.none")}</span>
) : (
devices.map((d) => {
const isProblem = d.state !== "ready";
return (
<button
key={d.deviceId}
type="button"
// Only a problem chip is interactive (opens the issues panel).
onClick={isProblem ? () => setOpen((v) => !v) : undefined}
aria-disabled={!isProblem}
title={isProblem ? t("devices.clickForIssues") : undefined}
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-term border border-term-border bg-term-panel-2 px-2 py-0.5 ${
isProblem ? "cursor-pointer hover:border-term-amber" : "cursor-default"
}`}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]} ${
d.state === "offline" ? "animate-pulse" : ""
}`}
/>
<span className={TEXT[d.state]}>{label(d)}</span>
</button>
);
})
)}
</div>
{/* Right-aligned roll-up; clicking opens the issues panel when any exist. */}
<button
type="button"
disabled={problems.length === 0}
onClick={() => setOpen((v) => !v)}
className="ml-auto shrink-0 tabular-nums disabled:cursor-default"
>
{problems.length === 0 ? (
devices.length > 0 ? (
<span className="text-term-green">{t("devices.allOk")}</span>
) : null
) : (
<span className="text-term-amber hover:underline">
{t("devices.issuesCount", { count: problems.length })}
</span>
)}
</button>
{/* Issues panel — anchored above the footer, lists only problem devices. */}
{open && problems.length > 0 && (
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
{t("devices.issuesTitle")}
</span>
<button
type="button"
onClick={() => setOpen(false)}
className="text-term-muted hover:text-term-text"
aria-label={t("common.close")}
>
✕
</button>
</div>
<ul className="max-h-[40vh] overflow-y-auto p-1.5">
{problems.map((d) => (
<li
key={d.deviceId}
className="flex items-start gap-2 border-b border-term-border/40 px-1.5 py-1.5 last:border-b-0"
>
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
{t(`devices.state.${d.state}`)}
</span>
</div>
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
</div>
</div>
</li>
))}
</ul>
</div>
)}
</footer>
);
}
@@ -0,0 +1,25 @@
-- Rename permit → subscription (master data only). The signed ledger keeps its
-- immutable `permitId` payload — NOT touched here. Data-preserving ALTER RENAMEs
-- (SQLite 3.25+) rather than drop/recreate, so existing subscriptions survive.
-- Adds per-subscription pricing (price_minor + period + currency) and a site-wide
-- default monthly price. See wiki/entities/subscription.md.
ALTER TABLE `permits` RENAME TO `subscriptions`;
--> statement-breakpoint
ALTER TABLE `permit_credentials` RENAME TO `subscription_credentials`;
--> statement-breakpoint
ALTER TABLE `subscription_credentials` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `permit_plates` RENAME TO `subscription_plates`;
--> statement-breakpoint
ALTER TABLE `subscription_plates` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `price_minor` integer;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `period` text DEFAULT 'monthly' NOT NULL;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `currency` text;
--> statement-breakpoint
ALTER TABLE `sessions` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `site_config` ADD `subscription_monthly_price_minor` integer;
+7
View File
@@ -29,6 +29,13 @@
"when": 1781774228086, "when": 1781774228086,
"tag": "0003_early_hawkeye", "tag": "0003_early_hawkeye",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1781800000000,
"tag": "0004_subscriptions_rename",
"breakpoints": true
} }
] ]
} }
+38 -21
View File
@@ -9,7 +9,7 @@ import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md. // tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input). // • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md. // High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a // - Business master data (tariffs/subscriptions/blocklist) IS mutable, but its USE is fixed in a
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned: // signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md. // editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first). // - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
@@ -93,7 +93,7 @@ export const snapshots = sqliteTable("snapshots", {
direction: text("direction", { enum: ["entry", "exit"] }).notNull(), direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
// The camera `devices` instance that captured it (raw provenance). // The camera `devices` instance that captured it (raw provenance).
deviceId: text("device_id"), deviceId: text("device_id"),
// The session/credential ref (ticket id, plate, permit) — links to the ledger event. // The session/credential ref (ticket id, plate, subscription) — links to the ledger event.
identity: text("identity"), identity: text("identity"),
contentType: text("content_type").notNull(), contentType: text("content_type").notNull(),
bytes: blob("bytes").notNull().$type<Buffer>(), bytes: blob("bytes").notNull().$type<Buffer>(),
@@ -166,6 +166,11 @@ export const siteConfig = sqliteTable("site_config", {
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" }) exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
/** Default monthly subscription price in minor units (e.g. 1000000 = 10,000.00).
* A starting value the subscription form pre-fills; each subscription stores its
* own price and may differ. null = no site default set. See
* wiki/entities/subscription.md. */
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
updatedAt: text("updated_at") updatedAt: text("updated_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@@ -203,16 +208,28 @@ export const tariffVersions = sqliteTable("tariff_versions", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// --- Permits (subscriptions) --------------------------------------------- // --- Subscriptions --------------------------------------------------------
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event. // A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null = // month) instead of paying per stay. Mutable master data; every USE still produces a
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a // signed vehicle_entry/exit ledger event. Two optional, independent bindings:
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md. // car-count (maxConcurrent, default 1, null = unbound) and plate (plates rows,
export const permits = sqliteTable("permits", { // default none = any car). Identity = card/QR OR a matching plate (LPR/ANPR future).
// Pricing: priceMinor + period + currency record the plan; collecting the fee into
// the ledger/shift is deferred. See wiki/entities/subscription.md.
// NB: signed ledger events still carry `permitId` in their payload — immutable
// history, intentionally NOT renamed. These tables are the mutable master data,
// renamed permit→subscription in migration 0004.
export const subscriptions = sqliteTable("subscriptions", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
holderName: text("holder_name"), holderName: text("holder_name"),
contact: text("contact"), contact: text("contact"),
// Car-count binding: how many of the permit's cars may be inside at once. // Recurring price for the plan, in minor units (e.g. 1000000 = 10,000.00 ALL).
// null = no price set (comp/legacy). The `period` says what it recurs over.
priceMinor: integer("price_minor"),
period: text("period", { enum: ["monthly"] }).notNull().default("monthly"),
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
currency: text("currency"),
// Car-count binding: how many of the subscription's cars may be inside at once.
// null = unbound. Default 1. // null = unbound. Default 1.
maxConcurrent: integer("max_concurrent").default(1), maxConcurrent: integer("max_concurrent").default(1),
validFrom: text("valid_from"), validFrom: text("valid_from"),
@@ -225,19 +242,19 @@ export const permits = sqliteTable("permits", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane. // A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
export const permitCredentials = sqliteTable("permit_credentials", { export const subscriptionCredentials = sqliteTable("subscription_credentials", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
permitId: text("permit_id").notNull(), subscriptionId: text("subscription_id").notNull(),
kind: text("kind", { enum: ["rf", "qr"] }).notNull(), kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
value: text("value").notNull(), value: text("value").notNull(),
}); });
// Plate binding (optional). When a permit has plate rows, a matching plate read is // Plate binding (optional). When a subscription has plate rows, a matching plate read
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car). // is itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
export const permitPlates = sqliteTable("permit_plates", { export const subscriptionPlates = sqliteTable("subscription_plates", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
permitId: text("permit_id").notNull(), subscriptionId: text("subscription_id").notNull(),
plate: text("plate").notNull(), plate: text("plate").notNull(),
}); });
@@ -266,8 +283,8 @@ export const sessions = sqliteTable("sessions", {
// Identity that opened the session, and how it was read. // Identity that opened the session, and how it was read.
identity: text("identity"), identity: text("identity"),
source: text("source"), source: text("source"),
// null while transient; set when matched to a permit. // null while transient; set when matched to a subscription.
permitId: text("permit_id"), subscriptionId: text("subscription_id"),
enteredAt: text("entered_at").notNull(), enteredAt: text("entered_at").notNull(),
// null until exit; presence = CLOSED. // null until exit; presence = CLOSED.
exitedAt: text("exited_at"), exitedAt: text("exited_at"),
@@ -288,8 +305,8 @@ export type SetupStateRow = typeof setupState.$inferSelect;
export type SiteConfigRow = typeof siteConfig.$inferSelect; export type SiteConfigRow = typeof siteConfig.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect; export type TariffVersionRow = typeof tariffVersions.$inferSelect;
export type PermitRow = typeof permits.$inferSelect; export type SubscriptionRow = typeof subscriptions.$inferSelect;
export type PermitCredentialRow = typeof permitCredentials.$inferSelect; export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
export type PermitPlateRow = typeof permitPlates.$inferSelect; export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
export type BlocklistRow = typeof blocklist.$inferSelect; export type BlocklistRow = typeof blocklist.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect; export type SessionRow = typeof sessions.$inferSelect;
@@ -7,6 +7,7 @@ import type {
PrinterDevice, PrinterDevice,
PrinterStatus, PrinterStatus,
PrintReport, PrintReport,
SubscriptionCardData,
TicketData, TicketData,
} from "../interfaces.js"; } from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
@@ -108,6 +109,37 @@ function code128(data: string): Buffer {
]); ]);
} }
// --- 2D QR symbol (printer-generated via ESC/POS GS ( k) -----------------------
// A true QR for the SUBSCRIPTION card — the subscriber scans it at the reader (which
// reads QR + 1D barcode) every entry/exit for the coverage period. The board renders
// the QR from these GS ( k commands (no bitmap, no dependency), same approach as
// code128. We also print the code as text below as the hand-key fallback. The QR
// "model 2" sequence: set model → set module size → set error-correction → store the
// data in symbol storage → print it. See ESC/POS GS ( k (function 165/167/169/180/181).
/** A QR code via ESC/POS `GS ( k`. `size` = module dot size (1–16; 6 ≈ readable on
* 80mm at short range). Error-correction level M (15%) — robust to a smudged print. */
function qrCode(data: string, size = 6): Buffer {
const bytes = Buffer.from(data, "ascii");
// pL/pH encode the data length + 3 (the cn,fn,m header bytes) for function 180.
const store = bytes.length + 3;
const pL = store & 0xff;
const pH = (store >> 8) & 0xff;
return Buffer.concat([
// fn 165: select QR model — 1d 28 6b 04 00 31 41 <model=50(2)> 00
Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]),
// fn 167: module size — 1d 28 6b 03 00 31 43 <size>
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, size]),
// fn 169: error correction level — 1d 28 6b 03 00 31 45 <49=M>
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31]),
// fn 180: store the symbol data — 1d 28 6b pL pH 31 50 30 <data>
Buffer.from([GS, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30]),
bytes,
// fn 181: print the stored symbol — 1d 28 6b 03 00 31 51 30
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]),
]);
}
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in // Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this // one place so a real i18n layer (per-locale tables + a t() helper) can replace this
// later without touching the render functions. See wiki/concepts/site-metadata.md. // later without touching the render functions. See wiki/concepts/site-metadata.md.
@@ -118,6 +150,12 @@ const STR = {
issuedAt: (v: string) => `Printuar më: ${v}`, issuedAt: (v: string) => `Printuar më: ${v}`,
/** "Lost your ticket? <phone>" footer; printed only when a phone is set. */ /** "Lost your ticket? <phone>" footer; printed only when a phone is set. */
lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`, lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`,
/** Subscription-card title. */
subscription: "ABONIM",
/** "Holder: <name>" line on the card. */
holder: (name: string) => `Mbajtësi: ${name}`,
/** "Valid: <from> – <to>" line on the card. */
validity: (from: string, to: string) => `Vlen: ${from} – ${to}`,
} as const; } as const;
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */ /** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
@@ -178,6 +216,35 @@ function renderTicket(data: TicketData): Buffer {
]); ]);
} }
/** Build the ESC/POS byte stream for a SUBSCRIPTION CARD: park header → a scannable
* QR of the code → the code in text (hand-key fallback) → holder + validity window.
* The subscriber keeps this and scans the QR at the reader every entry/exit. */
function renderSubscriptionCard(data: SubscriptionCardData): Buffer {
const parts: Buffer[] = [
INIT,
SELECT_CP852,
renderHeader(data.header),
line(),
BOLD_ON,
line(STR.subscription),
BOLD_OFF,
line(),
ALIGN_CENTER,
qrCode(data.code),
line(),
// The code in text, as the fallback if the QR won't scan.
line(data.code),
ALIGN_LEFT,
line(),
];
if (data.holderName) parts.push(line(STR.holder(data.holderName)));
if (data.validFrom || data.validTo) {
parts.push(line(STR.validity(data.validFrom ?? "—", data.validTo ?? "—")));
}
parts.push(FEED_AND_CUT);
return Buffer.concat(parts);
}
/** Open a TCP socket, write the bytes, wait for flush, then close. */ /** Open a TCP socket, write the bytes, wait for flush, then close. */
function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> { function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -320,6 +387,11 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`); stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`);
} }
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw(this.#host, this.#port, renderSubscriptionCard(data), this.#timeout);
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
/** /**
* Live operator-actionable status, scraped from the device's own status page. * Live operator-actionable status, scraped from the device's own status page.
* The board decodes the ESC/POS status bits itself, so we trust its Yes/No * The board decodes the ESC/POS status bits itself, so we trust its Yes/No
+16
View File
@@ -209,12 +209,28 @@ export interface TicketData {
readonly header?: TicketHeader; readonly header?: TicketHeader;
} }
/** A subscription card: the customer's keepsake, printed at the booth on creation
* (and re-printable). The driver renders the `code` as a SCANNABLE QR (the
* subscriber scans it every entry/exit) plus the code as text + the holder/validity.
* See wiki/entities/subscription.md. */
export interface SubscriptionCardData {
/** The credential value to encode in the QR (e.g. "SUB-…"). */
readonly code: string;
readonly holderName?: string | null;
/** Coverage window, for the printed card (human-readable already, or ISO). */
readonly validFrom?: string | null;
readonly validTo?: string | null;
readonly header?: TicketHeader;
}
export interface PrinterDevice extends Device { export interface PrinterDevice extends Device {
printTicket(data: TicketData): Promise<void>; printTicket(data: TicketData): Promise<void>;
/** Print a free-form text report (a shift Z-report, a receipt). `lines` are /** Print a free-form text report (a shift Z-report, a receipt). `lines` are
* printed as-is; the driver adds a header/cut. Kept generic so the business * printed as-is; the driver adds a header/cut. Kept generic so the business
* layer composes the content. See wiki/concepts/shift.md. */ * layer composes the content. See wiki/concepts/shift.md. */
printReport(report: PrintReport): Promise<void>; printReport(report: PrintReport): Promise<void>;
/** Print a subscription card: a scannable QR of the code + holder/validity. */
printSubscriptionCard(data: SubscriptionCardData): Promise<void>;
} }
export interface PrintReport { export interface PrintReport {
+3 -3
View File
@@ -14,7 +14,7 @@ session projection.
## The rule ## The rule
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an An identity (ticket id, [[subscription]] credential, or plate) **must not enter while it already has an
OPEN [[parking-session|session]].** At entry: OPEN [[parking-session|session]].** At entry:
``` ```
@@ -25,13 +25,13 @@ identify vehicle → is there already an OPEN session for this id?
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`. powers [[capacity-occupancy]] and [[subscription]] `maxConcurrent`.
## Interaction with the limits already designed ## Interaction with the limits already designed
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same - **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
id is always a violation (or a re-print/duplication attempt). id is always a violation (or a re-print/duplication attempt).
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a - **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[subscription]]): a
multi-car permit legitimately has several open sessions, but **the same car/credential** entering multi-car permit legitimately has several open sessions, but **the same car/credential** entering
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance. twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
+9
View File
@@ -83,6 +83,15 @@ the Active-Sessions "Open barrier" is disabled the same way. The server enforces
(`requireShift` 409 `no_shift`) — the UI just front-runs the rejection. The live feed is **scoped to (`requireShift` 409 `no_shift`) — the UI just front-runs the rejection. The live feed is **scoped to
the open shift's window** (empty when no shift is open). See [[shift]] for the rule and the routes. the open shift's window** (empty when no shift is open). See [[shift]] for the rule and the routes.
## The device-status footer
A **fixed footer** in the app shell shows the live status of every configured device — relays,
readers, cameras, printers — one chip each (coloured dot + name + fault detail), with an "all ready
/ N offline" roll-up. Fed by the unified [[device-status-monitoring|DeviceMonitor]] over the same
`/api/ws` socket (`hello` carries the initial set; a `device-status` frame per change), held in the
live store keyed by device id, with `GET /api/devices/status` as the seed/fallback. Visible on every
screen, so the operator always sees the barrier relay's reachability and the printer's paper state.
## Dev notes ## Dev notes
- Vite proxies `/api/ws` (`ws: true`) to the backend; the backend's Origin allowlist must include the - Vite proxies `/api/ws` (`ws: true`) to the backend; the backend's Origin allowlist must include the
dev SPA origin (`WS_ALLOWED_ORIGINS=http://localhost:5173`). In production Fastify serves the SPA dev SPA origin (`WS_ALLOWED_ORIGINS=http://localhost:5173`). In production Fastify serves the SPA
+40 -12
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, domain, booth, exit, payment, threat-model] tags: [parking, domain, booth, exit, payment, threat-model]
sources: [] sources: []
updated: 2026-06-17 updated: 2026-06-18
status: open status: open
--- ---
@@ -62,28 +62,56 @@ phantom obstacle: an animal, a person, a cardboard box or bag in the wind). Thes
**human in the booth** to open the barrier, leaving a signed trace. **human in the booth** to open the barrier, leaving a signed trace.
**A session is "active" (shown in the booth Active Sessions list) while it is EITHER:** **A session is "active" (shown in the booth Active Sessions list) while it is EITHER:**
- **open** — entered, no `vehicle_exit` yet (still inside), OR - **open + unpaid** — entered, no `vehicle_exit`, owing money. **Always shown** — a car that owes
money never ages out; it's genuinely still inside until it pays, however long that takes. OR
- **open + paid, still within grace** — paid but no exit recorded yet, `now ≤ graceExpiresAt`. OR
- **exited but `now ≤ graceExpiresAt`** — paid and/or the voucher scanned, but still within the - **exited but `now ≤ graceExpiresAt`** — paid and/or the voucher scanned, but still within the
walk-back grace window. Because the barrier is unconfirmed, the car is presumed *possibly still walk-back grace window. Because the barrier is unconfirmed, the car is presumed *possibly still
present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the
list** — only grace expiry does. list** — only grace expiry does.
A session drops off the list once it is exited **and** past grace (presumed truly gone). A session drops off the list once it is **past grace** and EITHER exited OR **paid** (presumed truly
gone). The **paid age-out** is important: a paid session whose walk-back grace lapsed has left, so it
is omitted **even if no `vehicle_exit` was ever signed**. Without this, a paid car that left via a
manual barrier re-open (which historically signed no exit — see below) would linger **forever**
(ticket T-397815c0, 2026-06-18). The signed log is untouched — this is purely the list's display
filter (`PayStation.activeSessions()`).
### The one operator action — "Open barrier" (audited re-pulse) ### The one operator action — "Open barrier" (audited re-pulse)
For an active session, the operator can open the barrier as a **human intervention**. This: For an active session, the operator can open the barrier as a **human intervention**. This:
- **re-pulses an exit relay** (resolved site-wide, as the booth exit does), and - **re-pulses an exit relay** (resolved site-wide, as the booth exit does), and
- signs an **`anomaly`** (`source: booth`, attributed to the operator, reason "manual barrier open") - signs an **`anomaly`** (`source: booth`, attributed to the operator, reason "manual barrier open"), and
— **NEVER a second `vehicle_exit`** (a second exit would double-count occupancy and corrupt the - **closes the session IF it is still open** — i.e. if no `vehicle_exit` exists yet, the re-open *is*
ledger's meaning). It is an audited *re-open*, not a new exit. this car leaving, so it also signs a **`vehicle_exit`** (`source: manual`, reason "human-intervention
exit"). If the session is **already exited** (the phantom re-close case — a second exit would
double-count occupancy), it signs **no** second exit: anomaly only.
**Guard — no payment, no button.** The "Open barrier" action is shown/active **only for sessions that > **Refined 2026-06-18 (was "NEVER a `vehicle_exit`").** The original rule never signed an exit on a
have a payment** (paid, or paid-and-exited-in-grace). An **unpaid** open session has **no barrier-open > re-open, on the assumption a normal `vehicle_exit` had already happened. But when the re-open was the
affordance at all** — the row routes to the [[#operator-flow|pay/exit modal]] instead. The > *only* way a car left (its walk-back grace had expired, so a normal exit was refused), the session
no-unpaid-bypass rule is enforced structurally: the button simply does not exist for an unpaid car. > kept **no exit event** and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only
(A future reason-required *force exit* for genuine disputes would be a separately-audited path — see > when the session is **still open**, preserving the no-double-count guarantee for the already-exited
Open.) > case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any
> paid session that still slips through.
**Guard — paid OR subscription, else no button.** The "Open barrier" action is shown/active for a
session that **has a payment** (paid, or paid-and-exited-in-grace) **OR is a [[subscription]]
occurrence** (prepaid — the operator must be able to assist a subscriber when the exit reader / card
fails). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to
the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally
(server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force
exit* for genuine disputes would be a separately-audited path — see Open.
### Subscription occurrences in the booth (built 2026-06-18)
A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by
the **holder name**, not the raw `SUBSESS-…` key). Opening it shows the **pay/exit modal in
subscription mode**: entry/duration + **PREPAID** (no amount — it is **never quoted or charged**),
the entry/exit **snapshots**, and a single **Open barrier** action (the audited re-pulse). This is
exactly the assist path for a **faulty exit reader or a missing/forgotten card/QR**. The session
view (`lookup` / `activeSessions` in `pay-station.ts`) carries `subscription`, `subscriptionId`,
`subscriptionHolder`, derived from the entry payload's `permit:true` / `permitId`.
This single mechanism covers both edge cases: a **damaged ticket / dead scanner** (find the still-open 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
+1 -1
View File
@@ -18,7 +18,7 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a - **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite). `zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
- Permit concurrency (`maxConcurrent`, see [[permit]]) is the same kind of fold, scoped to one - Permit concurrency (`maxConcurrent`, see [[subscription]]) is the same kind of fold, scoped to one
permit's open sessions. permit's open sessions.
## Full → refuse entry + FULL sign ## Full → refuse entry + FULL sign
+100
View File
@@ -0,0 +1,100 @@
---
type: concept
tags: [parking, device, monitoring, reliability, ui]
sources: []
updated: 2026-06-18
status: open
---
# Device status monitoring (the booth footer)
The booth shows a **fixed footer** with the live status of every configured device — relays,
readers, cameras, printers — so an operator sees at a glance that the barrier relay is reachable,
the exit scanner is up, and the ticket printer has paper. This generalises the printer-only
[[printer-status-monitoring]] to **all four [[device-adapter-pattern|device categories]]**. A
reliability control, not a threat-model one. (Built 2026-06-18.)
## What gets polled, and how
Every **enabled** row in `devices` is polled on an interval, regardless of category — the monitor
talks only to the adapter interfaces ([[device-adapter-pattern]]), never a driver SDK:
- **Printers** → their rich `MonitorableDevice.readStatus()` (paper end / near-end, cover open,
cutter error, off-line) — the same capability the existing [[printer-status-monitoring|PrinterMonitor]]
uses. The footer surfaces the fault detail.
- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
model — a relay either answers or it doesn't.
Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
`offline` (consistent with [[printer-status-monitoring]]'s fail-safe mapping); a driver that's no
longer registered reads `offline` ("driver not registered") rather than vanishing.
## The monitor (server)
`DeviceMonitor` (`apps/server/src/device-monitor.ts`), modelled on the PrinterMonitor:
- re-reads the device set each tick (a newly-assigned/removed device appears/disappears without a
restart); drops cached status for devices that are gone or disabled;
- polls every `DEVICE_POLL_MS` (default **8000ms**), never overlapping ticks;
- caches the latest unified status per device id;
- emits a `device-status` bus event **only when a device's state or detail changes** (deduped).
> **Relationship to the PrinterMonitor.** Both run. The PrinterMonitor stays the authority for the
> printer-specific live detail + its SSE stream (`/api/printers/status*`) that the entry flow may
> later depend on for [[printer-roles-failover]]. The DeviceMonitor is the **unified footer feed**
> across all categories. They poll independently (printers get probed by both — cheap HTTP reads);
> the small duplication is deliberate, to avoid coupling the footer to printer internals. Could be
> consolidated later if the overlap ever matters.
## API / live UI
- `GET /api/devices/status` — cached snapshot of all devices (no device round-trip). Any
authenticated role (operational, not a setup action).
- Live updates ride the **one booth [[booth-console|WebSocket]]** (`/api/ws`): the `hello` frame
carries the initial device-status set; a `device-status` frame is pushed per change. The web
[[booth-console|live store]] holds the set keyed by device id; the REST snapshot seeds it / fills
in if the socket is briefly down.
- **`DeviceFooter`** (`apps/web/src/ui/DeviceFooter.tsx`) renders one **compact** chip per device —
a coloured dot + a **role label, never the vendor** — ordered access → reader → camera → printer,
with a right-aligned roll-up ("N with issues" / "all ready"). Mounted in the app shell so it's
visible on every screen.
### Label = role, not vendor (refinement 2026-06-18)
The chip shows **what the device does, not who made it**: the localised category + a role/direction
suffix → `Lexuesi hyrje`, `Printer kabina`, `Kamera dalje`. The server sends a structured
**`roleKind`** token (not a composed string), the client localises it:
- **reader / camera** → the direction inherited from its bound relay (`directionOf()` in
[[entry-exit-points|device-resolve]]): `entry | exit | both`.
- **access controller** → `entry | exit | both` from its `relays[]`, or **`mixed`** when it spans
more than one direction; `null` if it declares none yet.
- **printer** → `lane` (entry-dispenser) | `booth` (booth-receipt) — the [[printer-roles-failover]] role.
- `null` → the chip shows the category alone.
### Detail does NOT pollute the footer (refinement 2026-06-18)
Chips stay short — **no inline fault text**. A device that is `degraded`/`offline` is clickable (so
is the roll-up); clicking opens a small **issues panel** anchored above the footer that lists only
the problem devices with their role label, state, the `detail` string, and the last-checked time.
`ready` chips are non-interactive. The panel closes on outside-click / Escape (a lightweight
popover — no extra dependency; only Radix Dialog is installed).
## Verified (2026-06-18)
On a fresh DB seeded with a stub relay, a TCP reader, and two printers (one reachable, one not):
relay + reader → `ready` via `healthCheck`; the unreachable printer → `offline` (with a detail
string, never threw); the bus emitted once per device on first observation, and a second unchanged
tick was silent (change-only emit). Server + web build clean.
## Open / not yet done
- **Reachability ≠ correctness.** `healthCheck()` says a relay/reader answers, not that it's wired
to the right barrier or reading cards — that's a setup/precondition concern ([[first-run-setup]],
the Dingtian [[access-controller-button-flow|precondition checks]]).
- **No per-device history / alerting.** The footer is point-in-time; a flapping device isn't
tracked over time. Reconciliation-style alerting is out of scope here.
- **Cameras** only expose `healthCheck` reachability today; a "last snapshot age" health signal
could be richer ([[lpr-camera]], [[opencv-anpr-service]]).
- Possible later **consolidation** of PrinterMonitor + DeviceMonitor (see the note above).
+3 -3
View File
@@ -43,7 +43,7 @@ A session needs a key that survives from entry to exit. Two populations, two key
- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]]) - **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]])
or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field. or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field.
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid - **Permit holder:** a **credential** (card / plate / QR) matched to a [[subscription]] record. A valid
permit means the session owes nothing — the PAY step is skipped (see below). permit means the session owes nothing — the PAY step is skipped (see below).
## Lifecycle (pay-on-foot / pay station model) ## Lifecycle (pay-on-foot / pay station model)
@@ -71,7 +71,7 @@ States, as derived from events:
| **CLOSED** | a matching `vehicle_exit` event exists | | **CLOSED** | a matching `vehicle_exit` event exists |
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) | | **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close. Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
## Edge cases the model must name (not yet designed in full) ## Edge cases the model must name (not yet designed in full)
@@ -106,7 +106,7 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
fails. See [[device-input-flow]]. fails. See [[device-input-flow]].
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the - **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**. **permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built. Lane resolved once (`readerLaneWithAccess`). See [[subscription]] as-built.
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) → - **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) →
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`** fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier → signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
@@ -7,6 +7,11 @@ updated: 2026-06-14
# Printer status monitoring # Printer status monitoring
> **Generalised 2026-06-18:** the booth's all-device status **footer** is a separate, unified
> monitor across every category (relays/readers/cameras/printers) — see
> [[device-status-monitoring]]. This page remains the authority for the *printer-specific* rich
> status (paper/cover/cutter) + its SSE stream; both monitors run.
The booth must know a printer is in trouble **before** a driver presses the entry button and no The booth must know a printer is in trouble **before** a driver presses the entry button and no
ticket comes out. So the system polls each printer's live status (paper out, cover open, cutter ticket comes out. So the system polls each printer's live status (paper out, cover open, cutter
jam, off-line) and pushes changes to the operator UI. A reliability control, like jam, off-line) and pushes changes to the operator UI. A reliability control, like
+1 -1
View File
@@ -17,7 +17,7 @@ derived and rebuildable, never a separate ledger.
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source: - **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]). `payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover. - **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
- **Stay analytics** — average/median duration, distribution; transient vs. [[permit]] split. - **Stay analytics** — average/median duration, distribution; transient vs. [[subscription]] split.
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`. - **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation — - **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
the `anomaly` events + reconciliation findings ([[reconciliation]]). the `anomaly` events + reconciliation findings ([[reconciliation]]).
+3 -1
View File
@@ -63,7 +63,9 @@ login ————————————————————————
## What End Shift does ## What End Shift does
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]], 1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
[[append-only-event-chain]]) between this shift's start mark and now. [[append-only-event-chain]]) between this shift's start mark and now. This includes a
**[[subscription]] fee** an operator collects during the shift (sold/renewed at the booth → a
signed `payment`, deferred build) — it folds into this set like any transient taking.
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured** 2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
(the card line is omitted when there's no terminal). (the card line is omitted when there's no terminal).
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator, 3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
+4 -4
View File
@@ -10,7 +10,7 @@ status: open
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data, How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
not code** — the park owner builds and constantly edits the rate card at runtime (like a not code** — the park owner builds and constantly edits the rate card at runtime (like a
[[permit]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to [[subscription]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
beyond the host). beyond the host).
@@ -144,9 +144,9 @@ production.
## Permit holders ## Permit holders
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription A valid [[subscription]] bypasses tariff computation entirely for the covered period (subscription
already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for
the uncovered time — an edge case to design with [[permit]]. the uncovered time — an edge case to design with [[subscription]].
## Versioning — edits publish immutable, effective-dated versions ## Versioning — edits publish immutable, effective-dated versions
@@ -198,7 +198,7 @@ Two operator asks extend this engine; both have design pages (not yet built), gr
slicing a stay at window boundaries while keeping the block ladder + daily cap continuous. slicing a stay at window boundaries while keeping the block ladder + daily cap continuous.
- **Validation & sponsorship** (merchant comps, coupons, **postpaid B2B** "enter/exit free, bill the - **Validation & sponsorship** (merchant comps, coupons, **postpaid B2B** "enter/exit free, bill the
business monthly") — see [[validation-sponsorship]]. A validation is a **typed modifier applied as a business monthly") — see [[validation-sponsorship]]. A validation is a **typed modifier applied as a
signed event** on a transient session, distinct from a [[permit]]; postpaid sponsors accrue a signed event** on a transient session, distinct from a [[subscription]]; postpaid sponsors accrue a
monthly-invoiced liability derivable from the chain. monthly-invoiced liability derivable from the chain.
## Open ## Open
+3 -3
View File
@@ -19,7 +19,7 @@ postpaid agreement whose customers enter and exit freely, billed to the business
## Why this is NOT a permit (the key distinction) ## Why this is NOT a permit (the key distinction)
| | [[permit]] | Validation / sponsorship | | | [[subscription]] | Validation / sponsorship |
| --- | --- | --- | | --- | --- | --- |
| Subject | Known in advance; carries a credential (card/QR/plate) | Anonymous walk-in; identified only by the **ticket they were issued** | | Subject | Known in advance; carries a credential (card/QR/plate) | Anonymous walk-in; identified only by the **ticket they were issued** |
| When applied | At entry (credential opens the lane) | **After entry**, against an existing session — at a pay station, by a code, or by a sponsor rule | | When applied | At entry (credential opens the lane) | **After entry**, against an existing session — at a pay station, by a code, or by a sponsor rule |
@@ -74,7 +74,7 @@ validations id, session_id, sponsor_id?, type, amount_minor|minutes,
- A **postpaid** sponsor: each full-comp validation appends a row and accrues `amount` to the - A **postpaid** sponsor: each full-comp validation appends a row and accrues `amount` to the
sponsor; monthly invoice = sum over the period; exit is free at the lane. sponsor; monthly invoice = sum over the period; exit is free at the lane.
- **Free entry/exit "freely"**: either the sponsor issues credentials (then it's closer to a - **Free entry/exit "freely"**: either the sponsor issues credentials (then it's closer to a
[[permit]] — pick that path), or customers take a normal ticket and a sponsor rule / merchant code [[subscription]] — pick that path), or customers take a normal ticket and a sponsor rule / merchant code
comps it at exit. The agreement wording decides which; **both are expressible.** comps it at exit. The agreement wording decides which; **both are expressible.**
## Reconciliation & settlement ## Reconciliation & settlement
@@ -83,7 +83,7 @@ validations id, session_id, sponsor_id?, type, amount_minor|minutes,
Statement lines trace to signed validation events → disputes resolvable against the chain. Statement lines trace to signed validation events → disputes resolvable against the chain.
## Open ## Open
- **"Enter/exit freely" mechanism**: sponsor-issued credentials ([[permit]]-like) vs. ticket + - **"Enter/exit freely" mechanism**: sponsor-issued credentials ([[subscription]]-like) vs. ticket +
comp-at-exit. Likely offer both; confirm the operator's actual deal shape. comp-at-exit. Likely offer both; confirm the operator's actual deal shape.
- Prepaid coupon format: printed codes (legacy) vs. QR vs. merchant web-validation portal. - Prepaid coupon format: printed codes (legacy) vs. QR vs. merchant web-validation portal.
- Who may apply a validation, and the **per-operator cap** (a comp is a fraud vector — bound it and - Who may apply a validation, and the **per-operator cap** (a comp is a fraud vector — bound it and
+2 -2
View File
@@ -19,7 +19,7 @@ The starting decision for the **business layer**, taken 2026-06-15 as the projec
events. A cache table is allowed for query speed but is always rebuildable and never events. A cache table is allowed for query speed but is always rebuildable and never
authoritative. authoritative.
2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first; 2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first;
layer [[permit]] holders on top as a second identity source that short-circuits payment layer [[subscription]] holders on top as a second identity source that short-circuits payment
([[entry-exit-readers]]). ([[entry-exit-readers]]).
3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a 3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a
central station; the exit lane only validates the session is paid and within the walk-back central station; the exit lane only validates the session is paid and within the walk-back
@@ -48,7 +48,7 @@ pay-station and exit-validation flows. Schema (`packages/db`) + shared types fol
- Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]). - Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]).
- Tariff versioning (effective-dated) for historical repricing. - Tariff versioning (effective-dated) for historical repricing.
- [[permit]] data model + lapsed-mid-stay handling. - [[subscription]] data model + lapsed-mid-stay handling.
- Wire payment capture to a concrete pay-station terminal ([[open-questions]] #3) — kept abstract - Wire payment capture to a concrete pay-station terminal ([[open-questions]] #3) — kept abstract
(payment = an independent signed event referencing a session) until procurement settles. (payment = an independent signed event referencing a session) until procurement settles.
- Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4 - Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4
+2 -2
View File
@@ -11,7 +11,7 @@ status: open
The project's **QR-code reader** (GEE NFC LIMITED). A static optical scanner for **QR / The project's **QR-code reader** (GEE NFC LIMITED). A static optical scanner for **QR /
DataMatrix / 1D barcode**, optional ID/IC card. This is the **[[ticket-encoding|QR ticket]] DataMatrix / 1D barcode**, optional ID/IC card. This is the **[[ticket-encoding|QR ticket]]
scanner** the design called for — read at the pay station and exit lane — and a path for **QR scanner** the design called for — read at the pay station and exit lane — and a path for **QR
[[permit]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485). [[subscription]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
(See [[gee-qr-er80|datasheet summary]] / `raw/`.) (See [[gee-qr-er80|datasheet summary]] / `raw/`.)
## What it is (and isn't) ## What it is (and isn't)
@@ -39,7 +39,7 @@ GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2 chars>&
This is **host-in-the-loop and SYNCHRONOUS**: the GET *is* the access query and **our reply is the This is **host-in-the-loop and SYNCHRONOUS**: the GET *is* the access query and **our reply is the
decision** — it drives the reader's beep + output. So unlike a fire-and-forget reader, the endpoint decision** — it drives the reader's beep + output. So unlike a fire-and-forget reader, the endpoint
must decide (valid/invalid, direction from `status`) and reply, then also emit a `DeviceReadEvent` must decide (valid/invalid, direction from `status`) and reply, then also emit a `DeviceReadEvent`
on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[permit]]) to open the on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[subscription]]) to open the
barrier. ([[device-input-flow]] is the analogous push pattern; this one also returns a verdict.) barrier. ([[device-input-flow]] is the analogous push pattern; this one also returns a verdict.)
> **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A > **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A
+3 -3
View File
@@ -17,12 +17,12 @@ recognition **host-side on ordinary IP-camera snapshots**, replacing the dedicat
1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing 1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing
`IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way `IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way
a plate-bound [[permit]] is matched. a plate-bound [[subscription]] is matched.
2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum 2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum
`{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding). `{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding).
This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives
in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at
entry vs. exit (and vs. the [[permit]]'s known car) can. A plate that entered on a red hatchback entry vs. exit (and vs. the [[subscription]]'s known car) can. A plate that entered on a red hatchback
but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role
the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]]. the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]].
@@ -64,7 +64,7 @@ guarantee is preserved. Recorded as an explicit exception in [[standing-decision
## Anti-fraud / threat-model fit ## Anti-fraud / threat-model fit
- **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or - **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or
vs. a [[permit]]'s registered car → anomaly. Doesn't *block* on its own (recognition is vs. a [[subscription]]'s registered car → anomaly. Doesn't *block* on its own (recognition is
probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record. probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record.
- The recognition result and the source image both attach to the signed [[append-only-event-chain]] - The recognition result and the source image both attach to the signed [[append-only-event-chain]]
entry, so the *evidence* is tamper-evident even though recognition itself is host-side and entry, so the *evidence* is tamper-evident even though recognition itself is host-side and
-149
View File
@@ -1,149 +0,0 @@
---
type: entity
tags: [parking, domain, business, subscriptions, identity]
sources: []
updated: 2026-06-15
status: open
---
# Permit (Subscription)
A **subscription**: a known holder authorized to enter/exit without paying per-stay, for a covered
period. The second of the "two populations" ([[entry-exit-readers]]); a valid permit
**short-circuits the payment step** of a [[parking-session]] ([[session-model]]). Transient is
built first; permits layer on top.
## Credentials (how a permit is presented) — confirmed with operator 2026-06-15
A permit is recognized by a credential read at the lane. Two kinds, mapping to the two identity
paths:
- **RF tag / chip / card.** An RFID/proximity credential. Read **host-side** (reader → host →
`pulseOpen`): autonomy isn't required (resolved below), and the [[dingtian-relay]] has no onboard
card list anyway, so there's no need to route RF into a controller. A Wiegand-out reader is still
fine and keeps a future autonomous path open ([[entry-exit-readers]]), but isn't required.
- **QR code.** Read by the **optical reader** — inherently **host-side** ([[entry-exit-readers]]:
pure optical/network readers are invisible to a controller). Host decodes the QR → looks up the
permit → decides.
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.
## Two optional, independent bindings — confirmed 2026-06-15
A permit has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
neither — the four combinations are all valid.
### 1. Car-count binding (default: 1)
- **Optional.** By default a permit is bound to **1 car at a time**. The admin may raise the limit
(a household, a company fleet) or **unbind it entirely** (no cap on how many cars use it).
- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the
[[parking-session]] projection: at entry, count the permit's currently-open sessions; if
`< maxConcurrent` (or unbound) allow, else reject (allowance full). This is exactly why
sessions-as-projection matters — "how many of this permit's cars are inside right now" is a fold
over open entry/exit events, **not a counter someone can edit**.
### 2. Plate binding (default: off)
- **Optional.** By default a permit is **not** plate-bound — any car may use it (identity is the
card/QR). The admin may bind it to a set of specific licence plates.
- When **bound**, an allowed plate is an **accepted identity in its own right** — a valid
**card/QR OR a matching plate** opens the lane (either, not a second factor):
```
entry: read card/QR → find permit → car-count ok → open
OR LPR plate ∈ permit's bound plates → find permit → car-count ok → open
```
- **Accepted tradeoff:** card-OR-plate is the most convenient but does **not** prevent
card-sharing (a lent card still opens). Fine for a trusted permit population; the signed
[[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible
to [[reconciliation]] after the fact.
- **Plate-spoofing defence:** a printed copy of a registered plate on a *different* car is caught
not here but by the [[opencv-anpr-service]]'s **vehicle-attribute verification** — the seen car
must reconcile with the permit's known car, not just the plate string.
> The two are independent: a plate-bound permit may have no car cap; a car-capped permit may accept
> any plate. The binding fields are simply absent/null when a constraint isn't applied.
## Data model (first cut — to firm up with [[session-model]])
A `permits` table (and supporting rows). Unlike the event log, reference/master data like permits
**is** mutable (an admin grants/revokes/renews) — but every *use* of a permit still produces a
signed `vehicle_entry`/`vehicle_exit` event in the [[append-only-event-chain]], so the audit trail
stays append-only even though the permit record itself is editable.
| Field | Notes |
| --- | --- |
| `id`, `holderName`/contact | the subscriber |
| `credentials[]` | one or more: `{ kind: 'rf' \| 'qr', value }` |
| `maxConcurrent` | car-count binding; **default 1**, raise for fleets, or `null` = unbound |
| `plates[]` | plate binding; **default empty/false** = any car; when set, these plates are accepted identities |
| `validFrom`, `validTo` | coverage window |
| `status` | active / suspended / revoked |
> Both bindings are nullable/empty by default — a bare permit is "1 car at a time, any plate,
> identified by its card/QR".
## Interaction with the session model
- **Entry:** credential read → permit lookup → valid (active, in window, plate allowed **if
plate-bound**, concurrent cars `< maxConcurrent` **if car-bound**) → signed `vehicle_entry`
(source = `wiegand`/`qr`/`lpr`), open barrier. No ticket, no fee. (A bare permit applies neither
extra check — just active + in window.)
- **Exit:** credential/plate read → matching open permit session → signed `vehicle_exit`, open. No
payment required.
- **Lapsed mid-stay:** permit expires while a car is parked → the uncovered time falls back to the
transient [[tariff]] (edge case to design).
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
refused, per policy (OPEN).
## As-built (2026-06-15)
`apps/server/src/permit-flow.ts`, reached via the **read dispatcher**
(`read-dispatch.ts`): a credential read routes to the permit flow if it **matches a permit**
(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler
serves both populations ([[entry-exit-readers]]), disambiguated by *what the credential is*.
- **Direction is inferred from session state for that car** — the read credential value is the
per-car session key. No open session for that car → **ENTRY** (check `maxConcurrent`, sign
`vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_exit`, open, close). A fleet
permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an
inside car is its exit, never a second entry).
- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's
`vehicle_entry` events whose car has no later exit; reject at the limit (`null` = unbound).
- **Validity** (active + within `validFrom`/`validTo`) and **plate-OR-card identity** as designed.
No ticket, no fee — the permit is the authorization; every use is still a signed ledger event
carrying `permitId`.
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
**Admin CRUD** (`apps/server/src/routes/permits.ts` + `apps/web/src/PermitManager.tsx`): a permit is
an **aggregate** (the row + its credentials + bound plates); create/update treat it as one unit
(child sets are replaced on update). `GET /api/permits` (any signed-in role — for lookup),
`POST/PUT/DELETE /api/permits[/:id]` + `POST /api/permits/:id/revoke` (**admin only**). Validation:
`maxConcurrent` is a positive int or `null` (unbound); a permit must have **at least one credential
or one bound plate** (else nothing identifies it). Revoke is the soft, common case (keeps history,
barred at the barrier); DELETE hard-removes — past ledger events that reference the permit are
untouched (the audit trail is append-only and independent). Verified via inject (validation, child
replacement, RBAC, revoke/delete).
## Resolved (2026-06-15)
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
unbound) and plate-binding (`plates[]`, **default off** = any car). Either, both, or neither.
- **Plate vs. credential:** when plate-bound, **card/QR OR matching plate** — either is accepted
identity (not a second factor); card-sharing not prevented by design, caught by
[[reconciliation]] after.
- **Autonomy:** **host-in-the-loop for everything** — no onboard card list needed, so the
[[dingtian-relay]] stays sufficient (no new controller). Permit entry **fails closed** if the
host is down ([[fail-state-safety]]). One code path for transient + permit.
## Open questions
1. **Reader hardware** — confirm the RF reader and the QR/optical reader models (procurement;
relates to [[bom]] and [[open-questions]]). RF need not be Wiegand now that autonomy isn't
required, but a Wiegand-out reader keeps options open.
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm
with operator.
+8 -1
View File
@@ -38,7 +38,14 @@ many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
## Ticket rendering ## Ticket rendering
`printTicket(TicketData)` builds ESC/POS: `ESC @` init, centered/bold/double-size header, `printTicket(TicketData)` builds ESC/POS: `ESC @` init, centered/bold/double-size header,
lane, ticket id, issued-at, feed + partial cut (`GS V B`). CP437/ASCII subset. lane, ticket id, issued-at, feed + partial cut (`GS V B`). CP437/ASCII subset. The entry ticket
encodes the id as a **1D Code128** barcode (`GS k`).
**`printSubscriptionCard(SubscriptionCardData)`** (added 2026-06-18) renders a **2D QR** of the
[[subscription]] code via ESC/POS **`GS ( k`** (model 2, EC level M) — firmware-rendered, no bitmap
dependency — plus the code as text + holder/validity. Used for the auto-printed + reprintable
subscription card. (Verified: the `GS ( k` store/print byte sequences + the embedded code appear on
the wire against a TCP capture.)
## Status ## Status
+268
View File
@@ -0,0 +1,268 @@
---
type: entity
tags: [parking, domain, business, subscriptions, identity, pricing]
sources: []
updated: 2026-06-18
status: open
---
# Subscription
A **subscriber**: a known holder who parks on a **recurring plan** (e.g. **10,000 ALL / month**)
instead of paying per stay. The second of the "two populations" ([[entry-exit-readers]]); a valid
subscription **short-circuits the payment step** of a [[parking-session]] ([[session-model]]).
Transient is built first; subscriptions layer on top.
> **Renamed 2026-06-18 (was "Permit").** The operator term is **subscription / abonim**, not
> "permit / lejet". The master-data **tables/routes/UI/types were renamed** permit→subscription
> (migration `0004`). The **signed ledger keeps its `permitId` payload field** — that is immutable,
> hash-chained history, so renaming it would break verification of past events. So: *code & data =
> "subscription"; the on-chain field name stays `permitId`.* See the schema note in `schema.ts`.
## Pricing — recurring monthly plan (built 2026-06-18)
Each subscription records its **own price**, so an individual and a company fleet can differ:
- `priceMinor` — the recurring price in **minor units** (integer; e.g. `1000000` = 10,000.00).
`null` = no price set (a comp / legacy subscription).
- `period` — the billing period. **`"monthly"` only** today (the enum is widened later if a site
ever needs weekly/annual).
- `currency` — ISO-4217 of `priceMinor` (e.g. `"ALL"`); required when a price is set.
A **site default monthly price** lives in `site_config.subscription_monthly_price_minor` — it
merely **pre-fills** the new-subscription form; each subscription still stores its own value and may
override.
### Multi-month: pay N months → extend `validTo` (built 2026-06-18)
A customer paying for **more than one month** is handled by the **coverage window**, not by separate
records. The form takes a **`months`** count; with `validFrom` set, the server computes **`validTo =
validFrom + N months`** (whole-month add, with day-overflow clamp — e.g. Jan 31 + 3mo → Apr 30). One
subscription row, one window. The amount the operator should collect is **N × the monthly price**
(the form previews `end date · total`); collection into the ledger is still deferred (below).
- `months` is **input-only** — it's not stored; the stored truth is `validFrom`/`validTo`. Renewing
for more months is just editing the window (set a new `months` or an explicit `validTo`).
- The validity check is unchanged: a session is allowed while the subscription is **active and
`now` ∈ [validFrom, validTo]** — so a 3-month window simply stays valid for three months.
- An explicit **`validTo` override** is still accepted (manual end date) when `months` isn't used.
### Collecting the fee is a SHIFT transaction (decided 2026-06-18, deferred build)
Selling/renewing a subscription is a **financial transaction a common operator makes during their
[[shift]]** — the subscriber pays the monthly fee at the booth like any other customer. So it is
**not** an admin-only master-data edit; the money must land in **that operator's shift**: their
drawer (if cash) and their [[shift|Z-report]].
The clean way (the model already supports it): collection writes a signed **`payment`** ledger event
— same shape the transient pay-station uses (`{ amountMinor, currency, tender }`) — at collection
time, tagged with `{ subscriptionId }` so it's identifiable as subscription revenue.
- It folds into the shift automatically: the Z-report sums `payment` events in `[start, end]` **by
payment time**, and the drawer fold adds **cash** tenders (card settles to the bank) — no new
summing logic needed. The fee lands in **whichever shift was open when it was taken**, attributed
to that operator. (See [[shift]] "drawer balance".)
- **Admin** still edits the subscription master data (price, window, credentials); the **operator**
takes the money. Two different acts.
- A subscription's own [[parking-session|entry/exit]] events stay **free** (no per-stay `payment`) —
only the *plan fee* is a payment, decoupled from any individual stay.
> **Deferred build.** Today we only *record* the agreed price + coverage window
> (`validFrom`/`validTo`); no collection event is written yet, so subscription revenue does not flow
> into the drawer/Z-report or [[reconciliation]]. Open detail when built: whether to model it as a
> plain `payment` (simplest, folds today) or a distinct `subscription_payment` type (clearer in
> reports, but the shift/drawer fold would need to count it too). Leaning **plain `payment` +
> `subscriptionId` tag**. (Decision 2026-06-18: store price now, collect-in-shift later.)
## Credentials (how a subscription is presented) — confirmed 2026-06-15
Recognized by a credential read at the barrier. The operator **chooses the credential type** per
subscription. Two kinds, mapping to the two identity paths, and **either can be combined with
LPR/ANPR plate identity** (the plate binding below):
- **QR code — the only type live today (2026-06-18).** Read by the optical reader — inherently
**host-side** ([[entry-exit-readers]]). Host decodes the QR → looks up the subscription → decides.
A subscription's QR can be **printed**. The new-subscription form **defaults to QR**.
- **The code is AUTO-GENERATED server-side** (`SUB-<15× base32>`, crypto-random, checked
globally-unique). The operator never types it and the customer can't pick it — anti-fraud
(a chosen value could be guessable or collide). The UI sends a blank QR credential; the server
mints the value and returns it (so the UI can print it). **An RF credential, by contrast, carries
the physical card id, so it is operator-entered.**
- **Reader output = TCP/IP full string** (decided 2026-06-18, the [[gee-qr-er80|host-in-the-loop
QR reader]] path): the reader delivers the whole decoded string, so the code length is free
(unguessable token). *If a site ever wires the reader as **Wiegand 26/34** instead, a scanned
QR truncates to a 24-/32-bit number — the generated code would then have to be a numeric id in
that range. Not our path today.* (Manufacturer reader: ID/IC/NFC + QR/barcode; Wiegand 26/34 /
TCP/IP / USB / RS485; 125 kHz + 13.56 MHz — one device covers QR **and** future RFID.)
- **The card is PRINTED so the operator can hand it over.** On creation the server **auto-prints**
a subscription card on the booth printer ([[rongta-printer]], role `booth-receipt`, failing over
to the dispenser): park header → a **real scannable QR** of the code → the code as text (hand-key
fallback) → holder + validity window. Printing is **best-effort** — a print failure never fails
the create (the subscription + code are saved); the response returns `{ printed, printError }` and
the UI warns + offers **"Print code"** (reprint via `POST /api/subscriptions/:id/print`) for a
failed print / lost card / re-hand. The QR is rendered by the printer firmware via ESC/POS
**`GS ( k`** (model-2, error-correction M) — added to the Rongta driver
(`printSubscriptionCard`), no image/bitmap dependency (same approach as the Code128 ticket).
- **RF tag / chip / card — selectable later, NOT live yet.** An RFID/proximity credential, read
**host-side** (reader → host → `pulseOpen`). **LIVE since 2026-06-18** — the operator selects RFID
and **reads the card off a physical reader** (see "Enrolling a card" below) rather than typing the
number. The GEE readers are combo QR + RFID (ID/IC/NFC), so the same device captures both. A
Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]); the
[[dingtian-relay]] has no onboard card list.
- **Plate (LPR/ANPR) — NOT YET IMPLEMENTED.** When plate-bound (below), a matching plate read is an
accepted identity too. The vision/ANPR service that produces plate reads is future work
([[opencv-anpr-service]] / [[lpr-camera]]); until it exists, plate binding has no live source.
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
already in the model) and whose value is the credential id.
### Enrolling a card — "Read card" capture (built 2026-06-18)
RFID values are awkward to type, so the operator **presents the physical card to a chosen reader**
and the system captures it into the credential. The catch is that the readers are also serving live
traffic, so capture must **borrow one reader briefly without blocking the other**:
- **`CredentialCapture`** (in-memory, single-site): the operator picks a reader and **arms** it
(`POST /api/subscriptions/capture/arm {deviceId}`). It is **single-shot + a ~30 s TTL**.
- In the reader route (`qr-reader.ts`), each read first checks `tryConsume(deviceId, value)`: if
**this** reader is the armed one, the value is captured and the read is **NOT dispatched to the
access flow** (no barrier opens for a card being enrolled), then capture auto-disarms. A read on
**any other reader dispatches normally** — the live entry/exit flow on the other barrier is never
blocked. (Accepted trade: while armed, a real customer at the *armed* reader is captured instead of
admitted — kept tiny by single-shot + TTL.)
- The booth form **polls** `GET /api/subscriptions/capture` (idle | armed | captured | expired);
on `captured` it drops the value into the RFID field. `POST …/capture/cancel` disarms.
- Verified end-to-end (12/12): captured-not-dispatched (no ledger write), single-shot, the other
reader still drives a live `vehicle_exit` while armed, value retrievable, cancel/expiry.
> The same mechanism would work to capture a **QR** too, but QR codes are server-generated + printed,
> so capture is RFID-only in practice (QR has nothing to read off a card).
### Multiple credentials, and entry decoupled from exit (2026-06-18)
A subscription is a one-to-many aggregate: it may hold **several credentials at once** — e.g. a QR
**and** an RFID card (and later NFC). Each is its own `subscription_credentials` row; any of them
resolves the same subscription at the barrier. (NFC works today as an `rf` credential on the combo
GEE reader; a distinct `nfc` `kind` is a small future labelling-only addition.)
Crucially, **entry and exit are NOT bound to the same credential.** Originally the session was keyed
by the exact credential value read, so you had to leave with whatever you arrived with — an
*accidental* coupling. Now sessions are keyed by a **subscription occurrence** (`SUBSESS-<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
A subscription has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
neither.
### 1. Car-count binding (default: 1)
- **Optional.** By default bound to **1 car at a time**. The admin may raise the limit (a household, a
company fleet) or **unbind it entirely** (no cap).
- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the [[parking-session]]
projection: at entry, count the subscription's currently-open sessions; if `< maxConcurrent` (or
unbound) allow, else reject. A fold over the signed ledger, **not a counter someone can edit**.
### 2. Plate binding (default: off)
- **Optional.** By default not plate-bound — any car may use it (identity is the card/QR). The admin
may bind it to a set of specific plates; a matching plate then **is an accepted identity**
(card/QR **OR** plate, not a second factor).
- **Accepted tradeoff:** card-OR-plate doesn't prevent card-sharing; the signed
[[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible to
[[reconciliation]]. Plate-spoofing (a printed plate on a different car) is caught by the
[[opencv-anpr-service]]'s vehicle-attribute verification, not here.
## Time-of-day access windows — DESIGN NOTE, NOT YET IMPLEMENTED (2026-06-18)
A subscription may be valid **only during certain hours of the day**, behaving as a normal transient
customer outside them. The motivating case: an **overnight subscriber** allowed in on their
subscription **19:00 → 07:00**, but charged the normal [[tariff]] if they park during the day.
Intended behaviour (to design + build later):
- The subscription carries one or more **recurring daily time windows** (e.g. `[{ from: "19:00",
to: "07:00", days: [...] }]`). Windows may **wrap past midnight** (19:00→07:00 spans two calendar
days) — the check must handle the wrap.
- **At ENTRY**, evaluate the window against the host clock ([[clock-integrity]]):
- **inside the window** → subscription entry (no ticket, no fee), exactly as today;
- **outside the window** → the car is treated as a **normal transient**: it takes a ticket and
pays the [[tariff]] on the way out. The subscription is simply *not used* for this stay.
- **The boundary cases need a decision** (flagged, not resolved):
- *Enters inside the window, exits outside it* (parks past 07:00): is the whole stay free
(entry-time decides), or is the over-window time charged transient (like
[[tariff|lapsed-mid-stay]])? Leaning **entry-time decides** for simplicity, but confirm.
- *Day-of-week scope* (weekdays vs. weekends), holidays.
- Interaction with `maxConcurrent` and plate binding (orthogonal — should still apply).
- **Data:** a child table (e.g. `subscription_windows`) or a JSON column on `subscriptions`; TBD with
the implementation. Legacy precedent exists — the ParkSQL2017 schema had
`MembershipPlansTime` / `ActiveDays` ([[parksql2017-legacy-schema]] §"time-/day-restricted
memberships"), confirming this is a real market need.
> **Explicitly postponed.** For now this is documentation only — no schema, no enforcement. A
> subscription is valid whenever it is active and within `validFrom`/`validTo`, all day.
## Data model (as-built 2026-06-18)
Tables (mutable master data; every *use* still produces a signed `vehicle_entry`/`vehicle_exit`):
| Table / field | Notes |
| --- | --- |
| `subscriptions.id`, `holderName`, `contact` | the subscriber |
| `subscriptions.priceMinor` / `period` / `currency` | recurring plan (monthly); null price = unset |
| `subscriptions.maxConcurrent` | car-count binding; **default 1**, raise for fleets, `null` = unbound |
| `subscriptions.validFrom` / `validTo` / `status` | coverage window; active / suspended / revoked |
| `subscription_credentials[]` | `{ kind: 'rf' \| 'qr', value }` |
| `subscription_plates[]` | bound plates (accepted identities when set) |
## Interaction with the session model
- **Entry:** credential read → subscription lookup → valid (active, in window, plate allowed **if
plate-bound**, concurrent cars `< maxConcurrent` **if car-bound**) → signed `vehicle_entry`
(`source = wiegand/qr/lpr`), open barrier. No ticket, no fee.
- **Exit:** credential/plate read → matching open subscription session → signed `vehicle_exit`, open.
- **Lapsed mid-stay:** subscription expires while parked → uncovered time falls back to the transient
[[tariff]] (edge case to design — and the same question the time-window boundary raises above).
- **Revoked:** a revoked subscription fails the entry check → treated as transient or refused (OPEN).
## As-built (2026-06-15, renamed + priced 2026-06-18)
`apps/server/src/subscription-flow.ts` (was `permit-flow.ts`), reached via the **read dispatcher**
(`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.
- **Any credential opens/closes — sessions keyed by SUBSCRIPTION, not credential** (changed
2026-06-18). A subscriber can **enter with their QR and exit with their RFID card** (or any mix).
Entry mints a fresh **per-occurrence** session id (`SUBSESS-<subId>-<uuid>`, the ledger `identity`)
with `payload.permitId = subId`; the credential read is decoupled from the session key. See "Entry
decoupled from exit" below.
- **Direction = the BARRIER the reader sits at.** An entry-lane read is an ENTRY, an exit-lane read
is an EXIT; a `"both"` barrier infers from open state (open occurrence → exit, else entry). This is
what lets a **fleet** (`maxConcurrent > 1`) admit several cars (each entry-lane read is an entry)
*and* exit any of them with any credential.
- **Exit closes the OLDEST open occurrence (FIFO).** Per-car identity within a fleet isn't tracked
(it never was, once credentials are shared) — a read closes one occurrence, oldest first. An exit
read with nothing open is a no-op anti-passback signal (signed `anomaly`).
- **`maxConcurrent`** enforced as a fold over the signed ledger by occurrence (`payload.permitId`
match). Refusals (revoked / out-of-window / at-capacity / exit-with-nothing-open) are signed
`anomaly` events.
- **Admin CRUD** (`apps/server/src/routes/subscriptions.ts` + `apps/web/src/SubscriptionManager.tsx`):
a subscription is an **aggregate** (row + credentials + bound plates + price). `GET
/api/subscriptions` (any signed-in role — for lookup), `POST/PUT/DELETE /api/subscriptions[/:id]` +
`POST /api/subscriptions/:id/revoke` (**admin only**). Validation: `maxConcurrent` positive int or
`null`; `priceMinor` non-negative int (currency required when set); at least one credential or one
bound plate.
- **Pricing** stored on each subscription (`priceMinor`/`period`/`currency`), pre-filled from
`site_config.subscription_monthly_price_minor`; **fee collection into the ledger is deferred**
(see Pricing above).
## Open questions
1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]],
[[open-questions]]).
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm.
3. **Subscription-fee collection** — a **shift transaction** (operator takes the monthly fee at the
booth → signed `payment` → folds into their drawer/Z-report). Deferred build; see Pricing.
4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy
above (see the design note).
+5 -4
View File
@@ -1,13 +1,13 @@
--- ---
type: overview type: overview
tags: [parking, index] tags: [parking, index]
updated: 2026-06-14 updated: 2026-06-18
--- ---
# Index # Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 4 sources · 19 entities · 41 concepts · 5 decision records. Counts: 4 sources · 19 entities · 42 concepts · 5 decision records.
## Overview & navigation ## Overview & navigation
- [[overview]] — the top-level synthesis and entry point. - [[overview]] — the top-level synthesis and entry point.
@@ -64,6 +64,7 @@ Counts: 4 sources · 19 entities · 41 concepts · 5 decision records.
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware. - [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth. - [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI. - [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable. - [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog. - [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
@@ -92,12 +93,12 @@ Counts: 4 sources · 19 entities · 41 concepts · 5 decision records.
- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt. - [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt.
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions. - [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger. - [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[permit]] — subscription; RF/QR or plate identity, registered-cars + max-concurrent, host-in-loop; short-circuits payment. - [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness). - [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness).
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed. - [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
## Concepts — frontend / operator UI ## Concepts — frontend / operator UI
- [[booth-console]] — operator-UI architecture: TanStack Query/Router + Zustand + Tailwind terminal theme; one /api/ws live feed (anti-CSWSH). - [[booth-console]] — operator-UI architecture: TanStack Query/Router + Zustand + Tailwind terminal theme; one /api/ws live feed (anti-CSWSH); shift control + device-status footer.
- [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian. - [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian.
## Dev environment (reference) ## Dev environment (reference)
+44
View File
@@ -800,3 +800,47 @@ Audited wiki vs. the session: three major builds (live WebSocket, frontend found
## [2026-06-18] ingest | Shift gating — site-wide single-open, booth money-path gate, per-shift logs ## [2026-06-18] ingest | Shift gating — site-wide single-open, booth money-path gate, per-shift logs
Built the shift-enforcement model. A shift is now **site-wide single-open** (was per-operator): `ShiftService.currentOpenShift()` reads the most recent shift event on the whole chain; `open()` refuses if ANY shift is open and throws `ShiftAlreadyOpenError{heldBy}`. Login stays decoupled from shifts (operator can log in off-shift to review). The booth money path is **gated**: `/api/pay`, `/api/exit`, `/api/voucher`, `/api/barrier/reopen` get a `requireShift` preHandler → 409 `{code:"no_shift"}`; read-only lookups stay open so the modal can display + prompt. `GET /api/shift/current` now returns the site-wide `{open:{startedAt,operator},isMine}`. Logs are **per-shift** via `GET /api/events?since=<shiftStart>`. UI: header shift button (open / close-mine / disabled-when-other), pay-modal gate banner with one-click open, gated Active-Sessions re-open, shift-scoped live feed; shared `useShift()` Query invalidated by the WS on shift/cash events. Updated [[shift]] (new "Site-wide single-open + booth gate" section; superseded the per-operator as-built note) and [[booth-console]] (header control + gate). Verified the invariant + chain integrity on a fresh migrated DB (11/11 assertions). Builds clean across db/server/web. Built the shift-enforcement model. A shift is now **site-wide single-open** (was per-operator): `ShiftService.currentOpenShift()` reads the most recent shift event on the whole chain; `open()` refuses if ANY shift is open and throws `ShiftAlreadyOpenError{heldBy}`. Login stays decoupled from shifts (operator can log in off-shift to review). The booth money path is **gated**: `/api/pay`, `/api/exit`, `/api/voucher`, `/api/barrier/reopen` get a `requireShift` preHandler → 409 `{code:"no_shift"}`; read-only lookups stay open so the modal can display + prompt. `GET /api/shift/current` now returns the site-wide `{open:{startedAt,operator},isMine}`. Logs are **per-shift** via `GET /api/events?since=<shiftStart>`. UI: header shift button (open / close-mine / disabled-when-other), pay-modal gate banner with one-click open, gated Active-Sessions re-open, shift-scoped live feed; shared `useShift()` Query invalidated by the WS on shift/cash events. Updated [[shift]] (new "Site-wide single-open + booth gate" section; superseded the per-operator as-built note) and [[booth-console]] (header control + gate). Verified the invariant + chain integrity on a fresh migrated DB (11/11 assertions). Builds clean across db/server/web.
## [2026-06-18] ingest | Device-status footer — unified monitor across all categories
Generalised printer-only status monitoring to a booth-wide DEVICE-STATUS FOOTER covering relays/readers/cameras/printers. New `DeviceMonitor` (`apps/server/src/device-monitor.ts`) polls every enabled device each tick (default 8s): printers via rich `readStatus()`, all others via the generic `healthCheck()` reachability probe, flattened to one traffic-light (ready/degraded/offline)+detail, deduped (emits on change only), fail-toward-offline (a throw/timeout → offline, never false-healthy). New `device-status` bus event + `GET /api/devices/status` snapshot; live updates ride the existing `/api/ws` (`hello` now carries the initial device set; `device-status` frame per change). Web: live-store `devices` map (setDevices/upsertDevice), WS handler wired, new `DeviceFooter` chip-per-device with an "all ready / N offline" roll-up, mounted in the app shell; `devices` i18n namespace (sq/en). The PrinterMonitor + its SSE stream stay as the printer-specific authority (both run — see the note in [[device-status-monitoring]]). Filed [[device-status-monitoring]] (resolves the code link), cross-linked [[printer-status-monitoring]] + [[booth-console]], indexed (concepts 41→42). Verified on a fresh DB (relay+reader → ready via healthCheck; unreachable printer → offline with detail, no throw; emit-once-then-silent) — 9/9; server+web build clean.
## [2026-06-18] refine | Device footer — role-only labels + click-to-see-issues
Two refinements to the device-status footer. (1) Chips label by ROLE, not vendor: the server sends a structured `roleKind` token per device (reader/camera → direction inherited from the bound relay via `directionOf()`; access → entry/exit/both, or "mixed" across relays; printer → lane/booth) and the client localises category+role → "Lexuesi hyrje", "Printer kabina", "Kamera dalje". Dropped `label`/`role`/driverId from the chip. (2) Fault detail no longer pollutes the footer: chips are compact (dot + label); a degraded/offline chip (or the "N with issues" roll-up) is clickable and opens a small issues panel above the footer listing only the problem devices with state/detail/checked-time (outside-click/Esc to close; no new dependency). i18n `devices.role.*` + issues keys (sq/en). Verified roleKind resolution on a fresh DB (access→mixed, reader(exit)→exit, camera(entry)→entry, printers→lane/booth) 7/7; server+web build clean. Updated [[device-status-monitoring]].
## [2026-06-18] fix | Stuck active session — paid ticket that never got a vehicle_exit (T-397815c0)
Investigated a paid ticket stuck forever in the Active Sessions tab. Root cause (confirmed from the live ledger): the car left via a **manual barrier re-open**, which by design signed an `anomaly` but **never a `vehicle_exit`** — so `activeSessions()` saw it as permanently `open` (the grace-expiry eviction only applied to *exited* sessions). The normal exit that would have signed the exit was refused because walk-back grace (5 min) had expired ~17h earlier. Two fixes: (1) `ExitFlow.reopenBarrier` now signs a `vehicle_exit` (`source:manual`) **when the session is still open**, closing it — while still NOT double-signing an already-exited session (phantom re-close). (2) `PayStation.activeSessions()` ages out a **paid** open session past grace even with no exit (unpaid open sessions never age out — a car owing money stays). Plus a one-off corrective: appended a signed `vehicle_exit` (index 68, `correction:true`) for T-397815c0 through EventLog (chain verified `{ok:true}`), clearing it from the list. Verified both fixes on a fresh DB (9/9; chain intact). Updated [[booth-exit-flow]] (active-session definition + the re-open rule, was "NEVER a vehicle_exit").
## [2026-06-18] ingest | Permit → Subscription rename + monthly pricing (timeframes deferred)
Renamed the "permit" feature to "subscription" (operator term: abonim) and added recurring monthly pricing. FULL rename of mutable master data: tables permits→subscriptions, permit_credentials→subscription_credentials, permit_plates→subscription_plates, sessions.permit_id→subscription_id (data-preserving ALTER RENAMEs, migration 0004); server permit-flow.ts→subscription-flow.ts (SubscriptionFlow), routes/permits.ts→routes/subscriptions.ts (/api/subscriptions), web PermitManager→SubscriptionManager, api types, i18n (sq "Abonimet"/en "Subscriptions"). The signed ledger `permitId` payload field is INTENTIONALLY kept (immutable hash-chained history — renaming would break verification of past events); code/data are "subscription", the on-chain field stays `permitId`. Pricing: per-subscription priceMinor + period("monthly") + currency, with a site default (site_config.subscription_monthly_price_minor) pre-filling the form; collecting the fee into the ledger/shift is DEFERRED (wiki note only). Time-of-day access windows (e.g. overnight subscriber 19:00–07:00, transient outside) documented as a design note in [[subscription]] — NOT implemented; legacy precedent in [[parksql2017-legacy-schema]] (MembershipPlansTime). Renamed [[entities/permit|permit]]→[[subscription]] and swept all [[permit]] wikilinks across the wiki (log.md historical entries left as-was). Verified end-to-end on a fresh migrated DB (schema+pricing, card entry/exit, maxConcurrent cap, on-chain permitId carries the sub id, chain verify) 6/6; migration also applied cleanly to a copy of the live DB (18 sessions preserved). Full monorepo builds clean.
## [2026-06-18] note | Subscription-fee collection is a SHIFT transaction
Clarified (user): collecting/renewing a subscription's monthly fee is a financial transaction a common operator makes DURING their shift — it must reflect in THAT shift's drawer + Z-report, not be an admin-only edit. Updated [[subscription]] (Pricing → "Collecting the fee is a SHIFT transaction"): model it as a signed `payment` event (same `{amountMinor,currency,tender}` shape) tagged `{subscriptionId}` at collection time, so it folds into the open shift automatically (Z-report sums payments by time; drawer adds cash tenders) with no new summing logic. Admin edits the master data; operator takes the money. Subscription entry/exit stay free — only the plan fee is a payment. Still DEFERRED build; cross-linked from [[shift]] ("What End Shift does"). Open: plain `payment`+tag vs. a distinct `subscription_payment` type (leaning plain).
## [2026-06-18] note | Subscription credential type — operator chooses, QR-only for now
The subscription form lets the operator choose the credential type; for now only QR is live. UI change only: the new-credential default is now QR (was RF), and the RFID option is shown DISABLED ("soon", `subs.rfCardTagSoon`) so the choice is visible. Backend + schema keep accepting `kind:'rf'|'qr'` unchanged — re-enabling RFID later is just dropping `disabled` (no migration). Updated [[subscription]] Credentials section.
## [2026-06-18] feat | Subscription QR auto-generation + multi-month coverage
QR credentials are now AUTO-GENERATED server-side (`SUB-<15×base32>`, crypto-random, globally-unique-checked) — the operator/customer never picks the code (anti-fraud); the UI sends a blank QR credential and the server mints+returns the value to print. RF credentials still carry the operator-entered card id. Reader output decided = TCP/IP full string (host-in-the-loop), so the code length is free; noted the Wiegand-26/34 numeric-truncation alternative if ever wired that way (+ the manufacturer reader's ID/IC/NFC+QR / Wiegand/TCP/USB/RS485 / 125kHz+13.56MHz spec — one device covers QR and future RFID). Multi-month: the form takes a `months` count → server sets `validTo = validFrom + N months` (day-clamp), one record/one window, total = N×monthly (collection still deferred); explicit `validTo` override still works; `months` is input-only (truth is validFrom/validTo). Backend: routes/subscriptions.ts (newQrCode/addMonths/resolveValidTo, validate RF-needs-value + months-needs-validFrom). Web: SubscriptionManager (QR shown auto-gen/read-only, months field + live coverage+total preview), api types, i18n (sq/en). Verified via buildServer+inject 9/9 (autogen, uniqueness, RF-blank reject, Jan31+3mo→Apr30, supplied-value preserved). Updated [[subscription]]. No new migration (uses existing columns).
## [2026-06-18] feat | Subscription QR card — printed on creation + reprint, real QR rendering
The auto-generated subscription QR is now PRINTED so the operator can hand it to the customer. Added real 2D QR rendering to the [[rongta-printer]] driver via ESC/POS `GS ( k` (model 2, EC level M; firmware-rendered, no bitmap dep) — new `PrinterDevice.printSubscriptionCard(SubscriptionCardData)`; the card is park header → scannable QR of the code → code text (hand-key fallback) → holder + validity. Server: `printSubscriptionCard()` in booth-print.ts (booth-receipt printer, failover to dispenser); create AUTO-PRINTS best-effort (a print failure never fails the create — response returns `{printed, printError}`); new `POST /api/subscriptions/:id/print` reprint (operator-or-admin; 409 if no QR credential, 503 if no printer). Web: SubscriptionManager surfaces the print outcome on save and a "Print code" button per QR subscription; api types + i18n (sq/en). Verified on the wire via buildServer+inject + a TCP capture (9/9: auto-print, well-formed GS ( k QR bytes with the embedded code, reprint re-sends, no-QR→409). Updated [[subscription]] + [[rongta-printer]]. No migration.
## [2026-06-18] feat | Subscription RFID enrollment — "Read card" capture on a chosen reader
Enabled RFID subscription credentials with a card-enrollment flow. The operator picks a reader and presents the physical card; the value is captured into the credential instead of being typed. New in-memory `CredentialCapture` (single-shot + ~30s TTL): `arm(deviceId)`; `routes/qr-reader.ts` checks `tryConsume()` on each read — an armed reader's read is captured and NOT dispatched (no barrier for an enrolled card), then auto-disarms; reads on the OTHER reader dispatch normally, so its live entry/exit flow is never blocked. Routes (operator/admin): `GET /api/subscriptions/readers` (picker), `POST /capture/arm`, `GET /capture` (poll: idle|armed|captured|expired), `POST /capture/cancel`. Web: RFID re-enabled in the form (was disabled "soon"); "Read card" → reader picker → arm → poll → fills the value; i18n (sq/en). The GEE readers are combo QR+RFID (ID/IC/NFC), same endpoint, so one device captures both. Verified via buildServer+inject + reader-scan simulation (12/12: captured-not-dispatched, single-shot, other reader still drives a live vehicle_exit while armed, value retrievable, cancel). Updated [[subscription]]. No migration.
## [2026-06-18] feat | Subscriptions — enter with one credential, exit with another (+ FIFO fleets)
Decoupled subscription exit from the entry credential. Previously the session was keyed by the exact credential value read (an accidental coupling → must exit with the same QR/RFID you entered with). Now sessions are keyed by a per-occurrence id (`SUBSESS-<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.
+1 -1
View File
@@ -74,7 +74,7 @@ clear anti-patterns, e.g. money as `float`).
1. **Time-of-day + date windows on the rate card** (`ValidFromHour`/`ValidToHour`, 1. **Time-of-day + date windows on the rate card** (`ValidFromHour`/`ValidToHour`,
`ValidFrom`/`ValidTo`) — the shipped way to do **happy hour / seasonal**. See [[tariff-time-tiers]]. `ValidFrom`/`ValidTo`) — the shipped way to do **happy hour / seasonal**. See [[tariff-time-tiers]].
2. **Vehicle/customer category as a pricing axis** (`BA_TicketCategory`). See [[tariff-time-tiers]]. 2. **Vehicle/customer category as a pricing axis** (`BA_TicketCategory`). See [[tariff-time-tiers]].
3. **Time-/day-restricted memberships** (`MembershipPlansTime`, `ActiveDays`) — a [[permit]] gap. 3. **Time-/day-restricted memberships** (`MembershipPlansTime`, `ActiveDays`) — a [[subscription]] gap.
## Anti-patterns to NOT copy ## Anti-patterns to NOT copy
- **Money as `float`** everywhere (`Charge`, `Price`, `LostPenalty`) — drifts across a revenue - **Money as `float`** everywhere (`Charge`, `Price`, `LostPenalty`) — drifts across a revenue