33c4ea1e91
Two halves of one anti-fraud design.
(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
- PRESENCE-GATED like the physical button: a real car must be present (radar/
loop AND camera busy). Enforced BOTH sides — the server re-checks current
presence so a direct POST can't bypass a disabled button; no presence loop
=> feature unavailable; a no-presence attempt signs an anomaly.
- FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
companion entry.operatorIssued anomaly (the adversary path always leaves a
red-flag row).
- capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
a legit car).
New session:create permission (migration 0019 -> operator role, admin-
revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
factored into one shared #issueTicket (button + operator). UI: the entry
BarrierLight becomes a clickable issue-control when presence+permission+shift
meet (confirm -> issue).
(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
- BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
pay/exit modal shows a red warning + "Override & release" (override signs an
attributed exit.plateSwapOverride). Flag+override, never a silent hard block
(exit fails-open; a plate is never the sole gate).
- READER path (no operator): log-only anomaly + fail-open.
Extended BoothExitResult + /api/exit (override); boothExit client returns a
structured swap result.
Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
598 lines
28 KiB
TypeScript
598 lines
28 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 { plateForIdentity, platesForIdentities } from "./plate-lookup.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 }
|
|
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
|
|
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
|
|
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
|
|
// See wiki/concepts/plate-reconciliation.md.
|
|
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
|
|
| { 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 };
|
|
|
|
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
|
|
* exiting read and the matched open session's entry read. Below this, the read is advisory-
|
|
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
|
|
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
|
|
|
|
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, opts?: { override?: boolean; operator?: 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 };
|
|
}
|
|
|
|
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
|
|
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
|
|
// (unless they consciously override). A denial here never traps the car — exit fails
|
|
// open and the operator can override; the anomaly is the control either way.
|
|
const swap = this.#reconcilePlateAtExit(id);
|
|
if (swap) {
|
|
if (!opts?.override) {
|
|
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
|
|
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
|
|
await this.#log.append({
|
|
type: "anomaly",
|
|
identity: id,
|
|
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
|
|
});
|
|
this.#fireExitSnapshot(id);
|
|
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
|
|
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
|
|
}
|
|
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
|
|
await this.#log.append({
|
|
type: "anomaly",
|
|
identity: id,
|
|
payload: {
|
|
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
|
source: "booth",
|
|
plateSwapOverride: true,
|
|
plate: swap.plate,
|
|
otherIdentity: swap.otherIdentity,
|
|
...(opts.operator ? { operator: opts.operator } : {}),
|
|
},
|
|
});
|
|
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
|
|
}
|
|
|
|
// 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 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<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 };
|
|
}
|
|
|
|
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
|
|
// automated lane to make the override decision, and exit fails open for safety, so we
|
|
// sign the suspicion anomaly (the control here) and still let the car out. The booth
|
|
// path (operator-mediated) is where the hold + override lives.
|
|
const swap = this.#reconcilePlateAtExit(e.value);
|
|
if (swap) {
|
|
await this.#log.append({
|
|
type: "anomaly",
|
|
identity: e.value,
|
|
payload: {
|
|
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
|
plateSwapSuspected: true,
|
|
plate: swap.plate,
|
|
otherIdentity: swap.otherIdentity,
|
|
},
|
|
});
|
|
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
|
|
}
|
|
|
|
// 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}`);
|
|
}
|
|
|
|
/**
|
|
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
|
|
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
|
|
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
|
|
* or two tickets were mixed up. We compare the EXITING plate against every open session's
|
|
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
|
|
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
|
|
* or null. See wiki/concepts/plate-reconciliation.md.
|
|
*/
|
|
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
|
|
// The exiting car's plate: prefer its own exit read, else its entry read.
|
|
const mine = plateForIdentity(this.#db, exitingId);
|
|
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
|
|
const wanted = mine.plate.trim().toUpperCase();
|
|
|
|
// All currently-open sessions (from the projection cache — a fast read-model; the check
|
|
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
|
|
const openIds = this.#db
|
|
.select({ id: sessions.id })
|
|
.from(sessions)
|
|
.where(eq(sessions.state, "open"))
|
|
.all()
|
|
.map((r) => r.id)
|
|
.filter((id) => id !== exitingId);
|
|
if (openIds.length === 0) return null;
|
|
|
|
const plates = platesForIdentities(this.#db, openIds);
|
|
for (const [otherId, pv] of plates) {
|
|
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
|
|
if (pv.plate.trim().toUpperCase() !== wanted) continue;
|
|
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
|
|
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
|
|
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|
|
}
|