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
+41 -7
View File
@@ -52,9 +52,16 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
const alreadyPaid = s?.paidAt != null;
const isSubscription = s?.subscription === true;
// OVERSTAY = paid but walk-back grace expired with no exit → a NEW period began; owes
// a fresh TOP-UP. Treat it as payable even though it's "already paid": the car must
// settle the new period's fee (s.amountMinor, priced from grace-expiry) before any
// exit. A normal within-grace paid session is NOT payable (it's settled). See
// booth-exit-flow.md / reopenBarrier server guard.
const isOverstay = s?.overstay === true;
// A subscription is prepaid: never charged. The only booth action is an audited
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
const canPay = shiftReady && s?.found && s.open && !alreadyPaid && !isSubscription;
// Allow pay for an unpaid session OR an overstay (new-period top-up) one.
const canPay = !!(shiftReady && s?.found && s.open && (!alreadyPaid || isOverstay) && !isSubscription);
async function handleOpenBarrier() {
if (!s) return;
@@ -103,8 +110,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
if (!s) return;
setError(null);
try {
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
if (!alreadyPaid) {
// 1. Take payment. For a first stay this is the only charge; for an OVERSTAY the
// session is "already paid" but a new period accrued — we still charge (canPay
// is true). A settled within-grace session is not payable (canPay false) and is
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
if (canPay) {
setPhase("paying");
await paySession(identity, tender);
}
@@ -221,15 +231,32 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
/>
<Row
label={t("pay.statusLabel")}
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
value={
isSubscription
? t("pay.subscription")
: isOverstay
? t("pay.overstay")
: alreadyPaid
? t("pay.paid")
: t("pay.unpaid")
}
valueClass={
isSubscription
? "text-term-cyan"
: isOverstay
? "text-term-red"
: alreadyPaid
? "text-term-green"
: "text-term-amber"
}
/>
</div>
{/* Total — a subscription is prepaid (no amount); show a badge. */}
{/* Total — a subscription is prepaid (no amount); show a badge. For an
overstay the amount is the TOP-UP delta, not the whole stay. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">
{isSubscription ? t("pay.plan") : t("pay.total")}
{isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
</span>
<span className="text-3xl font-bold text-term-cyan">
{isSubscription
@@ -249,6 +276,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</div>
)}
{/* For an overstay, explain why a top-up is required (no free exit). */}
{isOverstay && (
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.overstayHint")}
</div>
)}
{/* Snapshots */}
<SnapshotStrip identity={identity} />