feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots

Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth
reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and
signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket
id barcode; site_config.exit_voucher_default (migration 0002) drives the default.
Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher.

Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'),
SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
This commit is contained in:
2026-06-18 11:05:10 +02:00
parent 9956488fd5
commit 06dab1e790
14 changed files with 1891 additions and 24 deletions
+216 -13
View File
@@ -1,6 +1,6 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import type { ResolvedRelay } from "./device-resolve.js";
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
@@ -38,6 +38,21 @@ interface SessionView {
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
}
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
* the barrier didn't open (payment stands; operator opens manually). */
export type BoothExitResult =
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
| { ok: true; opened: true }
| { ok: true; opened: false; reason: string };
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
* intervention was recorded (signed anomaly) but the relay did not fire. */
export type BoothReopenResult =
| { ok: false; reason: string }
| { ok: true; opened: boolean; reason?: string };
export class ExitFlow {
readonly #db: Db;
readonly #log: EventLog;
@@ -50,6 +65,170 @@ export class ExitFlow {
this.#logger = logger;
}
/**
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
* relay site-wide, pulses it, and fires the exit snapshot.
*
* Returns a discriminated result so the route can react precisely:
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
* — nothing is signed beyond the existing anomaly; the operator takes payment.
* - { ok: true, opened: true } on a clean exit.
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
* operator opens manually. Payment is never rolled back.
*/
async exitForBooth(identity: string): Promise<BoothExitResult> {
const id = identity.trim();
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
const key = `booth:${id}`;
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
this.#inFlight.add(key);
try {
const view = this.#sessionFor(id);
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
// path) so a booth attempt on a bad ticket is auditable.
if (!view || !view.open) {
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for ticket";
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
return { ok: false, status: view ? "closed" : "no_session", reason };
}
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
const freeGrace = view.paidAt == null && view.freeGrace != null;
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!freeGrace && (!paid || !withinGrace)) {
const reason = !paid
? "exit refused — not paid (take payment first)"
: "exit refused — walk-back grace expired (top-up required)";
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason };
}
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
// reader path does.
if (freeGrace && view.freeGrace) {
await this.#log.append({
type: "payment",
identity: id,
payload: {
sessionRef: id,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
reason: "free entry-grace (no charge)",
},
});
}
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
// to let the car out has been made and validated. Then attempt the open.
await this.#signExit(id);
if (!resolved) {
await this.#openFailedAnomaly(id, "no exit relay configured");
return { ok: true, opened: false, reason: "exit recorded, but no exit barrier is configured — open manually" };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
await this.#openFailedAnomaly(id, "exit controller would not build");
return { ok: true, opened: false, reason: "exit recorded, but the barrier is unavailable — open manually" };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
return { ok: true, opened: false, reason: "exit recorded, but the barrier did not open — open manually" };
}
this.#fireExitSnapshot(id);
this.#closeSessionCache(id);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/**
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
* opens the barrier with a signed trace.
*
* 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
* `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.
* See wiki/concepts/booth-exit-flow.md.
*/
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
const id = identity.trim();
if (!id) return { ok: false, reason: "ticket id required" };
const view = this.#sessionFor(id);
if (!view) return { ok: false, reason: "no session for ticket" };
// No payment → no re-open. The barrier-open action is only for sessions that
// have been paid (or paid-then-exited within grace). An unpaid car takes the
// pay/exit flow instead — enforced here, not just in the UI.
if (view.paidAt == null) {
return { ok: false, reason: "session not paid — no barrier open without payment" };
}
const key = `reopen:${id}`;
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
this.#inFlight.add(key);
try {
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
// the physical open succeeds) — never a second vehicle_exit.
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
reason: "manual barrier open (human intervention)",
source: "booth",
barrierReopen: true,
...(operator ? { operator } : {}),
},
});
if (!resolved) {
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
return { ok: true, opened: false, reason: "no exit barrier configured — open manually" };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
return { ok: true, opened: false, reason: "barrier unavailable — open manually" };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
return { ok: true, opened: false, reason: "barrier did not open — open manually" };
}
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/** 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). */
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
@@ -131,36 +310,60 @@ export class ExitFlow {
* Shared by the paid-exit and free-entry-grace paths. The caller has already
* established the session is allowed out (and, for grace, minted the $0 payment). */
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source: e.kind === "plate" ? "lpr" : "ticket",
identity: e.value,
payload: { sessionRef: e.value },
});
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
// SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate).
this.#fireExitSnapshot(e.value);
this.#closeSessionCache(e.value);
return { accepted: true, direction: "exit" };
}
/** Append the signed vehicle_exit. `source` defaults to "ticket" (booth/manual). */
async #signExit(identity: string, source: "ticket" | "lpr" = "ticket"): Promise<void> {
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source,
identity,
payload: { sessionRef: identity },
});
}
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
#fireExitSnapshot(identity: string): void {
void snapshotAsync({
db: this.#db,
direction: "exit",
identity: e.value,
identity,
logger: this.#logger,
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
}
/** Update the (rebuildable) session projection cache to closed. */
#closeSessionCache(identity: string): void {
try {
this.#db
.update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "closed" })
.where(eq(sessions.id, e.value))
.where(eq(sessions.id, identity))
.run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
}
return { accepted: true, direction: "exit" };
}
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
* The payment + exit STAND; this tells the operator to open manually. */
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity,
payload: { reason: "exit signed but barrier open failed", detail, source: "booth", exitOpenFailed: true },
});
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
}
/** Fold the signed ledger into a session view for one identity (authoritative). */