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.
8.4 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-06-17 | 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
- Ticket input on the booth screen. The operator scans (HID scanner types the id + Enter) or keys the ticket number.
- 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).
- The operator takes payment → a signed
paymentevent (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 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
pulseOpenfails (offline controller), the signedpayment+vehicle_exitalready stand (money was taken, the car is owed an exit). The booth surfaces a clear error and an auditedanomalyso 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)
- 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_exityet (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 secondvehicle_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 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'sgraceExitMin. 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
paymentwrites its owngraceExitMin, 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):
- 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.
- 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.
- 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.