import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db"; import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import type { EventLog } from "./event-log.js"; import { plateForIdentity, platesForIdentities } from "./plate-lookup.js"; import { windowOwedBetween } from "./subscription-window.js"; import { liveValidations } from "./validations.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; /** Vehicle entry time (the session's original entry; for display/audit). */ readonly enteredAt: string; /** Start of the period being billed RIGHT NOW. For a first payment this is the * entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car * re-parked / a new period began) it is the moment that grace expired: the overstay * is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT * "full stay minus paid" (which a daily cap collapses toward zero). */ readonly periodStart: string; /** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */ readonly amountMinor: number; /** The pre-validation fee (= amountMinor when no validations apply). */ readonly grossMinor: number; /** Total the merchant validations took off (gross − net). */ readonly discountMinor: number; /** Per-validation receipt/display lines (empty when none apply). */ readonly validationLines: ValidationLine[]; /** The validation event ids this quote applied — the payment stamps them as * CONSUMED so an overstay's fresh period never re-applies them. */ readonly validationIds: string[]; /** True when this quote prices an overstay period (grace lapsed), not the first stay. */ readonly overstay: boolean; 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; /** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit. * The car either re-parked (a new period began) or is faulty/abandoned — not a system * fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from * grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator * reconciles via a top-up, instead of silently aging it out. No free barrier open. * See wiki/concepts/booth-exit-flow.md. */ readonly overstay: boolean; /** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it * with snapshots + an always-available "open barrier" (assist a faulty exit reader / * missing card), and never a pay flow. See wiki/entities/subscription.md. */ readonly subscription: boolean; /** The subscription id (on-chain `permitId`), when `subscription` is true. */ readonly subscriptionId: string | null; /** The subscriber's holder name (for a friendly label instead of the raw key). */ readonly subscriptionHolder: string | null; /** Advisory licence plate recognized for this session (ANPR-on-snapshot), shown for * at-a-glance identification. Null when no plate was read. Never an access decision. */ readonly plate: 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; /** Amount actually PAID (from the latest payment event), if any. Distinct from * `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null, * but the operator still wants to see the sum that was collected. */ readonly paidMinor: number | null; readonly paidCurrency: 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; /** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period * began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */ readonly overstay: boolean; /** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */ readonly subscription: boolean; readonly subscriptionId: string | null; readonly subscriptionHolder: string | null; /** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when * none. Display/audit only — never an access decision. */ readonly plate: string | null; /** Merchant validations folded into `amountMinor` (which is NET): the pre-discount * fee, the total taken off, and the per-validation lines for the modal/receipt. * grossMinor/discountMinor are null when no quote resolved. */ readonly grossMinor: number | null; readonly discountMinor: number | null; readonly validationLines: ValidationLine[]; } 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. Normally the period is entry→now. But for an OVERSTAY — a * paid session whose walk-back grace has lapsed (the car re-parked, or a new period * began) — the customer is billed for a FRESH period from grace-expiry→now, with its * own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the * whole-stay gross plateaus while prior payments keep pace, so the delta collapses to * 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period * reflects the reality and re-accrues the fee. No side effect. */ quote(identity: string): Quote { const entry = this.#openEntry(identity); if (!entry) throw new NoOpenSessionError(identity); // The tariff in force is keyed to ENTRY (the version frozen for this session), even // for an overstay period — the customer keeps the rate card they entered under. const tv = this.#tariffVersionFor(entry.occurredAt); if (!tv) throw new NoTariffError(); const structure = tv.structure as unknown as TariffStructure; // Category was frozen in the signed vehicle_entry payload — pricing AND repricing // both read it from there, so a V2 category tariff yields the same amount at the // booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing. const category = (entry.payload as { category?: string } | null)?.category; // Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment // matters for grace/overstay; pass it through. Overstay → fresh period from // grace-expiry; within-grace → settled; unpaid → entry→now running total. // Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a // prior payment) so the quote is NET — the payment then stamps their ids as // consumed. See wiki/concepts/validation-discounts.md. const last = this.#lastPayment(identity); const validations = liveValidations(this.#db, identity); const p = priceSession( entry.occurredAt, new Date().toISOString(), structure, last ? [last] : [], category, validations, ); return { identity, enteredAt: entry.occurredAt, periodStart: p.periodStart, amountMinor: p.amountMinor, grossMinor: p.grossMinor, discountMinor: p.discountMinor, validationLines: p.validationLines, validationIds: validations.map((v) => v.eventId), overstay: p.overstay, currency: tv.currency, tariffVersionId: tv.id, graceExitMin: structure.gracePeriodExitMin, }; } /** The latest signed `payment` for this session (time + the grace window it granted), * or null if never paid. Folds the append-only ledger. */ #lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null { const rows = this.#db .select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload }) .from(ledgerEvents) .where(eq(ledgerEvents.identity, identity)) .orderBy(ledgerEvents.index) .all(); let last: { paidAt: string; graceExitMin: number | null } | null = null; for (const r of rows) { if (r.type !== "payment") continue; const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin; last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null }; } return last; } /** * 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 * (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`), * and this payment writes a new `graceExitMin` so the walk-back window restarts. * `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 }> { // A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here // (not a transient quote — the subscription itself is prepaid). The payment is keyed // to the occurrence so the exit gate (#windowOwed − payments) clears. const subWindow = this.#payableSubscriptionWindow(identity); if (subWindow) { const amountMinor = overrideMinor ?? subWindow.dueMinor; await this.#log.append({ type: "payment", source: "manual", identity, payload: { sessionRef: identity, amountMinor, currency: subWindow.currency ?? undefined, tender, ...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}), subscriptionWindowCharge: true, ...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}), }, }); this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`); return { amountMinor, currency: subWindow.currency ?? "" }; } 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, // Merchant validations: record the gross/discount split + CONSUME the applied // validation ids, so reporting sees the leakage and a later overstay period // never re-applies them. A zero-net settlement (full comp) is still a signed // payment — grace/voucher/exit work unchanged. See validation-discounts.md. ...(q.validationIds.length ? { grossMinor: q.grossMinor, discountMinor: q.discountMinor, validationIds: q.validationIds, validationLines: q.validationLines.map((l) => ({ ...l })), } : {}), ...(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, paidMinor: null, paidCurrency: null, withinGrace: false, graceExpiresAt: null, overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null, grossMinor: null, discountMinor: null, validationLines: [], }; } // Subscription occurrence? The entry payload carries permit:true + permitId. const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string }; const isSubscription = entryPl.permit === true || entryPl.permitId != null; const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null; // A `void` (cancelled ticket) closes the session like an exit — a voided ticket is no // longer open and can't be paid/exited. See void-flow.ts. const exitRow = rows.find((r) => r.type === "vehicle_exit" || r.type === "void"); const open = !exitRow; let paidAt: string | null = null; let graceExitMin: number | null = null; let paidMinor: number | null = null; let paidCurrency: string | null = null; for (const r of rows) { if (r.type === "payment") { paidAt = r.occurredAt; const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string }; if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin; // Sum payments (overstay top-ups append a second one) so the displayed paid total // reflects everything collected for the session, not just the last slip. if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor; if (typeof p.currency === "string") paidCurrency = p.currency; } } 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). For a TRANSIENT session // it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a // time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried // on the entry payload + a live late-exit charge), which the booth must take so the // exit gate clears. See wiki/entities/subscription.md. let amountMinor: number | null = null; let currency: string | null = null; let grossMinor: number | null = null; let discountMinor: number | null = null; let validationLines: ValidationLine[] = []; if (open && !isSubscription) { try { const q = this.quote(id); amountMinor = q.amountMinor; currency = q.currency; grossMinor = q.grossMinor; discountMinor = q.discountMinor; validationLines = q.validationLines; } catch { /* no active tariff — leave null; modal shows session without a price */ } } else if (open && isSubscription) { const w = this.#subscriptionWindowDue(id, subscriptionId); if (w && w.dueMinor > 0) { amountMinor = w.dueMinor; currency = w.currency; } } const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace; return { identity: id, found: true, open, enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null, paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay, subscription: isSubscription, subscriptionId, subscriptionHolder: this.#holderOf(subscriptionId), plate: plateForIdentity(this.#db, id)?.plate ?? null, grossMinor, discountMinor, validationLines, }; } /** * 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; subscriptionId?: string | null; }; 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; // Subscription occurrence? The entry payload carries permit:true + permitId // (the on-chain field). Mark it so the booth never tries to charge it. const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string }; if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null; byId.set(id, a); } else if (r.type === "vehicle_exit" || r.type === "void") { // A `void` closes the session like an exit — drop it from the active list. 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; } } } // Resolve advisory plates for all candidate identities in ONE device_events scan // (cheaper than one lookup per row). const plates = platesForIdentities(this.#db, byId.keys()); 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; const isSubscription = a.subscriptionId !== undefined; // 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 → OVERSTAY. A paid transient whose walk-back grace // lapsed with no signed vehicle_exit: the car re-parked (a new period) or is // faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy // and owes a fresh period (priced from grace-expiry, see `quote`). We used to // age these out (a silent display filter); now we KEEP them and flag `overstay` // so the operator reconciles via a top-up. The signed log is untouched, and the // barrier never opens for free on these. See booth-exit-flow.md. if (!open && !withinGrace) continue; const overstay = open && paid && !isSubscription && graceExpiresAt != null && !withinGrace; // Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new // period's top-up). A subscription is prepaid — never quote/charge it. let amountMinor: number | null = null; let currency: string | null = null; if (open && !isSubscription && (a.paidAt == null || overstay)) { 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, overstay, subscription: isSubscription, subscriptionId: a.subscriptionId ?? null, subscriptionHolder: this.#holderOf(a.subscriptionId ?? null), plate: plates.get(identity)?.plate ?? null, }); } // Newest entry first. out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt)); return out; } /** * The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right * now: the transient cost of the minutes parked OUTSIDE the plan's window over the WHOLE * stay `[entry, now]` (one computation — covers early entry AND late exit without * double-counting), minus whatever they've already paid against the occurrence. null * when the plan has no timeframes / nothing is owed. Single source of truth shared with * the exit gate so the booth quote and the gate agree. */ #subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null { if (!subscriptionId) return null; const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get(); if (!sub) return null; const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all(); const entryRow = rows.find((r) => r.type === "vehicle_entry"); if (!entryRow) return null; const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString()); if (!owed) return null; let paid = 0; for (const r of rows) { if (r.type !== "payment") continue; const pl = (r.payload ?? {}) as { amountMinor?: number }; if (typeof pl.amountMinor === "number") paid += pl.amountMinor; } return { dueMinor: owed.amountMinor - paid, currency: owed.currency }; } /** Is this identity an OPEN subscription occurrence that owes a window charge? Returns * the due amount + currency + the tariff version that priced the late-exit charge (for * the payment payload), or null when it's transient / nothing owed. */ #payableSubscriptionWindow( identity: string, ): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null { const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all(); const entry = rows.find((r) => r.type === "vehicle_entry"); if (!entry) return null; const ep = (entry.payload ?? {}) as { permit?: boolean; permitId?: string }; if (ep.permit !== true && ep.permitId == null) return null; // transient if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null); if (!due || due.dueMinor <= 0) return null; // Tariff version for the payment payload = the one that priced the stay (resolved at // entry inside windowOwedBetween). const owed = windowOwedBetween(this.#db, this.#planVersionOf(ep.permitId ?? null), entry.occurredAt, new Date().toISOString()); return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: owed?.tariffVersionId ?? null }; } /** The planVersionId of a subscription (for resolving its timeframes), or null. */ #planVersionOf(subscriptionId: string | null): string | null { if (!subscriptionId) return null; const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get(); return row?.planVersionId ?? null; } /** The subscriber's holder name for a subscription id (for a friendly UI label), * or null. Best-effort: a deleted subscription just yields null. */ #holderOf(subscriptionId: string | null): string | null { if (!subscriptionId) return null; try { const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get(); return row?.holderName ?? null; } catch { return null; } } /** 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; } }