Files
parking_solution/wiki/concepts/booth-exit-flow.md
T
julian 266e9b0027
Build desktop / desktop (push) Successful in 4m34s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s
docs(wiki): record session findings — snapshot fix, booth rework, db reset
- entry-exit-points.md: the snapshot content-type bug + serve-side cleanType
  fix (Hikvision image/jpeg; charset="UTF-8" broke every legacy render).
- booth-exit-flow.md: the Active-Sessions/modal rework — inline barrier button
  removed -> modal; closed-within-grace view; live grace countdown; actual paid
  amount; read-only snapshot review in the closed-session view.
- local-dev-workflow.md: the gated `pnpm db:reset` training tool + flag table +
  the booth (docker exec, no pnpm) note.
- appliance-provisioning.md: new §7d — reset on the booth via docker exec into
  the server container (script ships in the deploy bundle; DATABASE_URL=
  /data/parking.sqlite), ledger-truncation warning + the two safety gates.
- index.md catalog line; log.md entries.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 17:58:43 +02:00

258 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, domain, booth, exit, payment, threat-model]
sources: []
updated: 2026-06-30
status: 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|walk-back grace]] 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`/`renderReceipt` in
`@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 (latest
`payment` event) 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 `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|operator as adversary]])
- 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 red `overstay` badge** (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 — `overstay` is a derived display flag
(`PayStation.activeSessions()` and `lookup()`), 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 to `overstay` /
> *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
`reopenBarrier` **refuses 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 on `paidAt != null`), which would have let an operator wave out a multi-day
overstay for free — exactly the [[threat-model|operator-as-adversary]] path. Subscriptions are never
`overstay` (prepaid; no `paidAt`/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_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|overstay flag]] 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|pay/exit modal]] 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.
> **Open-barrier moved INTO the modal — the inline row button is gone (2026-06-30).** The audited
> re-pulse was previously an inline button on the paid-in-grace Active Sessions *row*. It was removed:
> clicking any row now opens the modal, which carries the Open-barrier action. Why: a paid-and-exited
> session is `open=false`, so clicking its row used to dead-end on *"This session is already closed"* —
> useless for the very case (paid, barrier didn't confirm) where the operator needs to re-pulse. The
> modal now recognizes a **closed-within-grace** transient (`found && !open && withinGrace`) and renders
> the session view + **Open barrier** instead of the dead-end notice. The server guard is unchanged
> (`reopenBarrier` already handled the closed-but-in-grace case — the T-397815c0 fix above). The
> Active Sessions list distinguishes these rows with a **live grace-remaining countdown** badge
> (`exited · M:SS`, ticking each second off `graceExpiresAt`) instead of a static "exited" label.
> Settled amounts now show the **actual sum paid** (new `SessionLookup.paidMinor`, summed across
> payments) rather than a flat "PAID" badge. And a **fully-closed (grace-expired) session** is no longer
> a pure dead-end: its modal shows a read-only **review view** — figures + paid amount + the entry/exit
> [[entry-exit-points#camera-snapshots-evidence-not-a-gate|snapshot strip]] — so an operator can review
> evidence for a car that just left (disputes/audits), with no pay/exit/open controls.
### 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|Overstay pricing]] 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'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/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.