Files
parking_solution/wiki/concepts/booth-exit-flow.md
T
julian b8ddda86e7 feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling
Rounds out subscriptions across enrollment, the barrier flow, and the booth.

- RFID credentials enabled with a "Read card" enrollment flow: the operator
  arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
  reader's next read is captured into the form and NOT dispatched to the access
  flow — the OTHER reader keeps serving live entry/exit. Routes:
  /api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
  per-occurrence id (SUBSESS-<short>), not the credential value, with
  permitId in the payload. Direction is decided by the barrier the reader sits
  at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
  admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
  pay/exit modal shows a subscription mode (snapshots + a single audited
  Open-barrier action) to assist a faulty exit reader / missing card;
  reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
  "abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
  verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.

Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:26:48 +02:00

11 KiB
Raw Blame History

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