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:
@@ -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<ReadOutcome> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user