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(); /** 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 { 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 { 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 SUBSCRIPTION occurrence (prepaid — exactly the case // the operator must assist when the exit reader / card fails) OR a transient whose // payment is STILL WITHIN the walk-back grace window. A stale payment does NOT // authorize a free open: a car that paid once and then sat inside past grace owes a // top-up for the extra time — letting it out on the old payment is the overstay-fraud // path. So we mirror the exit flow's grace check here (not just in the UI): an // unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead. // The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side. const paid = view.paidAt != null; const withinGrace = paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000; if (!view.subscription && (!paid || !withinGrace)) { return { ok: false, reason: paid ? "walk-back grace expired — take a top-up payment first" : "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 { 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 { 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 { 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 { 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 { 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; // A `void` (cancelled ticket) closes the session like an exit, so a voided ticket // presented at exit reads as "already closed" — never re-opens. See void-flow.ts. const exited = rows.some((r) => r.type === "vehicle_exit" || r.type === "void"); 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; } } }