--- type: concept tags: [parking, domain, booth, exit, payment, threat-model] sources: [] updated: 2026-06-18 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 + unpaid** — entered, no `vehicle_exit`, owing money. **Always shown** — a car that owes money never ages out; it's genuinely still inside until it pays, however long that takes. OR - **open + paid, still within grace** — paid but no exit recorded yet, `now ≤ graceExpiresAt`. 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 **past grace** and EITHER exited OR **paid** (presumed truly gone). The **paid age-out** is important: a paid session whose walk-back grace lapsed has left, so it is omitted **even if no `vehicle_exit` was ever signed**. Without this, a paid car that left via a manual barrier re-open (which historically signed no exit — see below) would linger **forever** (ticket T-397815c0, 2026-06-18). The signed log is untouched — this is purely the list's display filter (`PayStation.activeSessions()`). ### 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"), and - **closes the session IF it is still open** — i.e. if no `vehicle_exit` exists yet, the re-open *is* this car leaving, so it also signs a **`vehicle_exit`** (`source: manual`, reason "human-intervention exit"). If the session is **already exited** (the phantom re-close case — a second exit would double-count occupancy), it signs **no** second exit: anomaly only. > **Refined 2026-06-18 (was "NEVER a `vehicle_exit`").** The original rule never signed an exit on a > re-open, on the assumption a normal `vehicle_exit` had already happened. But when the re-open was the > *only* way a car left (its walk-back grace had expired, so a normal exit was refused), the session > kept **no exit event** and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only > when the session is **still open**, preserving the no-double-count guarantee for the already-exited > case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any > paid session that still slips through. **Guard — paid OR subscription, else no button.** The "Open barrier" action is shown/active for a session that **has a payment** (paid, or paid-and-exited-in-grace) **OR is a [[subscription]] occurrence** (prepaid — the operator must be able to assist a subscriber when the exit reader / card fails). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally (server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force exit* for genuine disputes would be a separately-audited path — see Open. ### Subscription occurrences in the booth (built 2026-06-18) A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by the **holder name**, not the raw `SUBSESS-…` key). Opening it shows the **pay/exit modal in subscription mode**: entry/duration + **PREPAID** (no amount — it is **never quoted or charged**), the entry/exit **snapshots**, and a single **Open barrier** action (the audited re-pulse). This is exactly the assist path for a **faulty exit reader or a missing/forgotten card/QR**. The session view (`lookup` / `activeSessions` in `pay-station.ts`) carries `subscription`, `subscriptionId`, `subscriptionHolder`, derived from the entry payload's `permit:true` / `permitId`. 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.