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
11 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| concept |
|
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
- 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 + 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_exitexists yet, the re-open is this car leaving, so it also signs avehicle_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 normalvehicle_exithad 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'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.