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.
This commit is contained in:
2026-06-17 12:17:28 +02:00
parent 727c62da90
commit 71aaad03b9
+72 -3
View File
@@ -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 { registry, type AccessControlDevice } from "@parking/devices";
import type { ResolvedRelay } from "./device-resolve.js"; import type { ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.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 { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -31,6 +31,11 @@ interface SessionView {
readonly open: boolean; // no vehicle_exit yet readonly open: boolean; // no vehicle_exit yet
readonly paidAt: string | null; // latest payment time, if any readonly paidAt: string | null; // latest payment time, if any
readonly graceExitMin: number | null; // from the payment's tariff context, if known 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 { export class ExitFlow {
@@ -76,6 +81,28 @@ export class ExitFlow {
return { accepted: false, direction: "exit", reason }; 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? // PAID + within walk-back grace?
const paid = view.paidAt != null; const paid = view.paidAt != null;
const withinGrace = const withinGrace =
@@ -96,7 +123,14 @@ export class ExitFlow {
return { accepted: false, direction: "exit", reason }; 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<ReadOutcome> {
await this.#log.append({ await this.#log.append({
type: "vehicle_exit", type: "vehicle_exit",
direction: "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 { return {
identity, identity,
enteredAt: entry.occurredAt, enteredAt: entry.occurredAt,
open: !exited, open: !exited,
paidAt, paidAt,
graceExitMin, 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. */ /** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null { #buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);