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; } 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", lane: -1, // payment happens at a central station, not a lane 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 }; } /** 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; } }