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

8.4 KiB
Raw Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
domain
booth
exit
payment
threat-model
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

  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 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)

  • 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 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.