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