ecaaefd899
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
485 lines
21 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|