feat(billing): USD offline bank-transfer payments

This commit is contained in:
Marshal
2026-08-08 13:37:05 +00:00
parent 83b9e32670
commit a42d32c27c
31 changed files with 923 additions and 11 deletions

View File

@@ -0,0 +1,426 @@
import type { Freight } from "@edr/types";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Modal,
SegmentedControl,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, ExternalLink, RefreshCw, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import {
InvoiceStatusBadge,
formatMoney,
humanize,
} from "@/components/customers";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { OfflineUsdInvoice } from "@/types/invoice";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
/**
* The customer's pay window, counted down live. Finance must confirm the bank
* transfer before it closes — past the deadline the booking expires like any
* unpaid one and the API refuses the confirmation.
*/
function formatRemaining(deadlineMs: number, now: number): string | null {
const diff = deadlineMs - now;
if (diff <= 0) return null;
const total = Math.floor(diff / 1000);
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return days > 0
? `${days}d ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!deadline) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [deadline]);
if (!deadline) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
const remaining = formatRemaining(new Date(deadline).getTime(), now);
if (!remaining) {
return (
<Badge color="red" variant="light" radius="sm">
Window closed
</Badge>
);
}
return (
<Text size="sm" fw={600} c="edr-text" ff="monospace">
{remaining}
</Text>
);
}
/** True once the pay window has closed — the API refuses confirmation then. */
function windowClosed(row: OfflineUsdInvoice): boolean {
const deadline = row.booking?.paymentDeadline;
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
}
export default function UsdPaymentsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState("");
const { user } = useAuth();
const canConfirm = hasPermission(
user,
FREIGHT_PERMS.invoices.confirmOffline,
);
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
);
const confirm = useMutation(api.invoices.confirmOffline.mutationOptions());
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const closeConfirm = () => {
setConfirming(null);
setSlip(null);
setReference("");
};
const submitConfirm = async () => {
if (!confirming || !slip) return;
try {
await confirm.mutateAsync({
id: confirming.id,
file: slip,
reference: reference.trim() || undefined,
});
toast.success(`${confirming.invoiceNumber} confirmed as paid`);
closeConfirm();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Confirmation failed");
}
};
const columns: ColumnDef<OfflineUsdInvoice>[] = useMemo(
() => [
{
id: "invoiceNumber",
header: "Invoice",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.invoiceNumber}
</Text>
),
},
{
id: "billedTo",
header: "Customer",
cell: ({ row }) => (
<Text size="sm" c="edr-text">
{row.original.company?.name ?? "—"}
</Text>
),
},
{
id: "booking",
header: "Booking",
cell: ({ row }) => {
const booking = row.original.booking;
if (!booking) {
return (
<Text size="sm" c="dimmed">
{humanize(row.original.source)}
</Text>
);
}
return (
<Button
variant="subtle"
size="compact-sm"
rightSection={<ExternalLink size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${booking.id}`);
}}
>
{booking.reference}
</Button>
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
},
{
id: "amount",
header: "Amount",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{formatMoney(row.original.totalAmount, row.original.currency)}
</Text>
),
},
{
id: "balance",
header: "Balance",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatMoney(row.original.balanceAmount, row.original.currency)}
</Text>
),
},
{
id: "payWindow",
header: "Pay window",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<PayWindowCell deadline={row.original.booking?.paymentDeadline ?? null} />
),
},
{
id: "action",
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => {
const paid = row.original.status === "PAID";
if (paid || !canConfirm) return null;
return (
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={windowClosed(row.original)}
onClick={(e) => {
e.stopPropagation();
setConfirming(row.original);
}}
>
Confirm paid
</Button>
);
},
},
],
[canConfirm, navigate],
);
return (
<PageContainer>
<PageHeader
title="USD Payments"
subtitle="USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by invoice number…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "open"}
onChange={(v) => {
setStatusFilter(
v === "open" ? "" : (v as Freight.InvoiceStatus),
);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "Awaiting payment", value: "open" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1040}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No USD invoices match your search."
: "No USD invoices awaiting confirmation."
}
error={
isError
? {
message: "Failed to load USD invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Box>
</Stack>
</Card>
<Modal
opened={confirming !== null}
onClose={closeConfirm}
title={
<Text fw={700}>Confirm bank transfer payment</Text>
}
radius="md"
size="md"
>
{confirming && (
<Stack gap="md">
<Text size="sm" c="dimmed">
Confirming settles {confirming.invoiceNumber} in full (
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
marks the booking as paid. Upload the customer&apos;s bank slip
first this cannot be undone.
</Text>
<PhasedFileDropzone
label="Bank payment slip"
description="PDF or image of the customer's transfer slip."
value={slip}
onChange={setSlip}
/>
<TextInput
label="Bank reference"
description="Optional — the transfer reference from the slip."
placeholder="e.g. FT24091234567"
value={reference}
onChange={(e) => setReference(e.target.value)}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={closeConfirm}
disabled={confirm.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
loading={confirm.isPending}
disabled={!slip}
leftSection={<CheckCircle2 size={16} />}
onClick={() => void submitConfirm()}
>
Confirm as paid
</Button>
</Group>
</Stack>
)}
</Modal>
</PageContainer>
);
}