fix: invoice based payment on the client

This commit is contained in:
Nathnael
2026-07-02 09:22:07 +00:00
parent 1c012ce1e2
commit 602453ff7a
3 changed files with 116 additions and 36 deletions

View File

@@ -15,13 +15,22 @@ import {
Text,
Title,
} from "@mantine/core";
import { ArrowLeft, CreditCard, Download, ExternalLink, Receipt } from "lucide-react";
import {
ArrowLeft,
CreditCard,
Download,
ExternalLink,
Receipt,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { saveBlob } from "@/utils/download";
@@ -38,7 +47,12 @@ import {
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<Box>
<Text fz={11} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
<Text
fz={11}
fw={700}
c={MUTED}
style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}
>
{label}
</Text>
<Text fz={14} mt={4} style={{ color: INK }}>
@@ -52,30 +66,25 @@ export default function InvoiceDetailPage() {
const { id = "" } = useParams();
const navigate = useNavigate();
const { data: invoice, isLoading, isError } = useQuery(
api.invoices.get.queryOptions({ input: { id } }),
);
const {
data: invoice,
isLoading,
isError,
} = useQuery(api.invoices.get.queryOptions({ input: { id } }));
const [payModalOpen, setPayModalOpen] = useState(false);
// Extracted for payMutation callbacks — guaranteed defined when they run
// (guarded by the early return below).
const invSource = invoice?.source;
const invSourceId = invoice?.sourceId;
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
// one of the signed-in customer's own invoices (unlike the admin-facing
// /payments/initiate, which takes any invoiceId with no ownership check).
const payMutation = useMutation({
mutationFn: async (method: PaymentMethod) => {
const bookingId =
invSource === "warehouse"
? (await warehouseInvoicesService.get(id)).bookingId ?? invSourceId!
: invSourceId!;
return api.payments.initiate.call({ bookingId, method });
},
mutationFn: (method: PaymentMethod) =>
api.invoices.pay.call({ id, payload: { method, platform: "web" } }),
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId: invSourceId!, method });
: paymentsService.checkoutUrlForInvoice({ invoiceId: id, method });
window.location.href = redirectUrl;
},
});
@@ -180,7 +189,12 @@ export default function InvoiceDetailPage() {
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap={12} align="center" wrap="wrap">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
{invoice.invoiceNumber}
</Title>
<InvoiceStatusBadge status={invoice.status} />
@@ -193,7 +207,9 @@ export default function InvoiceDetailPage() {
size="md"
leftSection={<ExternalLink size={16} />}
onClick={viewSource}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
styles={{
root: { fontWeight: 600, height: 42, paddingInline: 16 },
}}
>
View source
</Button>
@@ -204,7 +220,9 @@ export default function InvoiceDetailPage() {
size="md"
leftSection={<Download size={16} />}
onClick={downloadInvoice}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
styles={{
root: { fontWeight: 600, height: 42, paddingInline: 16 },
}}
>
Download invoice
</Button>
@@ -216,7 +234,9 @@ export default function InvoiceDetailPage() {
size="md"
leftSection={<Receipt size={16} />}
onClick={downloadReceipt}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
styles={{
root: { fontWeight: 600, height: 42, paddingInline: 16 },
}}
>
Receipt
</Button>
@@ -229,9 +249,12 @@ export default function InvoiceDetailPage() {
leftSection={<CreditCard size={16} />}
loading={payMutation.isPending}
onClick={handlePay}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
styles={{
root: { fontWeight: 600, height: 42, paddingInline: 18 },
}}
>
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
Pay{" "}
{formatCurrency(Number(invoice.totalAmount), invoice.currency)}
</Button>
)}
</Group>
@@ -241,7 +264,10 @@ export default function InvoiceDetailPage() {
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
<MetaItem label="Billed To" value={billedTo(invoice)} />
<MetaItem label="Source" value={`${titleCase(invoice.source)} · ${invoice.type}`} />
<MetaItem
label="Source"
value={`${titleCase(invoice.source)} · ${invoice.type}`}
/>
<MetaItem label="Issued" value={fmtDate(invoice.issuedAt)} />
<MetaItem label="Due" value={fmtDate(invoice.dueAt)} />
</SimpleGrid>
@@ -249,7 +275,12 @@ export default function InvoiceDetailPage() {
<Divider my="lg" color={BORDER} />
<Group justify="space-between" align="center">
<Text fz={14} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
<Text
fz={14}
fw={700}
c={MUTED}
style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}
>
Total
</Text>
<Text fz={24} fw={800} style={{ color: INK }}>
@@ -349,7 +380,10 @@ export default function InvoiceDetailPage() {
payMutation.reset();
}
}}
amountLabel={formatCurrency(Number(invoice.totalAmount), invoice.currency)}
amountLabel={formatCurrency(
Number(invoice.totalAmount),
invoice.currency,
)}
currency={invoice.currency}
processing={payMutation.isPending}
error={

View File

@@ -1,5 +1,5 @@
import { Box, Group, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, Download, Eye } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -8,7 +8,9 @@ import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import { invoicesService } from "@/services/invoices.service";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { isPayable } from "@/pages/billing/invoice-ui";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -51,17 +53,41 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
: "/contracts/new";
const onRebook = () => navigate(rebookTo);
// POST /payments/initiate creates the intent and returns the provider's
// redirect URL (clientAction.url). Send the browser straight there; fall back
// to the public /payments/checkout page if no redirect URL came back.
// Billing is invoice-centric — resolve the booking's currently payable
// invoice (same query/key BookingPaymentPanel uses, so this shares its
// cache) and pay it through the ownership-checked portal route.
const { data: bookingInvoices = [] } = useQuery({
queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id),
});
const payableInvoiceId = bookingInvoices.find((inv) =>
isPayable(inv.status),
)?.id;
// POST /billing/my-invoices/:id/pay creates the intent and returns the
// provider's redirect URL (clientAction.url). Send the browser straight
// there; fall back to the public /payments/checkout page if no redirect
// URL came back.
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) =>
api.payments.initiate.call({ bookingId: booking.id, method }),
mutationFn: (method: PaymentMethod) => {
if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId: booking.id, method });
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = redirectUrl;
},
});

View File

@@ -17,7 +17,7 @@ export type PaymentMethod =
export type PaymentPlatform = "web" | "mobile";
export interface InitiatePaymentPayload {
bookingId: string;
invoiceId: string;
method: PaymentMethod;
platform?: PaymentPlatform;
payerAccount?: string;
@@ -67,6 +67,25 @@ function buildCheckoutUrl(payload: {
return `${base}${P.CHECKOUT}?${params.toString()}`;
}
/**
* Checkout fallback keyed by invoice id — matches `GET /payments/checkout`,
* which reads `invoiceId` (billing is invoice-centric; there is no
* `bookingId` param on that route).
*/
function buildCheckoutUrlForInvoice(payload: {
invoiceId: string;
method: PaymentMethod;
platform?: PaymentPlatform;
}): string {
const base = API_BASE_URL.replace(/\/$/, "");
const params = new URLSearchParams({
invoiceId: payload.invoiceId,
method: payload.method,
platform: payload.platform ?? "web",
});
return `${base}${P.CHECKOUT}?${params.toString()}`;
}
export const paymentsService = {
initiate: async (
payload: InitiatePaymentPayload,
@@ -84,4 +103,5 @@ export const paymentsService = {
},
checkoutUrl: buildCheckoutUrl,
checkoutUrlForInvoice: buildCheckoutUrlForInvoice,
};