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
+81
View File
@@ -0,0 +1,81 @@
import { eq, siteConfig, type Db } from "@parking/db";
import {
printWithFailover,
registry,
type PrinterDevice,
type PrinterInstance,
type TicketData,
type TicketHeader,
} from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js";
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
// from the exit, the customer pays at the booth and walks a printed voucher to the
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
//
// This mirrors the entry flow's printer selection + header build, but prints on the
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
/** Park identity for the voucher header, from site_config (all fields optional). */
function ticketHeader(db: Db): TicketHeader | undefined {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
/** Build live printer instances for failover selection (entry direction covers the
* booth-receipt role too — the booth printer is configured on the entry side). */
function loadPrinters(db: Db): PrinterInstance[] {
const rows = devicesByDirection(db, "printer", "entry");
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/**
* Print an exit voucher for a paid session: the same ticket id reprinted as a
* barcode, on the booth printer (failing over to the entry dispenser). Returns the
* id of the printer that printed it. Throws NoPrinterAvailableError if none can.
*/
export async function printExitVoucher(
db: Db,
ticketId: string,
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const ticket: TicketData = {
ticketId,
issuedAt: new Date().toISOString(),
header: ticketHeader(db),
};
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printTicket(ticket),
);
logger.info(`exit voucher for ${ticketId} printed on ${printedBy}`);
return printedBy;
}
+216 -13
View File
@@ -1,6 +1,6 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, 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 { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js"; import { snapshotAsync } from "./snapshot.js";
import { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared"; import { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
@@ -38,6 +38,21 @@ interface SessionView {
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null; readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
} }
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
* the barrier didn't open (payment stands; operator opens manually). */
export type BoothExitResult =
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
| { ok: true; opened: true }
| { ok: true; opened: false; reason: string };
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
* intervention was recorded (signed anomaly) but the relay did not fire. */
export type BoothReopenResult =
| { ok: false; reason: string }
| { ok: true; opened: boolean; reason?: string };
export class ExitFlow { export class ExitFlow {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
@@ -50,6 +65,170 @@ export class ExitFlow {
this.#logger = logger; this.#logger = logger;
} }
/**
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
* relay site-wide, pulses it, and fires the exit snapshot.
*
* Returns a discriminated result so the route can react precisely:
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
* — nothing is signed beyond the existing anomaly; the operator takes payment.
* - { ok: true, opened: true } on a clean exit.
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
* operator opens manually. Payment is never rolled back.
*/
async exitForBooth(identity: string): Promise<BoothExitResult> {
const id = identity.trim();
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
const key = `booth:${id}`;
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
this.#inFlight.add(key);
try {
const view = this.#sessionFor(id);
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
// path) so a booth attempt on a bad ticket is auditable.
if (!view || !view.open) {
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for ticket";
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
return { ok: false, status: view ? "closed" : "no_session", reason };
}
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
const freeGrace = view.paidAt == null && view.freeGrace != null;
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!freeGrace && (!paid || !withinGrace)) {
const reason = !paid
? "exit refused — not paid (take payment first)"
: "exit refused — walk-back grace expired (top-up required)";
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason };
}
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
// reader path does.
if (freeGrace && view.freeGrace) {
await this.#log.append({
type: "payment",
identity: id,
payload: {
sessionRef: id,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
reason: "free entry-grace (no charge)",
},
});
}
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
// to let the car out has been made and validated. Then attempt the open.
await this.#signExit(id);
if (!resolved) {
await this.#openFailedAnomaly(id, "no exit relay configured");
return { ok: true, opened: false, reason: "exit recorded, but no exit barrier is configured — open manually" };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
await this.#openFailedAnomaly(id, "exit controller would not build");
return { ok: true, opened: false, reason: "exit recorded, but the barrier is unavailable — open manually" };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
return { ok: true, opened: false, reason: "exit recorded, but the barrier did not open — open manually" };
}
this.#fireExitSnapshot(id);
this.#closeSessionCache(id);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/**
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
* opens the barrier with a signed trace.
*
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
* the UI also hides the button). Unlike exitForBooth this does NOT sign a
* `vehicle_exit` (the session may already be exited; a second exit would
* double-count occupancy). It re-pulses the exit relay and signs an `anomaly`
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
* See wiki/concepts/booth-exit-flow.md.
*/
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
const id = identity.trim();
if (!id) return { ok: false, reason: "ticket id required" };
const view = this.#sessionFor(id);
if (!view) return { ok: false, reason: "no session for ticket" };
// No payment → no re-open. The barrier-open action is only for sessions that
// have been paid (or paid-then-exited within grace). An unpaid car takes the
// pay/exit flow instead — enforced here, not just in the UI.
if (view.paidAt == null) {
return { ok: false, reason: "session not paid — no barrier open without payment" };
}
const key = `reopen:${id}`;
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
this.#inFlight.add(key);
try {
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
// the physical open succeeds) — never a second vehicle_exit.
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
reason: "manual barrier open (human intervention)",
source: "booth",
barrierReopen: true,
...(operator ? { operator } : {}),
},
});
if (!resolved) {
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
return { ok: true, opened: false, reason: "no exit barrier configured — open manually" };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
return { ok: true, opened: false, reason: "barrier unavailable — open manually" };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
return { ok: true, opened: false, reason: "barrier did not open — open manually" };
}
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the /** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
* read dispatcher from the reader's binding, which has ruled out a permit match). */ * read dispatcher from the reader's binding, which has ruled out a permit match). */
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> { async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
@@ -131,36 +310,60 @@ export class ExitFlow {
* Shared by the paid-exit and free-entry-grace paths. The caller has already * 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). */ * established the session is allowed out (and, for grace, minted the $0 payment). */
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> { async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
await this.#log.append({ await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
type: "vehicle_exit",
direction: "exit",
source: e.kind === "plate" ? "lpr" : "ticket",
identity: e.value,
payload: { sessionRef: e.value },
});
const access = this.#buildAccess(resolved.controller); const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay); if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`); else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
// SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate). this.#fireExitSnapshot(e.value);
this.#closeSessionCache(e.value);
return { accepted: true, direction: "exit" };
}
/** Append the signed vehicle_exit. `source` defaults to "ticket" (booth/manual). */
async #signExit(identity: string, source: "ticket" | "lpr" = "ticket"): Promise<void> {
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source,
identity,
payload: { sessionRef: identity },
});
}
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
#fireExitSnapshot(identity: string): void {
void snapshotAsync({ void snapshotAsync({
db: this.#db, db: this.#db,
direction: "exit", direction: "exit",
identity: e.value, identity,
logger: this.#logger, logger: this.#logger,
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`)); }).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
}
/** Update the (rebuildable) session projection cache to closed. */
#closeSessionCache(identity: string): void {
try { try {
this.#db this.#db
.update(sessions) .update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "closed" }) .set({ exitedAt: new Date().toISOString(), state: "closed" })
.where(eq(sessions.id, e.value)) .where(eq(sessions.id, identity))
.run(); .run();
} catch (err) { } catch (err) {
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`); this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
} }
return { accepted: true, direction: "exit" }; }
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
* The payment + exit STAND; this tells the operator to open manually. */
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity,
payload: { reason: "exit signed but barrier open failed", detail, source: "booth", exitOpenFailed: true },
});
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
} }
/** Fold the signed ledger into a session view for one identity (authoritative). */ /** Fold the signed ledger into a session view for one identity (authoritative). */
+178
View File
@@ -35,6 +35,45 @@ export interface Quote {
readonly graceExitMin: number; 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 { export class PayStation {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
@@ -109,6 +148,145 @@ export class PayStation {
return { amountMinor, currency: q.currency }; 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. */ /** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
#openEntry(identity: string) { #openEntry(identity: string) {
const rows = this.#db const rows = this.#db
+106 -6
View File
@@ -1,15 +1,21 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import { requireRole } from "../auth.js"; import { requireRole } from "../auth.js";
import { import {
NoOpenSessionError, NoOpenSessionError,
NoTariffError, NoTariffError,
type PayStation, type PayStation,
} from "../pay-station.js"; } from "../pay-station.js";
import type { ExitFlow } from "../exit-flow.js";
import { printExitVoucher } from "../booth-print.js";
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session // Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
// then takes payment; the payment becomes a signed ledger event. PCI scope stays // when the booth is at/near the exit — open the barrier. The payment becomes a
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender` // signed ledger event; PCI scope stays OUT of the app (card capture is a standalone
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md. // P2PE terminal; `tender` just records cash vs. card). The booth exit reuses the
// SAME validation as the reader path — no booth-only bypass admits an unpaid car.
// See wiki/concepts/tariff.md, parking-session.md, booth-exit-flow.md, bom.md.
interface QuoteQuery { interface QuoteQuery {
identity: string; identity: string;
@@ -20,11 +26,76 @@ interface PayBody {
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */ /** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
overrideMinor?: number; overrideMinor?: number;
} }
interface ExitBody {
identity: string;
}
interface VoucherBody {
identity: string;
}
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> { export async function payRoutes(
// Cashier/operator/admin operate the pay station; readonly may not. app: FastifyInstance,
db: Db,
payStation: PayStation,
exitFlow: ExitFlow,
): Promise<void> {
// Cashier/operator/admin operate the booth; readonly may not.
const guard = requireRole("admin", "operator", "cashier"); const guard = requireRole("admin", "operator", "cashier");
// Active sessions for the booth list: still-open OR exited-but-within-grace
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
app.get("/api/sessions/active", { preHandler: guard }, async () => ({
sessions: payStation.activeSessions(),
}));
// Session lookup for the booth pay/exit modal: entry/exit times, paid state,
// amount owed now, walk-back-grace status. Read-only (no side effect).
app.get<{ Params: { identity: string } }>(
"/api/session/:identity",
{ preHandler: guard },
async (req, reply) => {
const identity = (req.params.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
return payStation.lookup(identity);
},
);
// Booth-driven exit: validate (paid + grace, or free entry-grace) THEN sign
// vehicle_exit + open the barrier. Maps the discriminated result to HTTP:
// - validation reject → 409 with a reason (operator takes payment first),
// - exit signed but barrier didn't open → 200 { opened:false } (payment stands;
// operator opens manually; an anomaly is already signed),
// - clean exit → 200 { opened:true }.
app.post<{ Body: ExitBody }>(
"/api/exit",
{ preHandler: guard },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const res = await exitFlow.exitForBooth(identity);
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
return reply.code(200).send(res);
},
);
// Human-intervention barrier re-open for an ACTIVE (paid) session — damaged
// ticket / dead scanner / phantom re-close. Re-pulses the exit relay + signs an
// anomaly (attributed); NEVER a second vehicle_exit. Refused without a payment
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
app.post<{ Body: ExitBody }>(
"/api/barrier/reopen",
{ preHandler: guard },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const operator = req.user?.username;
const res = await exitFlow.reopenBarrier(identity, operator);
if (!res.ok) return reply.code(409).send({ error: res.reason });
return reply.code(200).send(res);
},
);
// Quote: what does this session owe right now? (No side effect.) // Quote: what does this session owe right now? (No side effect.)
app.get<{ Querystring: QuoteQuery }>( app.get<{ Querystring: QuoteQuery }>(
"/api/pay/quote", "/api/pay/quote",
@@ -60,6 +131,35 @@ export async function payRoutes(app: FastifyInstance, payStation: PayStation): P
} }
}, },
); );
// Print an exit voucher (the paid ticket id reprinted as a barcode) on the booth
// printer. Used when the booth is far from the exit — the customer self-scans the
// voucher at the exit reader, which runs the normal validated exit. Requires the
// session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md.
app.post<{ Body: VoucherBody }>(
"/api/voucher",
{ preHandler: guard },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const view = payStation.lookup(identity);
if (!view.found || !view.open) {
return reply.code(404).send({ error: "no open session for ticket" });
}
if (view.paidAt == null) {
return reply.code(409).send({ error: "session not paid — take payment before printing a voucher" });
}
try {
const printedBy = await printExitVoucher(db, identity, app.log);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) {
return reply.code(503).send({ error: err.message });
}
return reply.code(500).send({ error: (err as Error).message });
}
},
);
} }
function mapError(reply: import("fastify").FastifyReply, err: unknown) { function mapError(reply: import("fastify").FastifyReply, err: unknown) {
+17 -3
View File
@@ -21,13 +21,21 @@ type TextField = (typeof TEXT_FIELDS)[number];
interface SiteConfigBody extends Partial<Record<TextField, string | null>> { interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Nominal capacity; null = no limit. */ /** Nominal capacity; null = no limit. */
capacity?: number | null; capacity?: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault?: boolean;
} }
/** Shape returned by GET/PUT: capacity + every metadata field (null when unset). */ /** Shape returned by GET/PUT: capacity + the booth flag + every metadata field. */
type SiteConfig = { capacity: number | null } & Record<TextField, string | null>; type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean } & Record<
TextField,
string | null
>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = { capacity: row?.capacity ?? null } as SiteConfig; const out = {
capacity: row?.capacity ?? null,
exitVoucherDefault: row?.exitVoucherDefault ?? false,
} as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out; return out;
} }
@@ -65,6 +73,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
patch.capacity = c ?? null; patch.capacity = c ?? null;
} }
if ("exitVoucherDefault" in body) {
if (typeof body.exitVoucherDefault !== "boolean") {
return reply.code(400).send({ error: "exitVoucherDefault must be a boolean" });
}
patch.exitVoucherDefault = body.exitVoucherDefault;
}
for (const f of TEXT_FIELDS) { for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]); if (f in body) patch[f] = normText(body[f]);
} }
+234
View File
@@ -0,0 +1,234 @@
import { useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
boothExit,
fetchSiteConfig,
lookupSession,
paySession,
printVoucher,
type SessionLookup,
} from "./api.js";
import { qk } from "./lib/query.js";
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
// payment, then EITHER prints an exit voucher (customer self-exits at a distant
// exit) OR fires the exit immediately (booth at/near the exit) — controlled by a
// checkbox defaulting from site_config.exitVoucherDefault. See booth-exit-flow.md.
type Phase = "review" | "paying" | "finishing" | "done" | "error";
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
const qc = useQueryClient();
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
const [tender, setTender] = useState<"cash" | "card">("cash");
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
const [phase, setPhase] = useState<Phase>("review");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle.
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
const alreadyPaid = s?.paidAt != null;
const canPay = s?.found && s.open && !alreadyPaid;
async function handlePayAndExit() {
if (!s) return;
setError(null);
try {
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
if (!alreadyPaid) {
setPhase("paying");
await paySession(identity, tender);
}
// 2. Voucher OR immediate exit.
setPhase("finishing");
if (voucher) {
const r = await printVoucher(identity);
setResult(`Exit voucher printed on ${r.printedBy}. Customer self-exits at the exit.`);
} else {
const r = await boothExit(identity);
setResult(
r.opened
? "Paid — barrier opened. Car may exit."
: `Paid and exit recorded, but the barrier did not open: ${r.reason ?? "open manually"}.`,
);
}
// Refresh the live views.
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-50 w-[560px] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl"
aria-describedby={undefined}
>
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
Ticket {identity}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
✕
</Dialog.Close>
</div>
<div className="flex flex-col gap-3 p-4">
{session.isLoading && <div className="text-term-muted">looking up…</div>}
{s && !s.found && (
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
No session found for this ticket.
</div>
)}
{s && s.found && !s.open && (
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
This session is already closed (exited {formatTime(s.exitedAt)}).
</div>
)}
{s && s.found && s.open && (
<>
{/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label="Entry" value={formatTime(s.enteredAt)} />
<Row label="Now" value={formatTime(new Date().toISOString())} />
<Row label="Duration" value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"} />
<Row
label="Status"
value={alreadyPaid ? "PAID" : "UNPAID"}
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
/>
</div>
{/* Total */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">Total</span>
<span className="text-3xl font-bold text-term-cyan">
{s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? "paid"
: "no tariff"}
</span>
</div>
{/* Snapshots */}
<SnapshotStrip identity={identity} />
{phase !== "done" && (
<>
{/* Tender */}
{canPay && (
<div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">Tender</span>
{(["cash", "card"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTender(t)}
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
tender === t
? "border-term-amber text-term-amber"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
{t}
</button>
))}
</div>
)}
{/* Voucher checkbox (default from site config) */}
<label className="flex items-center gap-2 text-[12px]">
<input
type="checkbox"
checked={voucher}
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
/>
Printo biletë dalje
<span className="text-term-muted">(customer self-exits at the exit)</span>
</label>
</>
)}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
{result && (
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
{phase === "done" ? (
<button
type="button"
onClick={onClose}
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
>
Close
</button>
) : (
<>
<button
type="button"
onClick={onClose}
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
>
Cancel
</button>
<button
type="button"
onClick={handlePayAndExit}
disabled={phase === "paying" || phase === "finishing"}
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
>
{phase === "paying"
? "taking payment…"
: phase === "finishing"
? voucher
? "printing voucher…"
: "opening…"
: alreadyPaid
? voucher
? "Print voucher"
: "Open barrier"
: voucher
? "Pay + print voucher"
: "Pay + open barrier"}
</button>
</>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
return (
<div className="flex items-baseline justify-between">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
<span className={`text-sm ${valueClass}`}>{value}</span>
</div>
);
}
+17 -1
View File
@@ -21,6 +21,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [occ, setOcc] = useState<Occupancy | null>(null); const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState(""); const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({}); const [meta, setMeta] = useState<Record<string, string>>({});
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
function reload() { function reload() {
@@ -31,6 +32,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
fetchSiteConfig() fetchSiteConfig()
.then((c) => { .then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity)); setCapInput(c.capacity == null ? "" : String(c.capacity));
setExitVoucherDefault(c.exitVoucherDefault);
const m: Record<string, string> = {}; const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]); for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m); setMeta(m);
@@ -41,7 +43,10 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
async function save() { async function save() {
setMsg(null); setMsg(null);
const raw = capInput.trim(); const raw = capInput.trim();
const patch: Partial<SiteConfig> = { capacity: raw === "" ? null : Math.round(Number(raw)) }; const patch: Partial<SiteConfig> = {
capacity: raw === "" ? null : Math.round(Number(raw)),
exitVoucherDefault,
};
// Send each metadata field; "" → null is applied server-side. // Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? ""; for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
try { try {
@@ -75,6 +80,17 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
Capacity (blank = no limit):{" "} Capacity (blank = no limit):{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" /> <input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input
type="checkbox"
checked={exitVoucherDefault}
onChange={(e) => setExitVoucherDefault(e.target.checked)}
/>
Print exit ticket by default
<span style={{ color: "#888", fontSize: "0.8rem" }}>
(booth far from exit → customer self-exits with a voucher)
</span>
</label>
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}> <div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
Park details (optional — shown on tickets/receipts) Park details (optional — shown on tickets/receipts)
</div> </div>
+130 -1
View File
@@ -311,6 +311,9 @@ export function deletePermit(id: string): Promise<void> {
export interface ShiftStatus { export interface ShiftStatus {
operator: string; operator: string;
open: { startedAt: string } | null; open: { startedAt: string } | null;
/** Live physical drawer balance (cash payments + cash movements). */
drawerMinor: number;
currency: string | null;
} }
export interface ShiftReport { export interface ShiftReport {
operator: string; operator: string;
@@ -320,19 +323,35 @@ export interface ShiftReport {
cardTotalMinor: number; cardTotalMinor: number;
currency: string | null; currency: string | null;
paymentCount: number; paymentCount: number;
// Drawer (carries across shifts).
openingFloatMinor: number;
cashAddedMinor: number;
cashRemovedMinor: number;
expectedDrawerMinor: number;
printed: boolean; printed: boolean;
} }
export function fetchShift(): Promise<ShiftStatus> { export function fetchShift(): Promise<ShiftStatus> {
return apiFetch("/api/shift/current"); return apiFetch("/api/shift/current");
} }
export function openShift(): Promise<{ startedAt: string }> { export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
return apiFetch("/api/shift/open", { method: "POST" }); return apiFetch("/api/shift/open", { method: "POST" });
} }
export function closeShift(): Promise<ShiftReport> { export function closeShift(): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST" }); return apiFetch("/api/shift/close", { method: "POST" });
} }
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
export function recordCashMovement(
amountMinor: number,
reason: string,
): Promise<{ amountMinor: number; balanceMinor: number }> {
return apiFetch("/api/cash-movement", {
method: "POST",
body: JSON.stringify({ amountMinor, reason }),
});
}
// --- Site config / occupancy ---------------------------------------------- // --- Site config / occupancy ----------------------------------------------
export interface Occupancy { export interface Occupancy {
@@ -345,6 +364,8 @@ export interface Occupancy {
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */ /** Capacity + optional park metadata (all nullable). Mirrors site_config. */
export interface SiteConfig { export interface SiteConfig {
capacity: number | null; capacity: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault: boolean;
parkName: string | null; parkName: string | null;
operatorName: string | null; operatorName: string | null;
/** NIUS — Albanian tax/identification number. */ /** NIUS — Albanian tax/identification number. */
@@ -357,6 +378,114 @@ export interface SiteConfig {
export function fetchOccupancy(): Promise<Occupancy> { export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy"); return apiFetch("/api/occupancy");
} }
// --- Ledger events (the signed audit trail; read-only) --------------------
/** A persisted ledger row. Re-exported from shared so UI code has one source of
* truth for the event shape (the same type the WS pushes). */
export type { LedgerEvent } from "@parking/shared";
/** Recent ledger events, newest first (default 100, max 1000). Used for the
* booth feed's initial load; live updates then arrive over the WS. */
export function fetchEvents(limit = 100): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
return apiFetch(`/api/events?limit=${limit}`);
}
// --- Booth: session lookup, payment, exit ---------------------------------
/** One-read session view for the booth pay/exit modal (mirrors server SessionLookup). */
export interface SessionLookup {
identity: string;
found: boolean;
open: boolean;
enteredAt: string | null;
exitedAt: string | null;
paidAt: string | null;
amountMinor: number | null;
currency: string | null;
withinGrace: boolean;
graceExpiresAt: string | null;
}
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
export function lookupSession(identity: string): Promise<SessionLookup> {
return apiFetch(`/api/session/${encodeURIComponent(identity)}`);
}
/** One row in the booth Active Sessions list (mirrors server ActiveSession). */
export interface ActiveSession {
identity: string;
source: string | null;
enteredAt: string;
exitedAt: string | null;
open: boolean;
paidAt: string | null;
amountMinor: number | null;
currency: string | null;
withinGrace: boolean;
graceExpiresAt: string | null;
}
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
export function fetchActiveSessions(): Promise<{ sessions: ActiveSession[] }> {
return apiFetch("/api/sessions/active");
}
/** Human-intervention barrier re-open for a paid active session (damaged ticket /
* phantom re-close). Signs an audited anomaly; never a 2nd exit. */
export function reopenBarrier(identity: string): Promise<{ ok: true; opened: boolean; reason?: string }> {
return apiFetch("/api/barrier/reopen", { method: "POST", body: JSON.stringify({ identity }) });
}
/** Take payment for a session → signed payment event. `overrideMinor` sets an
* operator amount (lost ticket / dispute). */
export function paySession(
identity: string,
tender: "cash" | "card",
overrideMinor?: number,
): Promise<{ amountMinor: number; currency: string }> {
return apiFetch("/api/pay", {
method: "POST",
body: JSON.stringify({ identity, tender, ...(overrideMinor != null ? { overrideMinor } : {}) }),
});
}
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
* open (payment stands; operator opens manually). */
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
/** Validate + open the barrier for a session from the booth (when near the exit). */
export function boothExit(identity: string): Promise<BoothExitResult> {
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
}
/** Print an exit voucher (paid ticket id reprinted) for self-exit at a distant
* exit. Requires the session to be paid. */
export function printVoucher(identity: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch("/api/voucher", { method: "POST", body: JSON.stringify({ identity }) });
}
// --- Snapshots (entry/exit evidence images) -------------------------------
export interface SnapshotMeta {
id: string;
direction: "entry" | "exit" | null;
deviceId: string;
identity: string;
contentType: string;
capturedAt: string;
}
/** Snapshot metadata for a session identity (newest first). Image bytes are at
* `/api/snapshots/:id` — use that URL directly as an <img src>. */
export function fetchSnapshots(identity: string): Promise<{ snapshots: SnapshotMeta[] }> {
return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`);
}
/** URL for a snapshot's image bytes (cookie-authed; usable as <img src>). */
export function snapshotImageUrl(id: string): string {
return `/api/snapshots/${encodeURIComponent(id)}`;
}
export function fetchSiteConfig(): Promise<SiteConfig> { export function fetchSiteConfig(): Promise<SiteConfig> {
return apiFetch("/api/site-config"); return apiFetch("/api/site-config");
} }
+30
View File
@@ -0,0 +1,30 @@
// Small formatting helpers for the booth. Money is integer MINOR units (never a
// float — matches the tariff/ledger model); duration is whole minutes.
/** Format integer minor units + ISO-4217 currency as a major-unit string. */
export function formatMoney(amountMinor: number, currency: string): string {
const major = amountMinor / 100;
try {
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(major);
} catch {
// Unknown/garbled currency code — fall back to a plain number + the code.
return `${major.toFixed(2)} ${currency}`;
}
}
/** Human duration between two ISO times, e.g. "2h 14m" / "47m" / "0m". */
export function formatDuration(fromIso: string, toIso: string): string {
const ms = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(ms) || ms < 0) return "—";
const mins = Math.floor(ms / 60_000);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
/** Local time-of-day HH:MM:SS from an ISO string. */
export function formatTime(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
// Entry/exit evidence images for a session. Lets the operator verify the car at the
// booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed,
// served with a long immutable cache); clicking one enlarges it. Read-only.
export function SnapshotStrip({ identity }: { identity: string }) {
const { data, isLoading } = useQuery({
queryKey: ["snapshots", identity],
queryFn: () => fetchSnapshots(identity),
enabled: !!identity,
});
const [zoom, setZoom] = useState<string | null>(null);
const shots = data?.snapshots ?? [];
if (isLoading) return <div className="text-[11px] text-term-muted">loading snapshots…</div>;
if (shots.length === 0) return <div className="text-[11px] text-term-muted">no snapshots</div>;
return (
<>
<div className="flex gap-2">
{shots.map((s) => (
<button
key={s.id}
type="button"
onClick={() => setZoom(s.id)}
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
title={`${s.direction ?? "snapshot"} · ${new Date(s.capturedAt).toLocaleString()}`}
>
<img
src={snapshotImageUrl(s.id)}
alt={s.direction ?? "snapshot"}
className="h-20 w-28 object-cover"
loading="lazy"
/>
<span
className={`text-[9px] uppercase tracking-wider ${
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
}`}
>
{s.direction ?? "—"}
</span>
</button>
))}
</div>
{zoom && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
onClick={() => setZoom(null)}
>
<img src={snapshotImageUrl(zoom)} alt="snapshot" className="max-h-full max-w-full object-contain" />
</div>
)}
</>
);
}
@@ -0,0 +1 @@
ALTER TABLE `site_config` ADD `exit_voucher_default` integer DEFAULT false NOT NULL;
+805
View File
@@ -0,0 +1,805 @@
{
"version": "6",
"dialect": "sqlite",
"id": "dbee8e05-0b49-4af7-962c-9aab53b36eb7",
"prevId": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
"tables": {
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"park_name": {
"name": "park_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"operator_name": {
"name": "operator_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nius": {
"name": "nius",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exit_voucher_default": {
"name": "exit_voucher_default",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1781682176094, "when": 1781682176094,
"tag": "0001_neat_slipstream", "tag": "0001_neat_slipstream",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1781713560438,
"tag": "0002_panoramic_tiger_shark",
"breakpoints": true
} }
] ]
} }
+9
View File
@@ -151,6 +151,15 @@ export const siteConfig = sqliteTable("site_config", {
phone: text("phone"), phone: text("phone"),
/** Contact email. */ /** Contact email. */
email: text("email"), email: text("email"),
/** Default for the booth pay modal's "print exit ticket" checkbox. Site-wide
* because it's booth GEOGRAPHY: when the booth is far from the exit, the
* customer pays at the booth and self-exits later by scanning a printed exit
* voucher (= the ticket id reprinted, now paid). When near the exit, the booth
* opens the barrier directly. The operator may still override per transaction.
* Stored 0/1 (SQLite has no bool). See wiki/concepts/booth-exit-flow.md. */
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
.notNull()
.default(false),
updatedAt: text("updated_at") updatedAt: text("updated_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),