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:
@@ -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`):
|
||||
|
||||
Reference in New Issue
Block a user