mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix: invoice based payment on the client
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user