diff --git a/apps/server/src/booth-print.ts b/apps/server/src/booth-print.ts new file mode 100644 index 0000000..3df806c --- /dev/null +++ b/apps/server/src/booth-print.ts @@ -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; + 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 { + 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; +} diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 71a51c5..bfc5115 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -1,6 +1,6 @@ import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db"; 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 { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; @@ -38,6 +38,21 @@ interface SessionView { 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 { readonly #db: Db; readonly #log: EventLog; @@ -50,6 +65,170 @@ export class ExitFlow { 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 { + 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 { + 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 * read dispatcher from the reader's binding, which has ruled out a permit match). */ async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise { @@ -131,36 +310,60 @@ export class ExitFlow { * Shared by the paid-exit and free-entry-grace paths. The caller has already * established the session is allowed out (and, for grace, minted the $0 payment). */ async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise { - await this.#log.append({ - type: "vehicle_exit", - direction: "exit", - source: e.kind === "plate" ? "lpr" : "ticket", - identity: e.value, - payload: { sessionRef: e.value }, - }); + await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket"); const access = this.#buildAccess(resolved.controller); if (access) await access.pulseOpen(resolved.relay); 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 { + 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({ db: this.#db, direction: "exit", - identity: e.value, + identity, logger: this.#logger, }).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 { this.#db .update(sessions) .set({ exitedAt: new Date().toISOString(), state: "closed" }) - .where(eq(sessions.id, e.value)) + .where(eq(sessions.id, identity)) .run(); } 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 { + 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). */ diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index 7f1f200..cfae9da 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -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(); + 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 diff --git a/apps/server/src/routes/pay.ts b/apps/server/src/routes/pay.ts index 37f25d4..364963d 100644 --- a/apps/server/src/routes/pay.ts +++ b/apps/server/src/routes/pay.ts @@ -1,15 +1,21 @@ import type { FastifyInstance } from "fastify"; +import type { Db } from "@parking/db"; +import { NoPrinterAvailableError } from "@parking/devices"; import { requireRole } from "../auth.js"; import { NoOpenSessionError, NoTariffError, type PayStation, } 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 -// then takes payment; the payment becomes a signed ledger event. PCI scope stays -// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender` -// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md. +// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and — +// when the booth is at/near the exit — open the barrier. The payment becomes a +// signed ledger event; PCI scope stays OUT of the app (card capture is a standalone +// 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 { identity: string; @@ -20,11 +26,76 @@ interface PayBody { /** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */ overrideMinor?: number; } +interface ExitBody { + identity: string; +} +interface VoucherBody { + identity: string; +} -export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise { - // Cashier/operator/admin operate the pay station; readonly may not. +export async function payRoutes( + app: FastifyInstance, + db: Db, + payStation: PayStation, + exitFlow: ExitFlow, +): Promise { + // Cashier/operator/admin operate the booth; readonly may not. 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.) app.get<{ Querystring: QuoteQuery }>( "/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) { diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts index d3bb8ad..f5295ed 100644 --- a/apps/server/src/routes/site.ts +++ b/apps/server/src/routes/site.ts @@ -21,13 +21,21 @@ type TextField = (typeof TEXT_FIELDS)[number]; interface SiteConfigBody extends Partial> { /** Nominal capacity; null = no limit. */ 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). */ -type SiteConfig = { capacity: number | null } & Record; +/** Shape returned by GET/PUT: capacity + the booth flag + every metadata field. */ +type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean } & Record< + TextField, + string | null +>; 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; return out; } @@ -65,6 +73,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise { } 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) { if (f in body) patch[f] = normText(body[f]); } diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx new file mode 100644 index 0000000..6bff432 --- /dev/null +++ b/apps/web/src/BoothPayModal.tsx @@ -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(null); + const [phase, setPhase] = useState("review"); + const [error, setError] = useState(null); + const [result, setResult] = useState(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 ( + !o && onClose()}> + + + +
+ + Ticket {identity} + + + ✕ + +
+ +
+ {session.isLoading &&
looking up…
} + + {s && !s.found && ( +
+ No session found for this ticket. +
+ )} + + {s && s.found && !s.open && ( +
+ This session is already closed (exited {formatTime(s.exitedAt)}). +
+ )} + + {s && s.found && s.open && ( + <> + {/* Session figures */} +
+ + + + +
+ + {/* Total */} +
+ Total + + {s.amountMinor != null && s.currency + ? formatMoney(s.amountMinor, s.currency) + : alreadyPaid + ? "paid" + : "no tariff"} + +
+ + {/* Snapshots */} + + + {phase !== "done" && ( + <> + {/* Tender */} + {canPay && ( +
+ Tender + {(["cash", "card"] as const).map((t) => ( + + ))} +
+ )} + + {/* Voucher checkbox (default from site config) */} + + + )} + + {error &&
{error}
} + {result && ( +
{result}
+ )} + + {/* Actions */} +
+ {phase === "done" ? ( + + ) : ( + <> + + + + )} +
+ + )} +
+
+
+
+ ); +} + +function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/web/src/SiteSettings.tsx b/apps/web/src/SiteSettings.tsx index 7490228..417274e 100644 --- a/apps/web/src/SiteSettings.tsx +++ b/apps/web/src/SiteSettings.tsx @@ -21,6 +21,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { const [occ, setOcc] = useState(null); const [capInput, setCapInput] = useState(""); const [meta, setMeta] = useState>({}); + const [exitVoucherDefault, setExitVoucherDefault] = useState(false); const [msg, setMsg] = useState(null); function reload() { @@ -31,6 +32,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { fetchSiteConfig() .then((c) => { setCapInput(c.capacity == null ? "" : String(c.capacity)); + setExitVoucherDefault(c.exitVoucherDefault); const m: Record = {}; for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]); setMeta(m); @@ -41,7 +43,10 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { async function save() { setMsg(null); const raw = capInput.trim(); - const patch: Partial = { capacity: raw === "" ? null : Math.round(Number(raw)) }; + const patch: Partial = { + capacity: raw === "" ? null : Math.round(Number(raw)), + exitVoucherDefault, + }; // Send each metadata field; "" → null is applied server-side. for (const { key } of META_FIELDS) (patch as Record)[key] = meta[key] ?? ""; try { @@ -75,6 +80,17 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { Capacity (blank = no limit):{" "} setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" /> +
Park details (optional — shown on tickets/receipts)
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 81569fa..6a71a22 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -311,6 +311,9 @@ export function deletePermit(id: string): Promise { export interface ShiftStatus { operator: string; open: { startedAt: string } | null; + /** Live physical drawer balance (cash payments + cash movements). */ + drawerMinor: number; + currency: string | null; } export interface ShiftReport { operator: string; @@ -320,19 +323,35 @@ export interface ShiftReport { cardTotalMinor: number; currency: string | null; paymentCount: number; + // Drawer (carries across shifts). + openingFloatMinor: number; + cashAddedMinor: number; + cashRemovedMinor: number; + expectedDrawerMinor: number; printed: boolean; } export function fetchShift(): Promise { 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" }); } export function closeShift(): Promise { 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 ---------------------------------------------- export interface Occupancy { @@ -345,6 +364,8 @@ export interface Occupancy { /** Capacity + optional park metadata (all nullable). Mirrors site_config. */ export interface SiteConfig { capacity: number | null; + /** Default for the booth "print exit ticket" checkbox (booth-geography knob). */ + exitVoucherDefault: boolean; parkName: string | null; operatorName: string | null; /** NIUS — Albanian tax/identification number. */ @@ -357,6 +378,114 @@ export interface SiteConfig { export function fetchOccupancy(): Promise { 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 { + 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 { + 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 . */ +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 ). */ +export function snapshotImageUrl(id: string): string { + return `/api/snapshots/${encodeURIComponent(id)}`; +} export function fetchSiteConfig(): Promise { return apiFetch("/api/site-config"); } diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts new file mode 100644 index 0000000..8fceab5 --- /dev/null +++ b/apps/web/src/lib/format.ts @@ -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); +} diff --git a/apps/web/src/ui/SnapshotStrip.tsx b/apps/web/src/ui/SnapshotStrip.tsx new file mode 100644 index 0000000..27790f1 --- /dev/null +++ b/apps/web/src/ui/SnapshotStrip.tsx @@ -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(null); + + const shots = data?.snapshots ?? []; + + if (isLoading) return
loading snapshots…
; + if (shots.length === 0) return
no snapshots
; + + return ( + <> +
+ {shots.map((s) => ( + + ))} +
+ + {zoom && ( +
setZoom(null)} + > + snapshot +
+ )} + + ); +} diff --git a/packages/db/drizzle/0002_panoramic_tiger_shark.sql b/packages/db/drizzle/0002_panoramic_tiger_shark.sql new file mode 100644 index 0000000..8f1e84a --- /dev/null +++ b/packages/db/drizzle/0002_panoramic_tiger_shark.sql @@ -0,0 +1 @@ +ALTER TABLE `site_config` ADD `exit_voucher_default` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0002_snapshot.json b/packages/db/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..f9cff8d --- /dev/null +++ b/packages/db/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index b0dca2c..abe9850 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1781682176094, "tag": "0001_neat_slipstream", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1781713560438, + "tag": "0002_panoramic_tiger_shark", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3248f4f..4a49c3d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -151,6 +151,15 @@ export const siteConfig = sqliteTable("site_config", { phone: text("phone"), /** Contact 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") .notNull() .default(sql`(current_timestamp)`),