Files
parking_solution/apps/server/src/exit-flow.ts
T
julian ecaaefd899 refactor(vision): ANPR rides the entry/exit snapshot, drop polling reader
Rework the ANPR trigger to the real design: when a transient presses the button or a
subscriber passes QR/RFID, the entry/exit fires and takes its evidence snapshot — that
is the moment to recognize. snapshotAsync now takes the VisionClient and, after storing
each snapshot from an opt-in (config.anpr) camera, runs ANPR on the SAME image and
records the plate against the SAME session identity (device_events kind:"read" with
plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both
evidence and plate extraction; recognition fires only on a real entry/exit — no polling.

The entry/exit/subscription flows take an optional VisionClient and pass it through;
server.ts wires it. Removed the polling VisionReader and VISION_POLL_MS/VISION_DEDUPE_MS.

Advisory + fire-and-forget: a low-confidence/no-plate result records nothing, a vision
failure never delays or changes the open, and the plate does not feed the access
decision. Verified e2e: a simulated entry snapshot on an anpr camera (live fast_alpr)
stored the snapshot for the session and recorded {identity, plate:AA558EE, 0.999,
region:Albania, snapshotId}. Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:53:43 +02:00

485 lines
21 KiB
TypeScript

import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
// the session → validate it is PAID and within the walk-back grace → sign a
// vehicle_exit → open. Payment is decoupled from exit (it happens earlier at the
// pay station); the exit lane only VALIDATES. See wiki/concepts/parking-session.md.
//
// Validation is a fold over the SIGNED ledger (the authoritative record), not the
// projection cache: find the open vehicle_entry for this identity, then a covering
// payment within grace. The cache is updated after, for fast reads.
//
// REJECT (barrier stays closed) when unpaid / over grace — this is correct business
// logic, NOT a fail-state. "Exit fails OPEN" (fail-state-safety) is about the SYSTEM
// being unable to decide (power/host loss), not about an unpaid car; an unpaid driver
// is sent back to the pay station, the rejection is logged.
//
// NOTE: payments / the pay station don't exist yet, so no session is ever PAID — every
// transient exit currently REJECTS (logged). That's the correct end-state; it becomes
// passable once the pay-station + `payment` events land.
interface SessionView {
readonly identity: string;
readonly enteredAt: string;
readonly open: boolean; // no vehicle_exit yet
readonly paidAt: string | null; // latest payment time, if any
/** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to
* exit / re-open without a `payment`. */
readonly subscription: boolean;
readonly graceExitMin: number | null; // from the payment's tariff context, if known
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
// active tariff resolves (then we fall back to the normal paid check).
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;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
/** Optional vision client — passed to snapshotAsync so ANPR runs on the exit image. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#logger = logger;
this.#vision = vision;
}
/**
* 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 rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: view ? "closed" : "no_session", reason: rp.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 rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.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,
...reasonPayload("exit.freeGrace"),
},
});
}
// 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: renderReasonEn("exit.open.noBarrier") };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
await this.#openFailedAnomaly(id, "exit controller would not build");
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
}
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). It re-pulses the exit relay and signs an `anomaly`
* ("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.
*/
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" };
// Authorization to re-open: a PAID transient (paid, or paid-then-exited within
// grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must
// assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit
// flow instead — enforced here, not just in the UI (the no-unpaid-bypass rule).
if (view.paidAt == null && !view.subscription) {
return { ok: false, reason: "session not paid — no barrier open without payment" };
}
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).
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
...reasonPayload("exit.manualOpen"),
source: "booth",
barrierReopen: true,
...(operator ? { operator } : {}),
},
});
// 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) {
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
}
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: renderReasonEn("exit.open.unavailable") };
}
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: renderReasonEn("exit.open.failed") };
}
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 subscription match). */
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const key = `${e.deviceId}:${e.value}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#runExit(resolved, e);
} catch (err) {
this.#logger.error(`exit-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const view = this.#sessionFor(e.value);
// No matching open session — unknown/duplicate ticket. Reject + log.
if (!view || !view.open) {
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: { ...rp, exitRefused: true },
});
this.#fireExitSnapshot(e.value);
this.#logger.warn(`exit refused: no open session for ${e.value}`);
return { accepted: false, direction: "exit", reason: rp.reason };
}
// FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate
// with no pay-station visit. Mint a signed $0 `payment` first so the ledger keeps
// its "an exit is covered by a payment" invariant, then fall through to open.
// Only when NOT already paid (a real payment, walk-back grace, takes precedence).
if (view.paidAt == null && view.freeGrace) {
await this.#log.append({
type: "payment",
// No `source` (not operator-keyed nor a read) — the payload reason marks it.
identity: e.value,
payload: {
sessionRef: e.value,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
...reasonPayload("exit.freeGrace"),
},
});
this.#logger.info(`exit free within entry-grace (${e.value})`);
return this.#signExitAndOpen(resolved, e);
}
// PAID + within walk-back grace?
const paid = view.paidAt != null;
const withinGrace =
paid &&
view.graceExitMin != null &&
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!paid || !withinGrace) {
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: { ...rp, exitRefused: true, sessionRef: e.value },
});
this.#fireExitSnapshot(e.value);
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
return { accepted: false, direction: "exit", reason: rp.reason };
}
// Valid (a real payment within walk-back grace): sign + open.
return this.#signExitAndOpen(resolved, e);
}
/** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
* 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.#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`);
this.#fireExitSnapshot(e.value);
this.#closeSessionCache(e.value);
return { accepted: true, direction: "exit" };
}
/** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
* 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({
type: "vehicle_exit",
direction: "exit",
source,
identity,
payload: {
sessionRef: identity,
...(source === "manual" ? reasonPayload("exit.manualOpen") : {}),
},
});
}
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
#fireExitSnapshot(identity: string): void {
void snapshotAsync({
db: this.#db,
direction: "exit",
identity,
logger: this.#logger,
vision: this.#vision,
}).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, identity))
.run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
}
}
/** 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: { ...reasonPayload("exit.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). */
#sessionFor(identity: string): SessionView | null {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
if (rows.length === 0) return null;
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
const exited = rows.some((r) => r.type === "vehicle_exit");
let paidAt: string | null = null;
let graceExitMin: number | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as LedgerPayload & { graceExitMin?: number };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
}
}
// Free entry-grace: if the tariff prices entry→now at 0 (a quick in-and-out),
// the exit may open at the gate. Resolve against the tariff in force at entry,
// same as the pay station. Null when no payment is needed yet and no tariff
// resolves — then exit falls back to the normal paid check.
let freeGrace: SessionView["freeGrace"] = null;
if (!exited && paidAt == null) {
const tv = this.#tariffVersionFor(entry.occurredAt);
if (tv) {
const structure = tv.structure as unknown as TariffStructure;
// Same frozen-at-entry category the pay station uses, so the free-grace
// check agrees with the booth quote for V2 category tariffs.
const category = (entry.payload as { category?: string } | null)?.category;
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
if (fee === 0) {
freeGrace = {
tariffVersionId: tv.id,
currency: tv.currency,
graceExitMin: structure.gracePeriodExitMin,
};
}
}
}
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const subscription = entryPl.permit === true || entryPl.permitId != null;
return {
identity,
enteredAt: entry.occurredAt,
open: !exited,
paidAt,
subscription,
graceExitMin,
freeGrace,
};
}
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
#tariffVersionFor(at: string) {
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = this.#db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
/** 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;
}
}
}