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:
+216
-13
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user