import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db"; import { computeFee, type TariffStructure, type Tender } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import type { EventLog } from "./event-log.js"; // The PAY STATION: a customer pays for an open session BEFORE walking back to the // car (pay-on-foot — payment is decoupled from exit). Two steps: // 1. quote(identity) → look up the open session, price it against the tariff in // force at entry, return the amount due (no side effect). // 2. pay(identity, tender) → re-price, append a SIGNED `payment` event carrying // the amount, currency, tender, tariffVersionId, and graceExitMin (so the exit // flow can validate paid + within walk-back grace). Payment is a signed ledger // event, never a mutable "paid" flag — an operator can't forge or delete it. // See wiki/concepts/tariff.md, parking-session.md. export class NoOpenSessionError extends Error { constructor(identity: string) { super(`no open session for ${identity}`); this.name = "NoOpenSessionError"; } } export class NoTariffError extends Error { constructor() { super("no active tariff configured"); this.name = "NoTariffError"; } } export interface Quote { readonly identity: string; readonly enteredAt: string; readonly amountMinor: number; readonly currency: string; readonly tariffVersionId: string; readonly graceExitMin: number; } /** One row in the booth Active Sessions list. A session is "active" while it is * still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a * paid/exited car is presumed possibly-still-present until grace expires. The * "Open barrier" action is offered only when `paidAt != null` (no payment, no * button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */ export interface ActiveSession { readonly identity: string; readonly source: string | null; readonly enteredAt: string; /** null while still inside; set once a vehicle_exit is signed (may still be present). */ readonly exitedAt: string | null; readonly open: boolean; readonly paidAt: string | null; /** Amount owed now (open + unpaid only; null otherwise / no tariff). */ readonly amountMinor: number | null; readonly currency: string | null; readonly withinGrace: boolean; readonly graceExpiresAt: string | null; } /** Booth session view: everything the pay/exit modal needs in one read. */ export interface SessionLookup { readonly identity: string; readonly found: boolean; /** Open = entered, no exit yet. */ readonly open: boolean; readonly enteredAt: string | null; readonly exitedAt: string | null; /** Latest payment time, if paid. */ readonly paidAt: string | null; /** Amount owed right now (the quote). Null when no session / no active tariff. */ readonly amountMinor: number | null; readonly currency: string | null; /** True when paid AND still within the walk-back grace window. */ readonly withinGrace: boolean; /** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */ readonly graceExpiresAt: string | null; } export class PayStation { readonly #db: Db; readonly #log: EventLog; readonly #logger: FastifyBaseLogger; constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) { this.#db = db; this.#log = log; this.#logger = logger; } /** Price an open session against the tariff in force at its entry. No side effect. */ quote(identity: string): Quote { const entry = this.#openEntry(identity); if (!entry) throw new NoOpenSessionError(identity); const tv = this.#tariffVersionFor(entry.occurredAt); if (!tv) throw new NoTariffError(); const structure = tv.structure as unknown as TariffStructure; const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure); return { identity, enteredAt: entry.occurredAt, amountMinor, currency: tv.currency, tariffVersionId: tv.id, graceExitMin: structure.gracePeriodExitMin, }; } /** * Take payment for a session and append the signed `payment` event. Re-quotes at * the moment of payment (the customer pays for time parked SO FAR). For an * overstay top-up the same call re-prices entry→now and the exit flow's * grace-window restarts from this payment. `overrideMinor` lets the operator set * an arbitrary amount (lost ticket / dispute) — recorded as the charged amount. */ async pay( identity: string, tender: Tender, overrideMinor?: number, ): Promise<{ amountMinor: number; currency: string }> { const q = this.quote(identity); const amountMinor = overrideMinor ?? q.amountMinor; await this.#log.append({ type: "payment", source: "manual", identity, payload: { sessionRef: identity, amountMinor, currency: q.currency, tender, tariffVersionId: q.tariffVersionId, // The exit flow reads graceExitMin off the payment to validate the // walk-back window without re-resolving the tariff. graceExitMin: q.graceExitMin, ...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}), }, }); // Update the projection cache (rebuildable; not the source of truth). try { this.#db.update(sessions).set({ state: "paid" }).where(eq(sessions.id, identity)).run(); } catch (err) { this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`); } this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`); return { amountMinor, currency: q.currency }; } /** * One-read session view for the booth pay/exit modal: entry/exit times, paid * state, amount owed now, and walk-back-grace status. Read-only — folds the * signed ledger (authoritative). A quote failure (no tariff) leaves amount null * rather than throwing, so the modal can still show the session. */ lookup(identity: string): SessionLookup { const id = identity.trim(); const rows = this.#db .select() .from(ledgerEvents) .where(eq(ledgerEvents.identity, id)) .orderBy(ledgerEvents.index) .all(); const entry = rows.find((r) => r.type === "vehicle_entry"); if (!entry) { return { identity: id, found: false, open: false, enteredAt: null, exitedAt: null, paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null, }; } const exitRow = rows.find((r) => r.type === "vehicle_exit"); const open = !exitRow; 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 { graceExitMin?: number }; if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin; } } const graceExpiresAt = paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null; const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt); // Amount owed now (best-effort; null if no tariff resolves). Only meaningful while open. let amountMinor: number | null = null; let currency: string | null = null; if (open) { try { const q = this.quote(id); amountMinor = q.amountMinor; currency = q.currency; } catch { /* no active tariff — leave null; modal shows session without a price */ } } return { identity: id, found: true, open, enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null, paidAt, amountMinor, currency, withinGrace, graceExpiresAt, }; } /** * All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace * (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present * until grace expires). One ledger scan, grouped by identity (cheaper than N * lookups). Sorted by entry time, newest first. Folds the SIGNED ledger * (authoritative — not the sessions projection cache, which can drift). * See wiki/concepts/booth-exit-flow.md. */ activeSessions(): ActiveSession[] { const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); // Group the relevant events per identity in one pass. type Acc = { enteredAt?: string; source: string | null; exitedAt?: string; paidAt?: string; graceExitMin?: number }; const byId = new Map(); for (const r of rows) { const id = r.identity; if (!id) continue; if (r.type === "vehicle_entry") { const a = byId.get(id) ?? { source: r.source ?? null }; a.enteredAt = r.occurredAt; a.source = r.source ?? a.source; byId.set(id, a); } else if (r.type === "vehicle_exit") { const a = byId.get(id); if (a) a.exitedAt = r.occurredAt; } else if (r.type === "payment") { const a = byId.get(id); if (a) { a.paidAt = r.occurredAt; const p = (r.payload ?? {}) as { graceExitMin?: number }; if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin; } } } const now = Date.now(); const out: ActiveSession[] = []; for (const [identity, a] of byId) { if (!a.enteredAt) continue; // no entry → not a real session const open = a.exitedAt == null; const graceExpiresAt = a.paidAt && a.graceExitMin != null ? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString() : null; const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt); const paid = a.paidAt != null; // ACTIVE membership: // - exited + within grace → still shown (barrier unconfirmed, may be present); // - exited + past grace → presumed gone, omitted; // - open + UNPAID → always shown (a car owing money never ages out — // it's genuinely still inside until it pays, however long that takes); // - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace // lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a // manual barrier re-open before that path closed the session, or a historical // session like T-397815c0) it would otherwise linger forever. The signed log // is unchanged — this is purely a display filter. See booth-exit-flow.md. if (!open && !withinGrace) continue; if (open && paid && graceExpiresAt != null && !withinGrace) continue; // Amount owed now: only meaningful for an open + unpaid session. let amountMinor: number | null = null; let currency: string | null = null; if (open && a.paidAt == null) { try { const q = this.quote(identity); amountMinor = q.amountMinor; currency = q.currency; } catch { /* no active tariff — leave null */ } } out.push({ identity, source: a.source, enteredAt: a.enteredAt, exitedAt: a.exitedAt ?? null, open, paidAt: a.paidAt ?? null, amountMinor, currency, withinGrace, graceExpiresAt, }); } // Newest entry first. out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt)); return out; } /** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */ #openEntry(identity: string) { const rows = this.#db .select() .from(ledgerEvents) .where(eq(ledgerEvents.identity, identity)) .orderBy(ledgerEvents.index) .all(); const entry = rows.find((r) => r.type === "vehicle_entry"); if (!entry) return null; if (rows.some((r) => r.type === "vehicle_exit")) return null; // already closed return entry; } /** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the * (single, for now) active site tariff. */ #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; } }