From eb3dc18e67698208b513da8d1562ab46cc10453a Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Thu, 18 Jun 2026 11:05:26 +0200 Subject: [PATCH] feat(booth): active sessions panel + audited barrier re-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Active Sessions panel lists sessions that are open OR exited-but-within-grace (barrier state is unconfirmed, so a paid car is presumed possibly-present until grace expires). Row click → pay/exit modal; 'Open barrier' (paid sessions only — no payment, no button) fires a human-intervention re-pulse signed as an attributed anomaly, never a second vehicle_exit. Wiki: booth-exit-flow.md. Note: the backend (PayStation.activeSessions, ExitFlow.reopenBarrier, routes, api.ts) landed with the prior commit's shared files. --- apps/web/src/ActiveSessions.tsx | 115 +++++++++++++++++++++++++++ wiki/concepts/booth-exit-flow.md | 131 +++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 apps/web/src/ActiveSessions.tsx create mode 100644 wiki/concepts/booth-exit-flow.md diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx new file mode 100644 index 0000000..6b9e44f --- /dev/null +++ b/apps/web/src/ActiveSessions.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js"; +import { qk } from "./lib/query.js"; +import { formatDuration, formatTime } from "./lib/format.js"; +import { Panel } from "./ui/Panel.js"; + +// Active Sessions panel. A session is "active" while still inside OR exited-but- +// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed +// possibly-present until grace runs out). Lets the operator find a stuck car — +// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan: +// - click a row → the pay/exit modal (pay an unpaid car, or review), +// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse. +// No payment → no Open barrier button (the no-unpaid-bypass rule). +// See wiki/concepts/booth-exit-flow.md. + +function statusBadge(s: ActiveSession): { label: string; cls: string } { + if (!s.open && s.withinGrace) return { label: "exiting", cls: "text-term-cyan" }; + if (s.paidAt) return { label: "paid", cls: "text-term-green" }; + return { label: "unpaid", cls: "text-term-amber" }; +} + +export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) { + const qc = useQueryClient(); + const { data, isLoading } = useQuery({ + queryKey: qk.activeSessions, + queryFn: fetchActiveSessions, + // Belt-and-braces refresh in case a grace window expires with no ledger event + // to invalidate the cache (the WS only pushes on appends). + refetchInterval: 15_000, + }); + + const reopen = useMutation({ + mutationFn: (identity: string) => reopenBarrier(identity), + onSettled: () => { + void qc.invalidateQueries({ queryKey: qk.activeSessions }); + void qc.invalidateQueries({ queryKey: qk.events }); + }, + }); + const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null); + + const sessions = data?.sessions ?? []; + + async function handleReopen(s: ActiveSession) { + setReopenMsg(null); + try { + const r = await reopen.mutateAsync(s.identity); + setReopenMsg({ + id: s.identity, + ok: r.opened, + text: r.opened ? "barrier opened" : r.reason ?? "open manually", + }); + } catch (e) { + setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message }); + } + } + + return ( + {sessions.length} inside} + className="min-h-0" + > +
+ {sessions.length === 0 ? ( +
{isLoading ? "loading…" : "no active sessions."}
+ ) : ( + sessions.map((s) => { + const badge = statusBadge(s); + const msg = reopenMsg?.id === s.identity ? reopenMsg : null; + return ( +
+ + + {/* Open barrier — PAID sessions only (no payment, no button). */} + {s.paidAt ? ( + + ) : ( + + )} + + {msg && ( + + {msg.text} + + )} +
+ ); + }) + )} +
+
+ ); +} diff --git a/wiki/concepts/booth-exit-flow.md b/wiki/concepts/booth-exit-flow.md new file mode 100644 index 0000000..2e38b76 --- /dev/null +++ b/wiki/concepts/booth-exit-flow.md @@ -0,0 +1,131 @@ +--- +type: concept +tags: [parking, domain, booth, exit, payment, threat-model] +sources: [] +updated: 2026-06-17 +status: open +--- + +# Booth Exit Flow — pay-at-booth, voucher vs. immediate exit + +How the **manned booth** takes payment for a transient ticket and lets the car out. Complements the +unattended reader path in [[parking-session]] / the exit flow: same signed events, a booth-driven +trigger. Decided 2026-06-17. + +## Operator flow + +1. **Ticket input** on the booth screen. The operator scans (HID scanner types the id + Enter) or + keys the ticket number. +2. On submit, the booth **looks up the session** and opens a **modal**: entry time, exit time (now), + **duration**, **total owed** (the [[tariff]] quote), tender (cash/card), and a checkbox + **"Printo biletë dalje"** (print exit ticket). +3. The operator takes payment → a signed `payment` event ([[parking-session]]). What happens next + depends on the checkbox: + - **Checked → print an exit voucher.** The customer carries it to a (distant) exit and + **self-exits by scanning it** there; that scan runs the normal reader exit flow. The booth does + NOT open the barrier. + - **Unchecked → immediate exit.** When the modal closes after a successful payment, the booth + **signs `vehicle_exit`, pulses the exit relay, and fires the exit snapshot** right away (booth is + at/near the exit). + +## Settled decisions (2026-06-17) + +- **Voucher carries the SAME ticket id** (reprinted as the Code128 barcode). At the exit reader it + runs the existing exit validation — which now finds the session **paid + within walk-back grace**, + so it opens. No new identity or code type; the "biletë dalje" is a *paid reprint* of the entry + ticket id. Reuses [[tariff|walk-back grace]] exactly. +- **The checkbox default lives in `site_config`** (`exit_voucher_default`, a site-wide boolean edited + in Site settings) — because it's booth geography, not per-ticket. The operator may override per + transaction. (Per-exit-point config deferred until a site has both a near and a far exit.) +- **Payment is never rolled back.** If the checkbox is OFF and `pulseOpen` fails (offline + controller), the signed `payment` + `vehicle_exit` already stand (money was taken, the car is + owed an exit). The booth surfaces a clear error and an **audited `anomaly`** so the operator opens + manually — we never silently drop the payment, and never leave a paid car without an exit event. + +## Threat-model notes ([[threat-model|operator as adversary]]) + +- The booth exit reuses the **same validation as the reader path** (paid + within grace, or free + entry-grace) — there is no booth-only bypass that admits an unpaid car. An unpaid ticket sends the + operator to take payment first. +- Every booth action is a **signed ledger event attributed to the operator's session**: the payment, + the exit, and any `anomaly` (failed open / override). A colluding operator can't wave a car out + without leaving a signed, attributed trail visible to [[reconciliation]]. +- The voucher path keeps the **camera snapshot at the physical exit** (the self-scan fires it), so + the evidence is captured where the car actually leaves, not where it paid. + +## Active sessions & human-intervention barrier open + +**The barrier state is ASSUMED, never confirmed.** We send "open" intent and never truly know the car +cleared ([[barrier-not-a-door]], no wired loop/sensor feedback). So a signed `vehicle_exit` does NOT +mean the car is gone — it may be stuck (damaged ticket / dead scanner, or the barrier re-closed on a +phantom obstacle: an animal, a person, a cardboard box or bag in the wind). These edge cases need a +**human in the booth** to open the barrier, leaving a signed trace. + +**A session is "active" (shown in the booth Active Sessions list) while it is EITHER:** +- **open** — entered, no `vehicle_exit` yet (still inside), OR +- **exited but `now ≤ graceExpiresAt`** — paid and/or the voucher scanned, but still within the + walk-back grace window. Because the barrier is unconfirmed, the car is presumed *possibly still + present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the + list** — only grace expiry does. + +A session drops off the list once it is exited **and** past grace (presumed truly gone). + +### The one operator action — "Open barrier" (audited re-pulse) + +For an active session, the operator can open the barrier as a **human intervention**. This: +- **re-pulses an exit relay** (resolved site-wide, as the booth exit does), and +- signs an **`anomaly`** (`source: booth`, attributed to the operator, reason "manual barrier open") + — **NEVER a second `vehicle_exit`** (a second exit would double-count occupancy and corrupt the + ledger's meaning). It is an audited *re-open*, not a new exit. + +**Guard — no payment, no button.** The "Open barrier" action is shown/active **only for sessions that +have a payment** (paid, or paid-and-exited-in-grace). An **unpaid** open session has **no barrier-open +affordance at all** — the row routes to the [[#operator-flow|pay/exit modal]] instead. The +no-unpaid-bypass rule is enforced structurally: the button simply does not exist for an unpaid car. +(A future reason-required *force exit* for genuine disputes would be a separately-audited path — see +Open.) + +This single mechanism covers both edge cases: a **damaged ticket / dead scanner** (find the still-open +session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a +**phantom-obstacle re-close** (the just-exited car is still in the list within grace → Open barrier). + +## ⚠ Open question — walk-back grace renews on every payment (voucher overstay) + +**Found 2026-06-17. Not yet fixed.** Scenario: customer pays at the booth, takes an exit voucher, +then dawdles past the walk-back grace before reaching the exit. + +What the code does today (`exit-flow.ts`, `pay-station.ts`): +- The exit reader's grace check is `now − paidAt ≤ graceExitMin`, reading **the latest payment's** + `graceExitMin`. Over the window → exit **refuses** ("top-up required"). ✓ *Correct — no free exit.* +- The re-quote (`computeFee(enteredAt, now, …)`) always prices from **entry**, never from the last + payment. So a top-up charges the **full** entry→now fee (minus what's paid is implicit via the + ledger). ✓ *Correct — the timer does NOT restart; the customer pays the true total.* +- BUT every `payment` writes its own `graceExitMin`, and the exit flow reads the **latest** one — so + **each top-up grants a fresh, full grace window.** ✗ *This is the bug.* + +**The leak is time, not money.** It is not a free-exit hole (the fee always catches up from entry). +But the grace window — meant as a one-time walk-from-pay-to-gate allowance — is re-granted in full on +every payment, so a customer could pay → wait → pay a tiny delta → get another full window → repeat, +riding the gap between "paid" and "next increment accrues." With coarse [[tariff]] increments the +abuse is bounded but real. + +**Candidate fixes (business call — fairness vs. anti-abuse):** +1. **Grace on top-up only when the top-up charged new money** (recommended). Kills the "tiny delta + forever" loop while staying fair to a genuine overstay; re-price stays from entry. +2. **Single non-renewing window** anchored to the FIRST payment — cleanest anti-abuse, but can unfairly + trap someone who legitimately paid, walked, then hit a slow elevator after a top-up. +3. **Cap total grace** granted per session regardless of payment count. + +Decided halves: **refuse-on-expiry** and **reprice-from-entry** are deliberate and correct. The +**grace-renews-fully-per-payment** consequence was an unintended side effect of reading `graceExitMin` +off the latest payment. See [[tariff]] (walk-back grace) for the pricing side of the same question. + +## As-built / open +- Backend: `GET /api/session/:identity` (lookup + quote), `POST /api/exit { identity }` (validated + booth exit), `site_config.exit_voucher_default`. Exit validation shared between the booth and the + reader path (one code path, two triggers). +- **Open: walk-back grace renews on every payment** — see the flagged section above (voucher overstay + re-grants a full grace window; pick a fix before production). +- Voucher print = reprint the ticket id barcode on the booth printer ([[ticket-encoding]]). +- Open: a force-open **override** (lost ticket / equipment fault) — deferred; would be a separately + audited signed event, not folded into the validated path.