Rework paid-but-grace-expired sessions and add booth filters. Overstay (was "stuck"): - Stop silently aging out a paid transient whose walk-back grace lapsed with no signed exit. Keep it listed with an OVERSTAY badge — a new parking period began (re-parked) or the car is faulty/abandoned; it is not a system fault. - No free exit: reopenBarrier refuses server-side once a transient's payment grace has expired (allow only subscription OR paid-and-within-grace); the UI hides the Open-barrier button on overstay rows and routes to the pay/exit modal. Closes a hole where a stale payment authorized a free multi-day exit (operator-as-adversary). - Price the overstay as a NEW period from grace-expiry -> now with its own daily-cap ladder, NOT "full stay minus paid" (which a daily cap collapsed to 0 — ticket 1245791632490 owed ALL 0; now owes its real overstay). quote() gains periodStart + overstay; SessionLookup/ActiveSession gain `overstay`. handlePayAndExit charges whenever the session is payable (was: only if !alreadyPaid, skipping the overstay). Filters (new ui/FilterBar): Active Sessions — search + status (unpaid/paid/exiting/overstay) + transient-vs-subscriber. Live feed — search + event (entry/exit/pay/void/anomaly) + direction + source (booth=manual vs reader). All client-side over already-fetched data; matched/total count shown. i18n parity (sq+en). Wiki: booth-exit-flow updated (overstay model, naming history, no-free-exit security fix, new-period pricing; open question on grace-renewal noted). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
17 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 exited AND past grace (presumed truly gone). One more state is kept and flagged, not dropped:
-
OVERSTAY — open + paid + past grace, no signed
vehicle_exit. A paid transient whose walk-back grace lapsed. This is not a system fault and not "stuck": the customer paid, then the car stayed beyond the paid window — they re-parked (a new period began), or the car is faulty/abandoned. It is kept in the list with a distinct redoverstaybadge (not aged out) so the operator reconciles it. This replaces the earlier silent paid age-out (revised 2026-06-20): aging these out hid a real problem — the session lingers in occupancy (the ledger fold counts it inside, so occupancy and the active-list count diverge), and on a re-scan the exit flow refuses (exit.refused.graceExpired). The signed log is untouched —overstayis a derived display flag (PayStation.activeSessions()andlookup()), as the age-out was. The occupancy/active-list gap is now explained by these named rows rather than an unbounded counter.Naming history (2026-06-20). First shipped as
stuck/ i ngecur. Renamed tooverstay/ tej afatit the same day: "stuck" wrongly implied a system fault trapping the customer, when in fact a new parking period has begun. The label now states the fact (stayed beyond the paid window), not a presumed cause.No free exit on an overstay (security fix, 2026-06-20). An overstay is NOT offered the "Open barrier" action — its row routes to the pay/exit modal for the new-period payment, and
reopenBarrierrefuses server-side when a transient's payment grace has expired ("walk-back grace expired — take a top-up payment first"). A stale payment no longer authorizes a free open. This corrects a hole introduced earlier the same day: the first cut kept the Open-barrier button on these rows (it gated onpaidAt != null), which would have let an operator wave out a multi-day overstay for free — exactly the threat-model path. Subscriptions are neveroverstay(prepaid; nopaidAt/grace) and keep their assist Open-barrier.
Why flag, not auto-close. The chosen fix (user, 2026-06-20) keeps the ledger append-only and the operator in the loop: surfacing the session beats silently synthesizing an exit (which would mutate occupancy with a weaker audit story) or silently hiding it (which lets occupancy drift upward until the lot falsely reads "full").
Overstay pricing — a NEW period from grace-expiry (2026-06-20)
When an overstay is settled, quote() prices a fresh period anchored at grace-expiry
(paidAt + graceExitMin) → now, with its own daily-cap ladder — NOT the whole stay, and NOT
"full stay minus paid". The latter was tried first and was wrong under a daily cap: the
whole-stay gross plateaus at the cap while prior payments keep pace, so gross − paid collapses to
0 and a multi-day overstay would exit free (real case: ticket 1245791632490 — entered
2026-06-17, paid 330000 with a 100000/day cap, gross = 330000, delta = 0 ALL). Pricing the
overstay as a new session reflects reality (the car re-parked) and re-accrues the fee
(verified: the same ticket owes 20000 ALL for its first half-hour of overstay, not 0). The tariff
version stays the one frozen at entry (the customer keeps their rate card). The booth modal shows
this as a "New period due" total with an OVERSTAY status; taking the payment writes a fresh
graceExitMin, restarting the walk-back window so the car can exit normally. A within-grace paid
session is not an overstay (amountMinor = 0, non-payable). Quote now carries periodStart (entry,
or grace-expiry for an overstay) and an overstay flag.
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 visibility net for any paid session that still slips through — it surfaces the orphan for operator reconcile instead of hiding it.
Guard — paid-and-in-grace OR subscription, else no button. The "Open barrier" action is
shown/active for a session that has a payment still within the walk-back grace window (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). It is NOT offered for an unpaid
TRANSIENT (no-unpaid-bypass) nor for an overstay session (grace expired → owes a
top-up). Both route to the #operator-flow instead. Enforced structurally
server-side in reopenBarrier: allow only when subscription OR (paidAt != null AND now ≤ paidAt + graceExitMin). A future reason-required force exit for genuine disputes (car already gone)
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).
Booth filters (built 2026-06-20)
Both booth lists carry a shared, client-side FilterBar (search box + segmented toggles; the active
filter shows a matched/total count). No new API — filtering is over data already fetched.
- Active Sessions: free-text (ticket id / subscriber holder), a status segment (unpaid / paid / exiting / overstay), and a transient vs subscriber segment.
- Live feed: free-text (identity / subscriber label / advisory plate), an event segment
(entry / exit / pay / void / anomaly), a direction segment (entry / exit), and a source
segment — booth (operator-initiated,
source: manual) vs reader (device-initiated: wiegand/lpr/qr/ticket). Filters are scoped within the current shift window, as the feed already is.
⚠ Open question — walk-back grace renews on every payment (voucher overstay)
Update 2026-06-20 — pricing half resolved; grace-renewal half still open. The overstay work (see #overstay-pricing-a-new-period-from-grace-expiry-2026-06-20 above) changed the money model: an overstay is now priced as a NEW period from grace-expiry, not reprice-from-entry. The note below described the older reprice-from-entry behaviour; the leak-is- time, not money analysis still holds for the grace-window side, which is still unfixed — candidate fix #1 below remains the recommendation. (Note: under new-period pricing the "pay a tiny delta → fresh full window" loop now also re-accrues a fresh fee each cycle, narrowing but not closing the time leak.)
Found 2026-06-17. 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.