feat(booth): active sessions panel + audited barrier re-open
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.
This commit is contained in:
@@ -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 (
|
||||
<Panel
|
||||
title="Active sessions"
|
||||
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{sessions.length} inside</span>}
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-term-muted">{isLoading ? "loading…" : "no active sessions."}</div>
|
||||
) : (
|
||||
sessions.map((s) => {
|
||||
const badge = statusBadge(s);
|
||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||
return (
|
||||
<div
|
||||
key={s.identity}
|
||||
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(s.identity)}
|
||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||
title="Open pay / exit"
|
||||
>
|
||||
<span className="text-term-text">{s.identity}</span>
|
||||
<span className="text-term-muted">in {formatTime(s.enteredAt)}</span>
|
||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
||||
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{badge.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Open barrier — PAID sessions only (no payment, no button). */}
|
||||
{s.paidAt ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending}
|
||||
onClick={() => handleReopen(s)}
|
||||
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
|
||||
title="Human-intervention barrier open (audited)"
|
||||
>
|
||||
Open barrier
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-[88px] shrink-0" />
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user