import { useState } from "react"; import { useTranslation } from "react-i18next"; import * as Dialog from "@radix-ui/react-dialog"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { boothExit, can, fetchSiteConfig, lookupSession, openShift, paySession, printReceipt, printVoucher, reopenBarrier, voidTicket, type SessionLookup, } from "./api.js"; import { rootRoute } from "./router.js"; import { qk } from "./lib/query.js"; import { useShift } from "./lib/use-shift.js"; import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js"; import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { SnapshotStrip } from "./ui/SnapshotStrip.js"; import { Spinner } from "./ui/Spinner.js"; // The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the // session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes // payment, then EITHER prints an exit voucher (customer self-exits at a distant // exit) OR fires the exit immediately (booth at/near the exit) — controlled by a // checkbox defaulting from site_config.exitVoucherDefault. See booth-exit-flow.md. type Phase = "review" | "paying" | "finishing" | "done" | "error"; export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) { const { t } = useTranslation(); const qc = useQueryClient(); const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) }); const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig }); // A shift must be open (and mine) before any pay/exit/voucher action — the booth // money path is gated. The server enforces this too (409 no_shift); the modal // surfaces it up front and offers a one-click open. See wiki/concepts/shift.md. const { isOpen: shiftOpen, isMine: shiftMine, blockedByOther, heldBy } = useShift(); const shiftReady = shiftOpen && shiftMine; const [tender, setTender] = useState<"cash" | "card">("cash"); const [printVoucherChecked, setPrintVoucherChecked] = useState(null); const [phase, setPhase] = useState("review"); const [error, setError] = useState(null); const [result, setResult] = useState(null); const [openingShift, setOpeningShift] = useState(false); const [reprinting, setReprinting] = useState(false); // For a PREPAID subscriber with nothing owed, the audited manual barrier open // (assist a faulty reader / lost card) is no longer the default action — the // operator reveals it explicitly so the modal isn't an always-on "open" button. const [assistRevealed, setAssistRevealed] = useState(false); // For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay // first, then the modal reveals "Open barrier". This flips true once paid. const [windowPaid, setWindowPaid] = useState(false); // Cancel (void) a wrongly-printed ticket: a small reason prompt, then a signed void. const { user } = rootRoute.useRouteContext(); const canVoid = can(user, "event:void"); const [voiding, setVoiding] = useState(false); // reason prompt revealed // Plate-swap: set when boothExit returns swap_suspected. Holds the detail for the warning // panel; the operator must consciously "Override & release". See plate-reconciliation.md. const [swap, setSwap] = useState<{ plate: string; otherIdentity: string; otherEnteredAt: string | null } | null>(null); const [voidReason, setVoidReason] = useState(""); const s: SessionLookup | undefined = session.data; // Checkbox default comes from config the first time it loads; operator can toggle. const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false; 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; // 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 // when it has an amount due. Otherwise the only action is an audited assist-open. const subWindowDue = !!(isSubscription && (s?.amountMinor ?? 0) > 0); // Allow pay for an unpaid transient, an overstay top-up, or a subscriber window charge. const canPay = !!( shiftReady && s?.found && s.open && ((!alreadyPaid && !isSubscription) || isOverstay || subWindowDue) ); async function handleOpenBarrier() { if (!s) return; setError(null); setPhase("finishing"); try { const r = await reopenBarrier(identity); setResult(r.opened ? t("pay.subBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") })); void qc.invalidateQueries({ queryKey: qk.events }); void qc.invalidateQueries({ queryKey: qk.activeSessions }); setPhase("done"); } catch (e) { setError((e as Error).message); setPhase("error"); } } // Subscriber out-of-window charge: take the payment, but DON'T exit yet. The // barrier open is the operator's explicit second step (so the flow reads: // pay → then Open barrier), mirroring the two-step the operator asked for. async function handlePaySubscriptionWindow() { if (!s) return; setError(null); setPhase("paying"); try { await paySession(identity, tender); setWindowPaid(true); setPhase("review"); void qc.invalidateQueries({ queryKey: ["session", identity] }); void qc.invalidateQueries({ queryKey: qk.events }); } catch (e) { setError((e as Error).message); setPhase("error"); } } async function handleOpenShift() { setOpeningShift(true); setError(null); try { await openShift(); void qc.invalidateQueries({ queryKey: qk.shift }); void qc.invalidateQueries({ queryKey: qk.events }); } catch (e) { setError((e as Error).message); } finally { setOpeningShift(false); } } // A wrongly-printed ticket is cancellable only while it's a TRANSIENT, UNPAID, OPEN // session (a subscription is closed via its own flow; a paid ticket is a refund). The // server enforces all of this too; the UI just hides the action when it can't apply. const canCancel = !!(canVoid && shiftReady && s?.found && s.open && !isSubscription && !alreadyPaid); async function handleVoidTicket() { const reason = voidReason.trim(); if (!reason) return; setError(null); setPhase("finishing"); try { await voidTicket(identity, reason); setResult(t("pay.ticketCancelled")); void qc.invalidateQueries({ queryKey: qk.events }); void qc.invalidateQueries({ queryKey: qk.occupancy }); void qc.invalidateQueries({ queryKey: qk.activeSessions }); setPhase("done"); } catch (e) { setError((e as Error).message); setPhase("error"); } } async function handleReprintReceipt() { setReprinting(true); setError(null); try { const r = await printReceipt(identity); setResult(t("pay.receiptReprinted", { printer: r.printedBy })); } catch (e) { setError((e as Error).message); } finally { setReprinting(false); } } async function handlePayAndExit(override = false) { if (!s) return; setError(null); try { // 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). // On an OVERRIDE re-submit the payment already happened; don't double-charge. if (canPay && !override) { setPhase("paying"); await paySession(identity, tender); } // 2. Voucher OR immediate exit. setPhase("finishing"); if (voucher) { // The voucher slip carries the payment detail + barcode + grace. const r = await printVoucher(identity); setResult(t("pay.voucherPrinted", { printer: r.printedBy })); } else { const r = await boothExit(identity, override); // PLATE-SWAP suspected → don't exit; surface the warning + offer an override. if (!r.ok) { setSwap({ plate: r.plate, otherIdentity: r.otherIdentity, otherEnteredAt: r.otherEnteredAt }); setPhase("review"); return; } setSwap(null); // No voucher → auto-print a standalone payment receipt for transparency. // Best-effort: a printer fault must NOT block the exit that already happened; // the operator can reprint from the done screen. let receiptNote = ""; try { await printReceipt(identity); } catch { receiptNote = ` ${t("pay.receiptPrintFailed")}`; } setResult( (r.opened ? t("pay.paidBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") })) + receiptNote, ); } // Refresh the live views. void qc.invalidateQueries({ queryKey: qk.events }); void qc.invalidateQueries({ queryKey: qk.occupancy }); setPhase("done"); } catch (e) { setError((e as Error).message); setPhase("error"); } } return ( !o && onClose()}>
{isSubscription ? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}` : `${t("pay.ticket")} ${identity}`} ✕
{/* Shift gate — block all actions until THIS operator has a shift open. Another operator's open shift can't be operated under (no shared till); only an "open mine" path when no shift is open at all. */} {!shiftReady && (
{blockedByOther ? ( <>
{t("shift.gateOtherTitle")}
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
) : ( <>
{t("shift.gateTitle")}
{t("shift.gateBody")}
)}
)} {session.isLoading &&
{t("pay.lookingUp")}
} {s && !s.found && (
{t("pay.noSessionFound")}
)} {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: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
{alreadyPaid && s.paidMinor != null && s.paidCurrency && ( )}
)} {s && s.found && (s.open || closedWithinGrace) && ( <> {/* Session figures */}
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
{/* Merchant validations (bar/lavazh): the gross fee + one line per discount — the Total below is the NET the customer pays. The lines ride the quote (SessionLookup.validationLines) and reprint on the receipt. See wiki/concepts/validation-discounts.md. */} {!isSubscription && (s.validationLines ?? []).length > 0 && s.currency != null && s.amountMinor != null && (
{t("val.gross")} {formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
{(s.validationLines ?? []).map((v, i) => (
{v.label} −{formatMoney(v.discountMinor, s.currency!)}
))}
)} {/* Module charges folded into the settlement (e.g. a car wash ordered with "pay at booth") — one "+" line each; the Total below includes them. See wiki/decisions/venue-modules.md. */} {!isSubscription && (s.chargeLines ?? []).length > 0 && s.currency != null && (
{t("booth.charges")}
{(s.chargeLines ?? []).map((c, i) => (
{c.label} +{formatMoney(c.amountMinor, s.currency!)}
))}
)} {/* Total — a subscription is prepaid (no amount) UNLESS it owes an out-of-window window charge; then show that amount. For an overstay the amount is the TOP-UP delta, not the whole stay. */}
{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 ? formatMoney(s.amountMinor, s.currency) : isSubscription ? t("pay.prepaid") : s.amountMinor != null && s.currency ? formatMoney(s.amountMinor, s.currency) : 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")}
{/* Subscription guidance: an unpaid window charge explains the pay-first gate; once paid, prompt the operator to open the barrier; a prepaid subscriber sees the assist explanation only after revealing it. */} {subWindowDue && !windowPaid ? (
{t("pay.windowChargeHint")}
) : isSubscription && windowPaid ? (
{t("pay.windowPaidHint")}
) : isSubscription && assistRevealed ? (
{t("pay.subAssistHint")}
) : null} {/* For an overstay, explain why a top-up is required (no free exit). */} {isOverstay && (
{t("pay.overstayHint")}
)} {/* 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 */} {/* Tender — shown for any payable case (transient, overstay, OR a subscriber window charge that's still unpaid). Card is hidden until a P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED) — see lib/features.ts + wiki/concepts/card-payments.md. */} {phase !== "done" && canPay && !(subWindowDue && windowPaid) && CARD_PAYMENTS_ENABLED && (
{t("pay.tender")} {(["cash", "card"] as const).map((tn) => ( ))}
)} {/* 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 && ( )} {/* Cancel-ticket reason prompt (revealed by the "Cancel ticket" button). A few presets + free text; a reason is REQUIRED. Voiding appends a signed `void` event — the entry is never edited. */} {voiding && phase !== "done" && (
{t("pay.cancelTicketTitle")}
{t("pay.cancelTicketHint")}
{(["misprint", "test", "wrongVehicle"] as const).map((k) => ( ))}
setVoidReason(e.target.value)} placeholder={t("pay.cancelReasonPlaceholder")} />
)} {/* PLATE-SWAP warning: the exiting plate is already inside under another ticket. A prominent, deliberate hold — the operator must consciously override to release. See wiki/concepts/plate-reconciliation.md. */} {swap && (
{t("pay.swapTitle")}
{t("pay.swapBody", { plate: swap.plate, other: swap.otherIdentity, when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—", })}
{t("pay.swapHint")}
)} {error &&
{error}
} {result && (
{result}
)} {/* Actions */}
{phase === "done" ? ( <> {/* Reprint the payment receipt (slip jammed / customer asks). Only for a charged session — a subscription has no payment. */} {!isSubscription && ( )} ) : ( <> {closedWithinGrace ? ( // Paid + exited but the barrier didn't confirm — the only action is // an audited manual re-pulse (the server re-opens without signing a // second exit). No payment, no voucher; mirrors reopenBarrier's guard. ) : isSubscription ? ( subWindowDue && !windowPaid ? ( // Step 1 — a window charge is owed: take payment first. The // barrier open is the explicit next step (revealed once paid). ) : windowPaid || assistRevealed ? ( // The audited barrier open. Shown only AFTER a window charge is // settled, or after the operator explicitly reveals the assist — // never as the default action for a prepaid subscriber. ) : ( // Prepaid, nothing owed: no default open. A small reveal exposes // the audited manual open for a faulty reader / lost card. ) ) : voiding ? ( // Cancel-ticket confirm (reason prompt is shown above). ) : ( <> {/* Cancel a wrongly-printed ticket (transient, unpaid, open only; gated on event:void). Reveals the reason prompt above. */} {canCancel && ( )} {swap ? ( // Plate-swap held → the only forward action is a conscious // override (re-submit with override:true; payment already taken). ) : ( )} )} )}
)}
); } function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) { return (
{label} {value}
); }