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