diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 5be72ca..5f19815 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -195,12 +195,22 @@ export class ExitFlow { const view = this.#sessionFor(id); if (!view) return { ok: false, reason: "no session for ticket" }; - // Authorization to re-open: a PAID transient (paid, or paid-then-exited within - // grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must - // assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit - // flow instead — enforced here, not just in the UI (the no-unpaid-bypass rule). - if (view.paidAt == null && !view.subscription) { - return { ok: false, reason: "session not paid — no barrier open without payment" }; + // Authorization to re-open: a SUBSCRIPTION occurrence (prepaid — exactly the case + // the operator must assist when the exit reader / card fails) OR a transient whose + // payment is STILL WITHIN the walk-back grace window. A stale payment does NOT + // authorize a free open: a car that paid once and then sat inside past grace owes a + // top-up for the extra time — letting it out on the old payment is the overstay-fraud + // path. So we mirror the exit flow's grace check here (not just in the UI): an + // unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead. + // The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side. + const paid = view.paidAt != null; + const withinGrace = + paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000; + if (!view.subscription && (!paid || !withinGrace)) { + return { + ok: false, + reason: paid ? "walk-back grace expired — take a top-up payment first" : "session not paid — no barrier open without payment", + }; } const key = `reopen:${id}`; diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index 0e1750b..d27740e 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -28,8 +28,18 @@ export class NoTariffError extends Error { export interface Quote { readonly identity: string; + /** Vehicle entry time (the session's original entry; for display/audit). */ readonly enteredAt: string; + /** Start of the period being billed RIGHT NOW. For a first payment this is the + * entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car + * re-parked / a new period began) it is the moment that grace expired: the overstay + * is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT + * "full stay minus paid" (which a daily cap collapses toward zero). */ + readonly periodStart: string; + /** Amount owed now: the fee for [periodStart → now]. */ readonly amountMinor: number; + /** True when this quote prices an overstay period (grace lapsed), not the first stay. */ + readonly overstay: boolean; readonly currency: string; readonly tariffVersionId: string; readonly graceExitMin: number; @@ -53,6 +63,13 @@ export interface ActiveSession { readonly currency: string | null; readonly withinGrace: boolean; readonly graceExpiresAt: string | null; + /** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit. + * The car either re-parked (a new period began) or is faulty/abandoned — not a system + * fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from + * grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator + * reconciles via a top-up, instead of silently aging it out. No free barrier open. + * See wiki/concepts/booth-exit-flow.md. */ + readonly overstay: boolean; /** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it * with snapshots + an always-available "open barrier" (assist a faulty exit reader / * missing card), and never a pay flow. See wiki/entities/subscription.md. */ @@ -80,6 +97,9 @@ export interface SessionLookup { readonly withinGrace: boolean; /** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */ readonly graceExpiresAt: string | null; + /** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period + * began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */ + readonly overstay: boolean; /** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */ readonly subscription: boolean; readonly subscriptionId: string | null; @@ -97,11 +117,28 @@ export class PayStation { this.#logger = logger; } - /** Price an open session against the tariff in force at its entry. No side effect. */ + /** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a + * paid session whose walk-back grace has lapsed (the car re-parked, or a new period + * began) — the customer is billed for a FRESH period from grace-expiry→now, with its + * own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the + * whole-stay gross plateaus while prior payments keep pace, so the delta collapses to + * 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period + * reflects the reality and re-accrues the fee. No side effect. */ quote(identity: string): Quote { const entry = this.#openEntry(identity); if (!entry) throw new NoOpenSessionError(identity); + // If the latest payment's walk-back grace has expired, this is an overstay: anchor + // the new billing period at grace-expiry (paidAt + graceExitMin). Otherwise price + // from entry (first payment, or a still-within-grace re-quote of the same stay). + const last = this.#lastPayment(identity); + const graceExpiryMs = + last && last.graceExitMin != null ? Date.parse(last.paidAt) + last.graceExitMin * 60_000 : null; + const overstay = graceExpiryMs != null && Date.now() > graceExpiryMs; + const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : entry.occurredAt; + + // The tariff in force is keyed to ENTRY (the version frozen for this session), even + // for an overstay period — the customer keeps the rate card they entered under. const tv = this.#tariffVersionFor(entry.occurredAt); if (!tv) throw new NoTariffError(); const structure = tv.structure as unknown as TariffStructure; @@ -110,23 +147,44 @@ export class PayStation { // both read it from there, so a V2 category tariff yields the same amount at the // booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing. const category = (entry.payload as { category?: string } | null)?.category; - const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure, category); + const amountMinor = computeFee(periodStart, new Date().toISOString(), structure, category); return { identity, enteredAt: entry.occurredAt, + periodStart, amountMinor, + overstay, currency: tv.currency, tariffVersionId: tv.id, graceExitMin: structure.gracePeriodExitMin, }; } + /** The latest signed `payment` for this session (time + the grace window it granted), + * or null if never paid. Folds the append-only ledger. */ + #lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null { + const rows = this.#db + .select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload }) + .from(ledgerEvents) + .where(eq(ledgerEvents.identity, identity)) + .orderBy(ledgerEvents.index) + .all(); + let last: { paidAt: string; graceExitMin: number | null } | null = null; + for (const r of rows) { + if (r.type !== "payment") continue; + const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin; + last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null }; + } + return last; + } + /** * Take payment for a session and append the signed `payment` event. Re-quotes at - * the moment of payment (the customer pays for time parked SO FAR). For an - * overstay top-up the same call re-prices entry→now and the exit flow's - * grace-window restarts from this payment. `overrideMinor` lets the operator set - * an arbitrary amount (lost ticket / dispute) — recorded as the charged amount. + * the moment of payment (the customer pays for time parked SO FAR). For an OVERSTAY + * (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`), + * and this payment writes a new `graceExitMin` so the walk-back window restarts. + * `overrideMinor` lets the operator set an arbitrary amount (lost ticket / dispute) — + * recorded as the charged amount. */ async pay( identity: string, @@ -183,7 +241,7 @@ export class PayStation { return { identity: id, found: false, open: false, enteredAt: null, exitedAt: null, paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null, - subscription: false, subscriptionId: null, subscriptionHolder: null, + overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, }; } // Subscription occurrence? The entry payload carries permit:true + permitId. @@ -220,10 +278,12 @@ export class PayStation { } } + const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace; + return { identity: id, found: true, open, enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null, - paidAt, amountMinor, currency, withinGrace, graceExpiresAt, + paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay, subscription: isSubscription, subscriptionId, subscriptionHolder: this.#holderOf(subscriptionId), }; @@ -287,26 +347,29 @@ export class PayStation { const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt); const paid = a.paidAt != null; + const isSubscription = a.subscriptionId !== undefined; + // ACTIVE membership: // - exited + within grace → still shown (barrier unconfirmed, may be present); // - exited + past grace → presumed gone, omitted; // - open + UNPAID → always shown (a car owing money never ages out — // it's genuinely still inside until it pays, however long that takes); - // - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace - // lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a - // manual barrier re-open before that path closed the session, or a historical - // session like T-397815c0) it would otherwise linger forever. The signed log - // is unchanged — this is purely a display filter. See booth-exit-flow.md. + // - open + PAID + past grace → OVERSTAY. A paid transient whose walk-back grace + // lapsed with no signed vehicle_exit: the car re-parked (a new period) or is + // faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy + // and owes a fresh period (priced from grace-expiry, see `quote`). We used to + // age these out (a silent display filter); now we KEEP them and flag `overstay` + // so the operator reconciles via a top-up. The signed log is untouched, and the + // barrier never opens for free on these. See booth-exit-flow.md. if (!open && !withinGrace) continue; - if (open && paid && graceExpiresAt != null && !withinGrace) continue; + const overstay = + open && paid && !isSubscription && graceExpiresAt != null && !withinGrace; - const isSubscription = a.subscriptionId !== undefined; - - // Amount owed now: only meaningful for an open + unpaid TRANSIENT session. A - // subscription is prepaid — never quote/charge it. + // Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new + // period's top-up). A subscription is prepaid — never quote/charge it. let amountMinor: number | null = null; let currency: string | null = null; - if (open && a.paidAt == null && !isSubscription) { + if (open && !isSubscription && (a.paidAt == null || overstay)) { try { const q = this.quote(identity); amountMinor = q.amountMinor; @@ -327,6 +390,7 @@ export class PayStation { currency, withinGrace, graceExpiresAt, + overstay, subscription: isSubscription, subscriptionId: a.subscriptionId ?? null, subscriptionHolder: this.#holderOf(a.subscriptionId ?? null), diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index bc54fe0..bbc98db 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js"; @@ -6,6 +6,7 @@ import { qk } from "./lib/query.js"; import { useShift } from "./lib/use-shift.js"; import { formatDuration, formatRelativeDateTime } from "./lib/format.js"; import { Panel } from "./ui/Panel.js"; +import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; // Active Sessions panel. A session is "active" while still inside OR exited-but- // within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed @@ -14,10 +15,28 @@ import { Panel } from "./ui/Panel.js"; // - click a row → the pay/exit modal (pay an unpaid car, or review), // - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse. // No payment → no Open barrier button (the no-unpaid-bypass rule). +// +// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they +// stay listed with a distinct badge. A new period has begun (the car re-parked or is +// faulty/abandoned); occupancy lingers and the car owes a fresh top-up. The operator +// reconciles via the pay/exit modal — never a free barrier open. // See wiki/concepts/booth-exit-flow.md. -function statusBadge(s: ActiveSession): { key: string; cls: string } { +type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay"; +type KindFilter = "transient" | "subscription"; + +function statusOf(s: ActiveSession): StatusFilter | "subscription" { + if (s.subscription) return "subscription"; + if (s.overstay) return "overstay"; + if (!s.open && s.withinGrace) return "exiting"; + if (s.paidAt) return "paid"; + return "unpaid"; +} + +function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } { if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" }; + if (s.overstay) + return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" }; if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" }; if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" }; return { key: "booth.badgeUnpaid", cls: "text-term-amber" }; @@ -47,7 +66,36 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }); const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null); - const sessions = data?.sessions ?? []; + // Filters: free-text search, status, and transient-vs-subscriber. + const [search, setSearch] = useState(""); + const [status, setStatus] = useState(""); + const [kind, setKind] = useState(""); + + const sessions = useMemo(() => data?.sessions ?? [], [data]); + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return sessions.filter((s) => { + if (kind === "transient" && s.subscription) return false; + if (kind === "subscription" && !s.subscription) return false; + if (status && statusOf(s) !== status) return false; + if (q) { + const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); + }, [sessions, search, status, kind]); + + const statusOpts: SegOption[] = [ + { value: "unpaid", label: t("booth.fStatusUnpaid") }, + { value: "paid", label: t("booth.fStatusPaid") }, + { value: "exiting", label: t("booth.fStatusExiting") }, + { value: "overstay", label: t("booth.fStatusOverstay") }, + ]; + const kindOpts: SegOption[] = [ + { value: "transient", label: t("booth.fKindTransient") }, + { value: "subscription", label: t("booth.fKindSubscription") }, + ]; async function handleReopen(s: ActiveSession) { setReopenMsg(null); @@ -68,62 +116,84 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void title={t("booth.activeSessions")} right={ - {sessions.length} {t("booth.insideCount")} + {filtered.length} + {filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")} } className="min-h-0 flex-1" > -
- {sessions.length === 0 ? ( -
{isLoading ? t("common.loading") : t("booth.noActiveSessions")}
- ) : ( - sessions.map((s) => { - const badge = statusBadge(s); - const msg = reopenMsg?.id === s.identity ? reopenMsg : null; - return ( -
- +
+ + + + - {/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An - unpaid transient has no button (no-unpaid-bypass). */} - {s.paidAt || s.subscription ? ( +
+ {filtered.length === 0 ? ( +
+ {isLoading + ? t("common.loading") + : sessions.length === 0 + ? t("booth.noActiveSessions") + : t("booth.noMatch")} +
+ ) : ( + filtered.map((s) => { + const badge = statusBadge(s); + const msg = reopenMsg?.id === s.identity ? reopenMsg : null; + return ( +
- ) : ( - - )} - {msg && ( - - {msg.text} - - )} -
- ); - }) - )} + {/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION + (prepaid). NOT an OVERSTAY session: its grace has expired, so the car + owes a top-up — the row routes to the pay/exit modal instead (no + free overstay exit). An unpaid transient also has no button + (no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */} + {(s.paidAt && !s.overstay) || s.subscription ? ( + + ) : ( + + )} + + {msg && ( + + {msg.text} + + )} +
+ ); + }) + )} +
); diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index a4f0afd..497155c 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -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 />
- {/* 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. */}
- {isSubscription ? t("pay.plan") : t("pay.total")} + {isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")} {isSubscription @@ -249,6 +276,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
)} + {/* For an overstay, explain why a top-up is required (no free exit). */} + {isOverstay && ( +
+ {t("pay.overstayHint")} +
+ )} + {/* Snapshots */} diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index 9499d9c..c358112 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -12,6 +12,7 @@ import { BoothPayModal } from "./BoothPayModal.js"; import { ActiveSessions } from "./ActiveSessions.js"; import { Modal } from "./ui/Modal.js"; import { SnapshotStrip } from "./ui/SnapshotStrip.js"; +import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; import { renderReason } from "./lib/reason.js"; // The live operator booth view — the real-time heart of the console. Occupancy @@ -33,6 +34,27 @@ const EVENT_STYLE: Record = { anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" }, }; +// Live-feed filter category for an event type. Several ledger types collapse into a +// few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the +// filter and only show under "all". +type FeedCat = "entry" | "exit" | "pay" | "void" | "anomaly"; +function feedCat(type: string): FeedCat | null { + switch (type) { + case "vehicle_entry": + return "entry"; + case "vehicle_exit": + return "exit"; + case "payment": + return "pay"; + case "void": + return "void"; + case "anomaly": + return "anomaly"; + default: + return null; + } +} + function hhmmss(iso: string): string { // Local time-of-day, terminal style. Defensive against a bad timestamp. const d = new Date(iso); @@ -372,6 +394,12 @@ export function BoothScreen() { // The ledger event open in the read-only detail modal (null = closed). const [detailEvent, setDetailEvent] = useState(null); + // Live-feed filters: free-text search, event category, and direction/source. + const [feedSearch, setFeedSearch] = useState(""); + const [feedType, setFeedType] = useState(""); + const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">(""); + const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">(""); + // Live overlays from the WS store. const liveOcc = useLiveStore((s) => s.occupancy); const liveFeed = useLiveStore((s) => s.feed); @@ -385,11 +413,45 @@ export function BoothScreen() { const seen = new Set(liveFeed.map((e) => e.id)); const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id)); const merged = [...liveFeed, ...history].slice(0, 200); - const events = + const scoped = shiftOpen && shiftStart ? merged.filter((e) => e.occurredAt >= shiftStart) : []; + // Apply the live-feed filters. Source maps to booth (operator-initiated `manual`) + // vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity, + // subscriber label, and any advisory plate on the payload. + const fq = feedSearch.trim().toLowerCase(); + const events = scoped.filter((e) => { + if (feedType && feedCat(e.type) !== feedType) return false; + if (feedDir && e.direction !== feedDir) return false; + if (feedSrc) { + const isBooth = e.source === "manual"; + if (feedSrc === "booth" ? !isBooth : isBooth) return false; + } + if (fq) { + const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase(); + if (!hay.includes(fq)) return false; + } + return true; + }); + + const feedTypeOpts: SegOption[] = [ + { value: "entry", label: t("booth.fEvtEntry") }, + { value: "exit", label: t("booth.fEvtExit") }, + { value: "pay", label: t("booth.fEvtPay") }, + { value: "void", label: t("booth.fEvtVoid") }, + { value: "anomaly", label: t("booth.fEvtAnomaly") }, + ]; + const feedDirOpts: SegOption<"entry" | "exit">[] = [ + { value: "entry", label: t("booth.fDirEntry") }, + { value: "exit", label: t("booth.fDirExit") }, + ]; + const feedSrcOpts: SegOption<"booth" | "reader">[] = [ + { value: "booth", label: t("booth.fSrcBooth") }, + { value: "reader", label: t("booth.fSrcReader") }, + ]; + return (
{/* Ticket input spans both columns at the top — the operator's primary action. */} @@ -417,19 +479,40 @@ export function BoothScreen() { title={t("booth.liveFeed")} right={ - {events.length} {t("booth.events")} + {events.length} + {events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")} } className="min-h-0" > -
- {!shiftOpen ? ( -
{t("shift.gateTitle")}
- ) : events.length === 0 ? ( -
{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}
- ) : ( - events.map((e) => ) +
+ {shiftOpen && ( + + + + + )} +
+ {!shiftOpen ? ( +
{t("shift.gateTitle")}
+ ) : events.length === 0 ? ( +
+ {eventsQuery.isLoading + ? t("common.loading") + : scoped.length === 0 + ? t("booth.noEventsYet") + : t("booth.noMatch")} +
+ ) : ( + events.map((e) => ) + )} +
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index ca776c0..35021ae 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -694,6 +694,9 @@ export interface SessionLookup { currency: string | null; withinGrace: boolean; graceExpiresAt: string | null; + /** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began; + * owes a fresh top-up (amountMinor); cannot exit for free. */ + overstay: boolean; /** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */ subscription: boolean; subscriptionId: string | null; @@ -717,6 +720,10 @@ export interface ActiveSession { currency: string | null; withinGrace: boolean; graceExpiresAt: string | null; + /** OVERSTAY: paid transient whose walk-back grace lapsed with no signed exit — a new + * period began (re-parked) or the car is faulty/abandoned. Owes a fresh top-up; + * flagged so the operator reconciles, never a free exit. */ + overstay: boolean; /** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */ subscription: boolean; subscriptionId: string | null; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 852d0c7..3c8bfce 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -59,11 +59,11 @@ export const en: Catalog = { devices: { footerTitle: "Devices", none: "No devices configured.", - catAccess: "Barrier", + catAccess: "Relay", catReader: "Reader", catCamera: "Camera", catPrinter: "Printer", - catVision: "Vision", + catVision: "ANPR", // Role/direction suffixes for the chip label (e.g. "Reader entry"). role: { entry: "entry", @@ -101,6 +101,29 @@ export const en: Catalog = { activeSessions: "Active sessions", insideCount: "inside", noActiveSessions: "No active sessions.", + noMatch: "No sessions match the filter.", + badgeOverstay: "overstay", + badgeOverstayTitle: + "Paid session. The customer failed to exit during the grace period. A new period began.", + // filters + filterSearchSessions: "Search ticket / subscriber / plate…", + filterSearchFeed: "Search event / identity / plate…", + filterAll: "All", + fStatusUnpaid: "Unpaid", + fStatusPaid: "Paid", + fStatusExiting: "Exiting", + fStatusOverstay: "Overstay", + fKindTransient: "Transient", + fKindSubscription: "Subscribers", + fDirEntry: "Entry", + fDirExit: "Exit", + fSrcBooth: "Booth", + fSrcReader: "Reader", + fEvtEntry: "Entry", + fEvtExit: "Exit", + fEvtPay: "Pay", + fEvtVoid: "Void", + fEvtAnomaly: "Anomaly", openPayExit: "Open pay / exit", openBarrier: "Open barrier", openBarrierTitle: "Human-intervention barrier open (audited)", @@ -523,6 +546,9 @@ export const en: Catalog = { statusLabel: "Status", paid: "PAID", unpaid: "UNPAID", + overstay: "OVERSTAY", + overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.", + topUp: "New period due", total: "Total", noTariff: "no tariff", tender: "Tender", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 5160df2..3913f15 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -61,11 +61,11 @@ export const sq = { devices: { footerTitle: "Pajisjet", none: "Asnjë pajisje e konfiguruar.", - catAccess: "Barriera", + catAccess: "Rele", catReader: "Lexuesi", catCamera: "Kamera", catPrinter: "Printer", - catVision: "Vizioni", + catVision: "ANPR", // Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje"). role: { entry: "hyrje", @@ -103,6 +103,29 @@ export const sq = { activeSessions: "Sesionet aktive", insideCount: "brenda", noActiveSessions: "Asnjë sesion aktiv.", + noMatch: "Asnjë rezultat për filtrin.", + badgeOverstay: "tej afatit", + badgeOverstayTitle: + "Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.", + // filtra + filterSearchSessions: "Kërko biletë / abonent / targë…", + filterSearchFeed: "Kërko event / identitet / targë…", + filterAll: "Të gjitha", + fStatusUnpaid: "Papaguar", + fStatusPaid: "Paguar", + fStatusExiting: "Duke dalë", + fStatusOverstay: "Tej afatit", + fKindTransient: "Kalimtarë", + fKindSubscription: "Abonentë", + fDirEntry: "Hyrje", + fDirExit: "Dalje", + fSrcBooth: "Kabinë", + fSrcReader: "Lexues", + fEvtEntry: "Hyrje", + fEvtExit: "Dalje", + fEvtPay: "Pagesë", + fEvtVoid: "Anulim", + fEvtAnomaly: "Anomali", openPayExit: "Hap pagesën / daljen", openBarrier: "Hap barrierën", openBarrierTitle: "Hap barrierën manualisht", @@ -537,6 +560,9 @@ export const sq = { statusLabel: "Statusi", paid: "PAGUAR", unpaid: "PAPAGUAR", + overstay: "TEJ AFATIT", + overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.", + topUp: "Periudha e re për pagesë", total: "Totali", noTariff: "pa tarifë", tender: "Mënyra", diff --git a/apps/web/src/ui/FilterBar.tsx b/apps/web/src/ui/FilterBar.tsx new file mode 100644 index 0000000..25fec32 --- /dev/null +++ b/apps/web/src/ui/FilterBar.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; + +// A compact filter toolbar shared by the session list and the live feed: a search +// box plus one or more segmented toggle groups. Purely presentational — each tab +// owns its own filter state and predicates; this just lays the controls out in the +// terminal theme. Kept tiny on purpose (the booth screen is dense). + +export interface SegOption { + readonly value: V; + readonly label: string; +} + +/** A segmented single-select (e.g. status / direction). `value` "" = "all". */ +export function SegGroup({ + value, + options, + onChange, + allLabel, +}: { + value: V | ""; + options: readonly SegOption[]; + onChange: (v: V | "") => void; + allLabel: string; +}) { + const seg = (v: V | "", label: string) => ( + + ); + return ( +
+ {seg("", allLabel)} + {options.map((o) => seg(o.value, o.label))} +
+ ); +} + +export function FilterBar({ + search, + onSearch, + searchPlaceholder, + children, +}: { + search: string; + onSearch: (v: string) => void; + searchPlaceholder: string; + /** Segmented groups (one or more ). */ + children?: ReactNode; +}) { + return ( +
+ onSearch(e.target.value)} + placeholder={searchPlaceholder} + className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[12px] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none" + /> + {children} +
+ ); +} diff --git a/wiki/concepts/booth-exit-flow.md b/wiki/concepts/booth-exit-flow.md index 6921ede..69c7d35 100644 --- a/wiki/concepts/booth-exit-flow.md +++ b/wiki/concepts/booth-exit-flow.md @@ -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`): diff --git a/wiki/log.md b/wiki/log.md index 38f55f1..8432d0b 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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).