ca8c7f2fa2
A paid car that left via a manual barrier re-open kept no vehicle_exit, so activeSessions() saw it as permanently open and the grace-expiry eviction (which only ran for exited sessions) never fired — it lingered forever (ticket T-397815c0). - reopenBarrier() now signs a vehicle_exit (source:manual) when the session is still open, closing it; still no second exit when already exited (phantom re-close — no double-count). - activeSessions() ages out a PAID open session past grace even with no exit (unpaid open sessions never age out — a car owing money stays). Pure display filter; the signed log is untouched. Verified both fixes + chain integrity on a fresh DB. A one-off corrective vehicle_exit was appended to the live ledger to clear T-397815c0. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db";
|
|
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
|
import type { FastifyBaseLogger } from "fastify";
|
|
import type { EventLog } from "./event-log.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;
|
|
readonly enteredAt: string;
|
|
readonly amountMinor: number;
|
|
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;
|
|
}
|
|
|
|
/** 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;
|
|
/** 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;
|
|
}
|
|
|
|
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 against the tariff in force at its entry. No side effect. */
|
|
quote(identity: string): Quote {
|
|
const entry = this.#openEntry(identity);
|
|
if (!entry) throw new NoOpenSessionError(identity);
|
|
|
|
const tv = this.#tariffVersionFor(entry.occurredAt);
|
|
if (!tv) throw new NoTariffError();
|
|
const structure = tv.structure as unknown as TariffStructure;
|
|
|
|
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure);
|
|
return {
|
|
identity,
|
|
enteredAt: entry.occurredAt,
|
|
amountMinor,
|
|
currency: tv.currency,
|
|
tariffVersionId: tv.id,
|
|
graceExitMin: structure.gracePeriodExitMin,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 top-up the same call re-prices entry→now and the exit flow's
|
|
* grace-window restarts from this payment. `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 }> {
|
|
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,
|
|
...(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, withinGrace: false, graceExpiresAt: null,
|
|
};
|
|
}
|
|
const exitRow = rows.find((r) => r.type === "vehicle_exit");
|
|
const open = !exitRow;
|
|
|
|
let paidAt: string | null = null;
|
|
let graceExitMin: number | null = null;
|
|
for (const r of rows) {
|
|
if (r.type === "payment") {
|
|
paidAt = r.occurredAt;
|
|
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
|
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
|
}
|
|
}
|
|
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). Only meaningful while open.
|
|
let amountMinor: number | null = null;
|
|
let currency: string | null = null;
|
|
if (open) {
|
|
try {
|
|
const q = this.quote(id);
|
|
amountMinor = q.amountMinor;
|
|
currency = q.currency;
|
|
} catch {
|
|
/* no active tariff — leave null; modal shows session without a price */
|
|
}
|
|
}
|
|
|
|
return {
|
|
identity: id, found: true, open,
|
|
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
|
paidAt, amountMinor, currency, withinGrace, graceExpiresAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
const byId = new Map<string, Acc>();
|
|
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;
|
|
byId.set(id, a);
|
|
} else if (r.type === "vehicle_exit") {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// 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 → AGE-OUT (omit). A paid car whose walk-back grace
|
|
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
|
|
// manual barrier re-open before that path closed the session, or a historical
|
|
// session like T-397815c0) it would otherwise linger forever. The signed log
|
|
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
|
|
if (!open && !withinGrace) continue;
|
|
if (open && paid && graceExpiresAt != null && !withinGrace) continue;
|
|
|
|
// Amount owed now: only meaningful for an open + unpaid session.
|
|
let amountMinor: number | null = null;
|
|
let currency: string | null = null;
|
|
if (open && a.paidAt == null) {
|
|
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,
|
|
});
|
|
}
|
|
|
|
// Newest entry first.
|
|
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
|
|
return out;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|