diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 6fbb15d62..7abadf82f 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -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 ( - + {label} @@ -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 */} - + <Title + order={1} + fw={800} + fz={26} + style={{ letterSpacing: "-0.01em" }} + > {invoice.invoiceNumber} @@ -193,7 +207,9 @@ export default function InvoiceDetailPage() { size="md" leftSection={} onClick={viewSource} - styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }} + styles={{ + root: { fontWeight: 600, height: 42, paddingInline: 16 }, + }} > View source @@ -204,7 +220,9 @@ export default function InvoiceDetailPage() { size="md" leftSection={} onClick={downloadInvoice} - styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }} + styles={{ + root: { fontWeight: 600, height: 42, paddingInline: 16 }, + }} > Download invoice @@ -216,7 +234,9 @@ export default function InvoiceDetailPage() { size="md" leftSection={} onClick={downloadReceipt} - styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }} + styles={{ + root: { fontWeight: 600, height: 42, paddingInline: 16 }, + }} > Receipt @@ -229,9 +249,12 @@ export default function InvoiceDetailPage() { leftSection={} 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)} )} @@ -241,7 +264,10 @@ export default function InvoiceDetailPage() { - + @@ -249,7 +275,12 @@ export default function InvoiceDetailPage() { - + Total @@ -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={ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 7c2c2631c..9dbb9ed10 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -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; }, }); diff --git a/apps/edr-freight-web/portal/src/services/payments.service.ts b/apps/edr-freight-web/portal/src/services/payments.service.ts index 119aea4ed..c62e31108 100644 --- a/apps/edr-freight-web/portal/src/services/payments.service.ts +++ b/apps/edr-freight-web/portal/src/services/payments.service.ts @@ -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, };