From 71aaad03b9fe9ab254d60701daaa545ffb58a018 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Wed, 17 Jun 2026 12:17:28 +0200 Subject: [PATCH] exit: open free within entry-grace, no pay-station visit A quick in-and-out the tariff prices at 0 (stay <= gracePeriodEntryMin) now exits at the gate instead of being refused as "not paid". exit-flow resolves the active site tariff (same logic as the pay station) and, if computeFee for entry->now is 0, mints a signed $0 payment event (reason: free entry-grace) then signs the vehicle_exit and opens. The $0 payment keeps the append-only ledger invariant that an exit is covered by a payment, so a grace exit stays attributable in the audit trail. A real payment still takes precedence (the walk-back grace path is untouched). Sign+open extracted to #signExitAndOpen, shared by both paths. --- apps/server/src/exit-flow.ts | 75 ++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index e3ef640..71a51c5 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -1,8 +1,8 @@ -import { eq, ledgerEvents, sessions, type Db, type DeviceRow } from "@parking/db"; +import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db"; import { registry, type AccessControlDevice } from "@parking/devices"; import type { ResolvedRelay } from "./device-resolve.js"; import { snapshotAsync } from "./snapshot.js"; -import type { LedgerPayload } from "@parking/shared"; +import { computeFee, 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"; @@ -31,6 +31,11 @@ interface SessionView { readonly open: boolean; // no vehicle_exit yet readonly paidAt: string | null; // latest payment time, if any 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; } export class ExitFlow { @@ -76,6 +81,28 @@ export class ExitFlow { return { accepted: false, direction: "exit", 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, + reason: "free entry-grace (no charge)", + }, + }); + 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 = @@ -96,7 +123,14 @@ export class ExitFlow { return { accepted: false, direction: "exit", reason }; } - // Valid: sign the exit BEFORE opening, then open, then update the cache. + // 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 { await this.#log.append({ type: "vehicle_exit", direction: "exit", @@ -153,15 +187,50 @@ export class ExitFlow { } } + // 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; + const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure); + if (fee === 0) { + freeGrace = { + tariffVersionId: tv.id, + currency: tv.currency, + graceExitMin: structure.gracePeriodExitMin, + }; + } + } + } + return { identity, enteredAt: entry.occurredAt, open: !exited, paidAt, 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);