diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index e8d87a3..901b046 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -98,6 +98,11 @@ export interface SessionLookup { /** Amount owed right now (the quote). Null when no session / no active tariff. */ readonly amountMinor: number | null; readonly currency: string | null; + /** Amount actually PAID (from the latest payment event), if any. Distinct from + * `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null, + * but the operator still wants to see the sum that was collected. */ + readonly paidMinor: number | null; + readonly paidCurrency: string | null; /** True when paid AND still within the walk-back grace window. */ readonly withinGrace: boolean; /** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */ @@ -274,7 +279,8 @@ export class PayStation { if (!entry) { return { identity: id, found: false, open: false, enteredAt: null, exitedAt: null, - paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null, + paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null, + withinGrace: false, graceExpiresAt: null, overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null, }; } @@ -289,11 +295,17 @@ export class PayStation { let paidAt: string | null = null; let graceExitMin: number | null = null; + let paidMinor: number | null = null; + let paidCurrency: string | null = null; for (const r of rows) { if (r.type === "payment") { paidAt = r.occurredAt; - const p = (r.payload ?? {}) as { graceExitMin?: number }; + const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string }; if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin; + // Sum payments (overstay top-ups append a second one) so the displayed paid total + // reflects everything collected for the session, not just the last slip. + if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor; + if (typeof p.currency === "string") paidCurrency = p.currency; } } const graceExpiresAt = @@ -328,7 +340,7 @@ export class PayStation { return { identity: id, found: true, open, enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null, - paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay, + paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay, subscription: isSubscription, subscriptionId, subscriptionHolder: this.#holderOf(subscriptionId), plate: plateForIdentity(this.#db, id)?.plate ?? null, diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index 8621c49..55999b5 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -1,10 +1,9 @@ -import { useMemo, useState } from "react"; +import { useEffect, 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"; +import { useQuery } from "@tanstack/react-query"; +import { fetchActiveSessions } from "./api.js"; import { qk } from "./lib/query.js"; -import { useShift } from "./lib/use-shift.js"; -import { formatDuration, formatRelativeDateTime } from "./lib/format.js"; +import { formatCountdown, formatDuration, formatRelativeDateTime } from "./lib/format.js"; import { Panel } from "./ui/Panel.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; @@ -12,13 +11,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; // within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed // possibly-present until grace runs out). Lets the operator find a stuck car — // damaged ticket, dead scanner, or a phantom barrier re-close — without a scan: -// - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's -// out-of-window charge, assist-open a prepaid subscriber, or review), -// - "Open barrier" (PAID transient sessions only) → an audited human-intervention -// re-pulse for a car that paid but whose barrier didn't confirm. -// No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get -// NO inline open here — their assist-open / window-charge payment is modal-only, so -// the list can't one-click past an unpaid out-of-window charge. +// click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's +// out-of-window charge, assist-open a prepaid subscriber, or review). // // 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 @@ -30,11 +24,6 @@ type KindFilter = "transient" | "subscription"; export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) { const { t } = useTranslation(); - const qc = useQueryClient(); - // The audited barrier re-open is a money-path action (server-gated on an open - // shift); disable it unless this operator's shift is open. - const { isOpen: shiftOpen, isMine: shiftMine } = useShift(); - const shiftReady = shiftOpen && shiftMine; const { data, isLoading } = useQuery({ queryKey: qk.activeSessions, queryFn: fetchActiveSessions, @@ -43,14 +32,13 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void refetchInterval: 15_000, }); - const reopen = useMutation({ - mutationFn: (identity: string) => reopenBarrier(identity), - onSettled: () => { - void qc.invalidateQueries({ queryKey: qk.activeSessions }); - void qc.invalidateQueries({ queryKey: qk.events }); - }, - }); - const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null); + // A 1-second clock so the within-grace countdown badge ticks live (the query only + // refetches every 15s; the badge needs per-second resolution). + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(id); + }, []); // Filters: free-text search + transient-vs-subscriber. (No status filter — the status // column was dropped; an unpaid transient is normal and a subscriber is marked ★.) @@ -77,20 +65,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void { value: "subscription", label: t("booth.fKindSubscription") }, ]; - async function handleReopen(s: ActiveSession) { - setReopenMsg(null); - try { - const r = await reopen.mutateAsync(s.identity); - setReopenMsg({ - id: s.identity, - ok: r.opened, - text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"), - }); - } catch (e) { - setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message }); - } - } - return ( void : t("booth.noMatch")} ) : ( - // A real table — aligned columns (who · plate · entry · elapsed · action). No - // status column: an unpaid transient is the normal case, and a subscriber is - // already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row - // tint so that fraud-relevant signal isn't lost. The whole row is clickable - // (→ pay/exit modal); the trailing cell holds the audited Open-barrier action. + // A real table — aligned columns (who · plate · entry · elapsed). No status + // column: an unpaid transient is the normal case, and a subscriber is already + // marked with ★ + holder name. Overstay (a top-up is owed) keeps a row tint so + // that fraud-relevant signal isn't lost. The whole row is clickable (→ pay/exit + // modal). @@ -129,31 +103,43 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void - {filtered.map((s) => { - const msg = reopenMsg?.id === s.identity ? reopenMsg : null; - // Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid - // but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and - // NOT a subscription (assist-open lives in the modal). An unpaid transient - // gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard. - const canReopen = s.paidAt && !s.overstay && !s.subscription; + // EXITED-WITHIN-GRACE: a paid transient whose exit is recorded but the + // barrier didn't confirm — it lingers here until grace runs out. Mark it + // so the operator can tell it apart from a still-inside car (clicking it + // opens the modal's manual barrier re-open, not a pay flow). + const closedInGrace = !s.open && s.withinGrace && !s.subscription; + // Live grace-remaining for the badge (M:SS). Null once it lapses — the + // next refetch (≤15s) reclassifies the row (overstay / gone); until then + // we show a generic label so the badge doesn't flicker empty. + const graceLeft = closedInGrace ? formatCountdown(s.graceExpiresAt, nowMs) : null; return ( onPick(s.identity)} className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${ - s.overstay ? "bg-term-red/5" : "" + s.overstay ? "bg-term-red/5" : closedInGrace ? "bg-term-amber/5 text-term-muted" : "" }`} - title={t("booth.openPayExit")} + title={closedInGrace ? t("booth.openReopenBarrier") : t("booth.openPayExit")} > - ); diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 34a658b..b88a979 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -73,6 +73,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose // 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; + // CLOSED-WITHIN-GRACE: a paid transient whose exit was already signed but the barrier + // didn't confirm — it lingers in the active list until grace runs out (the "phantom + // re-close" / damaged-ticket case). `s.open` is false, so it's not payable and not the + // normal review flow; the only action is an audited manual re-pulse of the barrier. + // (A grace-EXPIRED closed session falls through to the plain "already closed" notice.) + const closedWithinGrace = !!(s?.found && !s.open && s.withinGrace && !isSubscription); // A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can // owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns // it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable @@ -278,21 +284,54 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose )} - {s && s.found && !s.open && ( -
- {t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })} -
+ {s && s.found && !s.open && !closedWithinGrace && ( + // A fully-closed session (exited, grace expired): no action to take, but the + // operator may still need to REVIEW the evidence (entry/exit snapshots + plate) + // — e.g. a dispute about a car that just left. Show the closed notice, the + // figures, and the snapshot strip read-only. No tender / voucher / open here. + <> +
+ {t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })} +
+ +
+ + + + {alreadyPaid && s.paidMinor != null && s.paidCurrency && ( + + )} +
+ + + )} - {s && s.found && s.open && ( + {s && s.found && (s.open || closedWithinGrace) && ( <> {/* Session figures */}
- + {/* Closed-within-grace shows the recorded EXIT; an open session shows now. */} +
@@ -322,7 +365,16 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose amount is the TOP-UP delta, not the whole stay. */}
- {subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")} + {subWindowDue + ? t("pay.windowCharge") + : isSubscription + ? t("pay.plan") + : isOverstay + ? t("pay.topUp") + : alreadyPaid && s.paidMinor != null + ? // Settled session — the figure is the sum collected, not a quote. + t("pay.paidAmount") + : t("pay.total")} {subWindowDue && s.amountMinor != null && s.currency @@ -331,9 +383,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose ? t("pay.prepaid") : s.amountMinor != null && s.currency ? formatMoney(s.amountMinor, s.currency) - : alreadyPaid - ? t("booth.badgePaid") - : t("pay.noTariff")} + : alreadyPaid && s.paidMinor != null && s.paidCurrency + ? // Settled (within-grace / closed): show the sum actually collected. + formatMoney(s.paidMinor, s.paidCurrency) + : alreadyPaid + ? t("booth.badgePaid") + : t("pay.noTariff")}
@@ -361,6 +416,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose )} + {/* Closed-within-grace: the exit is already paid + recorded; the barrier + just didn't confirm. Explain that the only action is a manual re-pulse. */} + {closedWithinGrace && ( +
+ {t("pay.closedWithinGraceHint")} +
+ )} + {/* Snapshots */} @@ -382,8 +445,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose )} - {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */} - {phase !== "done" && !isSubscription && ( + {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not + for a closed-within-grace session — its exit is already recorded. */} + {phase !== "done" && !isSubscription && !closedWithinGrace && (
{t("booth.colPlate")} {t("booth.colEntry")} {t("booth.colElapsed")}
{s.subscription ? ( ★ {s.subscriptionHolder ?? t("subs.unnamed")} ) : ( - s.identity + + {s.identity} + {closedInGrace && ( + + {graceLeft ? t("booth.exitedGraceLeft", { time: graceLeft }) : t("booth.exitedGrace")} + + )} + )} @@ -170,28 +156,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void {formatRelativeDateTime(s.enteredAt, t)} - {formatDuration(s.enteredAt, new Date().toISOString())} - - {canReopen && ( - - )} - {msg && ( - - {msg.text} - - )} + {/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */} + {formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}