Files
parking_solution/wiki/concepts/booth-exit-flow.md
T
julian eb3dc18e67 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.
2026-06-18 11:05:26 +02:00

132 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.