266e9b0027
- 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
258 lines
19 KiB
Markdown
258 lines
19 KiB
Markdown
---
|
||
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.
|