feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots

Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth
reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and
signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket
id barcode; site_config.exit_voucher_default (migration 0002) drives the default.
Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher.

Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'),
SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
This commit is contained in:
2026-06-18 11:05:10 +02:00
parent 9956488fd5
commit 06dab1e790
14 changed files with 1891 additions and 24 deletions
+178
View File
@@ -35,6 +35,45 @@ export interface Quote {
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;
@@ -109,6 +148,145 @@ export class PayStation {
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);
// ACTIVE = still inside, OR exited but still within the (unconfirmed) grace window.
// An exited session past grace is presumed truly gone → omitted.
if (!open && !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