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`):