After a completed payment the customer always gets a transparency record:
entry time, payment time, duration parked, amount + tender. One shared
ESC/POS renderer (renderReceipt + ReceiptData in @parking/devices), two
modes: VOUCHER = those figures PLUS the scannable Code128 barcode and an
emphasised walk-back-grace line, so the one slip both proves payment and
self-exits at a distant exit reader (replaced the old barcode-only voucher);
STANDALONE = detail-only, auto-printed at payment when no voucher is issued.
Figures fold from the SIGNED ledger (latest payment event); printed on the
booth printer (failover to dispenser). Best-effort: a printer fault never
blocks the exit that already happened — the modal shows a note and offers
"Reprint receipt".
Server: booth-print.ts printPaymentReceipt() + receiptFigures(); routes
POST /api/voucher (voucher) + new POST /api/receipt (standalone/reprint).
Both ESC/POS drivers gained printReceipt(). Web: BoothPayModal auto-prints
after a non-voucher payment + reprint button; api.ts printReceipt().
CP852 fixes found on a real printout: (1) uppercase Ë was mapped to 0xEB
(that's ű) — correct byte is 0xD3; (2) Intl.NumberFormat injects a NO-BREAK
SPACE (U+00A0/U+202F) that isn't in CP852 and printed as "?" — line() now
normalises it to a plain space ("1000 Lekë"); (3) grace line wrapped
mid-word — split into two short lines.
Full build green; both receipt modes render-verified; routes live.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
12 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 customer always gets a transparency receipt after paying (built 2026-06-18). Entry time,
payment time, duration parked, amount + tender — one shared
ReceiptData/renderReceiptin@parking/devices, two modes: (a) voucher mode = those figures PLUS the scannable barcode and an emphasised walk-back-grace line ("Dilni brenda N min — skanoni këtë biletë në dalje"), so the one slip both proves payment and self-exits — this replaced the old barcode-only voucher; (b) standalone mode = detail-only (no barcode), auto-printed at payment when the checkbox is OFF (booth at the exit, no voucher). Both fold the figures from the SIGNED ledger (latestpaymentevent) and print on the booth printer (failover to the dispenser). The standalone auto-print is best-effort: a printer fault must NOT block the exit that already happened — the modal shows a note and offers "Reprint receipt" (also used if the slip jams or the customer asks later). Routes:POST /api/voucher(voucher mode),POST /api/receipt(standalone/reprint; requires paid, allows an already-exited session so a reprint still works). Receipt timestamps use the host-local clock (the appliance runs in site time) — distinct from the tariff's frozen tz, which governs pricing reproducibility, not display. - 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/receipt print =
renderReceipt(shared ESC/POS) on the booth printer (ticket-encoding, printer-status-monitoring): voucher mode = figures + barcode + grace; standalone = figures only, auto-printed at payment.POST /api/voucher,POST /api/receipt. - Open: a force-open override (lost ticket / equipment fault) — deferred; would be a separately audited signed event, not folded into the validated path.