feat(booth): overstay sessions, top-up pricing, and session/feed filters

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
This commit is contained in:
2026-06-20 11:48:54 +02:00
parent 918f76fbef
commit a4712774ab
11 changed files with 620 additions and 110 deletions
+55
View File
@@ -948,3 +948,58 @@ Reworked the ANPR TRIGGER per the real design goal: when a transient pushes the
## [2026-06-19] feat | Surface recognized plate in the booth UI (SnapshotStrip)
Made the ANPR plate VIEWABLE (it was saved but had no UI). Extended GET /api/snapshots/by-identity/:identity to also query device_events kind:"read" for that identity and return plates[] (plate, confidence, region, direction, snapshotId, at) alongside the existing snapshots + failures. The SnapshotStrip now renders each recognized plate as a cyan "Plate: AA558EE 100%" chip above the images (deduped by plate+direction; title shows region + time) — so it appears in BOTH the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. session:read gated (same as snapshots). i18n pay.plate sq+en. VERIFIED: by-identity returns plates[] for a seeded read (status 200, {plate:AA558EE, confidence:0.999, region:Albania, direction:entry, snapshotId}). Build+lint green. Updated [[opencv-anpr-service]].
## [2026-06-20] feat | Flag stuck sessions + booth filters (session list & live feed)
Replaced the silent paid age-out in PayStation.activeSessions() with a derived `stuck` flag: an
open + paid + past-grace session with no signed vehicle_exit is no longer dropped — it stays listed
with a red "stuck" badge so the operator can reconcile (top-up exit / void). Root cause surfaced via
the live ledger: 4 such orphans (5717802544704, 1245791632490, 7985713986045, 9340902468934) each
entry=1/exit=0/pay=1, grace=5min lapsed; they linger in the occupancy fold (so occupancy diverges
from the active-list count) and a re-scan re-quotes the tariff from entry (paid customer charged
again). Signed log untouched; subscriptions never stuck (no paidAt). This removed the earlier
unbounded "presumed-left (N)" counter (occupancy − sessions), which had been growing.
Added a shared client-side FilterBar (ui/FilterBar.tsx: search + SegGroup toggles, matched/total
count). Active Sessions: search (ticket/holder) + status (unpaid/paid/exiting/stuck) + transient-vs-
subscriber. Live feed: search (identity/subscriber/plate) + event (entry/exit/pay/void/anomaly) +
direction (entry/exit) + source (booth=manual vs reader=device). No new API. Exit grace re-scan
logic UNCHANGED (still refuse + send to booth — user choice). Build+lint green across the monorepo.
Updated [[booth-exit-flow]].
## [2026-06-20] fix | No free exit on overstay (stuck session) + top-up pricing
SECURITY FIX correcting same-day stuck-flag work. A stuck session (paid + walk-back grace expired +
no signed exit) is AMBIGUOUS — the car may have left OR be overstaying inside. The first cut kept the
"Open barrier" button on these rows (gated on paidAt != null), which would let an operator wave out a
2-day overstay for free — the operator-as-adversary path. Fix: reopenBarrier now refuses a transient
whose payment grace has expired (allow only subscription OR paid-and-within-grace), enforced
server-side (exit-flow.ts), mirrored in the UI (no button on s.stuck → routes to pay/exit modal).
Verified against the live ledger: ticket 5717802544704 (entered 73.9h ago, paid 200000, grace 5min)
computes stuck=true and reopenBarrier REFUSES it.
Top-up pricing — "full stay minus paid" (user choice): quote() now returns grossMinor (whole stay
entry→now) and paidMinor (fold of prior signed payment amounts), with amountMinor = max(0, gross −
paid) — the delta only, never the full stay twice. lookup()/SessionLookup gained `stuck`; the pay
modal shows OVERSTAY status + "Top-up due" + a hint, and canPay now allows payment for a stuck
session. Taking the top-up restarts grace so the car exits normally. i18n pay.overstay/overstayHint/
topUp in sq+en. Partially resolves the grace-overstay Open question (the amount); whether to bill the
overstay delta-from-grace vs full-minus-paid left open. Build+lint green. Updated [[booth-exit-flow]].
## [2026-06-20] fix | Rename stuck→overstay + price overstay as a NEW period (fixes ALL 0)
Two user-driven corrections to the same-day overstay work. (1) NAMING: "stuck"/"i ngecur" wrongly
implied a system fault trapping the customer — but a paid-then-grace-expired car means a NEW parking
period began (re-parked) or the car is faulty/abandoned. Renamed the flag + badge + filter +
SessionLookup/ActiveSession field to `overstay` / "tej afatit" across server + web + i18n.
(2) PRICING BUG: "full stay minus paid" collapsed to 0 under a daily cap — ticket 1245791632490
(entered 06-17, paid 330000, cap 100000/day) had gross=330000, so delta=0 → "Diferenca për pagesë
ALL 0", a free multi-day exit. Fix (user choice): quote() now prices an overstay as a NEW period
anchored at grace-expiry (paidAt+graceExitMin)→now with its own daily-cap ladder, NOT entry→now. The
tariff version stays the one frozen at entry. Quote gained periodStart + overstay; removed
grossMinor/paidMinor. Verified: 1245791632490 now owes 20000 ALL (first half-hour of overstay), not 0.
pay modal: "New period due"/OVERSTAY; handlePayAndExit now charges when canPay (was: only if
!alreadyPaid — would have skipped the overstay charge). i18n pay.overstay/overstayHint/topUp +
booth.badgeOverstay*/fStatusOverstay rewritten in sq+en. Build+lint green. Updated [[booth-exit-flow]]
(overstay section + naming history + partial-resolution note on the grace-renewal open question).