Files
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

19 KiB
Raw Permalink Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
domain
booth
exit
payment
threat-model
2026-06-30 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 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)

  • 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 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 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.

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 — 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 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.