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
+82 -16
View File
@@ -84,12 +84,54 @@ phantom obstacle: an animal, a person, a cardboard box or bag in the wind). Thes
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 **past grace** and EITHER exited OR **paid** (presumed truly
gone). The **paid age-out** is important: a paid session whose walk-back grace lapsed has left, so it
is omitted **even if no `vehicle_exit` was ever signed**. Without this, a paid car that left via a
manual barrier re-open (which historically signed no exit — see below) would linger **forever**
(ticket T-397815c0, 2026-06-18). The signed log is untouched — this is purely the list's display
filter (`PayStation.activeSessions()`).
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)
@@ -106,16 +148,19 @@ For an active session, the operator can open the barrier as a **human interventi
> *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|paid age-out]] above is the belt-and-braces safety net for any
> paid session that still slips through.
> 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 OR subscription, else no button.** The "Open barrier" action is shown/active for a
session that **has a payment** (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). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to
the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally
(server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force
exit* for genuine disputes would be a separately-audited path — see Open.
**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.
### Subscription occurrences in the booth (built 2026-06-18)
@@ -131,9 +176,30 @@ This single mechanism covers both edge cases: a **damaged ticket / dead scanner*
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)
**Found 2026-06-17. Not yet fixed.** Scenario: customer pays at the booth, takes an exit voucher,
> **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`):
+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).