diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index e7682879b..dbf2bd4fd 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => { expect(manager.update).not.toHaveBeenCalled(); }); }); + +describe("BillingService — CAC Bank (OTP debit)", () => { + const openInvoice = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "PREPAID", + invoiceNumber: "INV-20260101-00001", + currency: "USD", + balanceAmount: 500, + totalAmount: 500, + paymentId: "intent-1", + dueAt: null, + }; + + const build = (payment: Record) => { + const repo = { + findOne: jest.fn().mockResolvedValue(openInvoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService( + { getRepository: () => repo } as never, + {} as never, + {} as never, + makeEvents() as never, + payment as never, + {} as never, + {} as never, + ); + return { service, repo }; + }; + + it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => { + const initiate = jest.fn(); + const { service } = build({ initiate }); + + await expect( + service.payInvoice("inv-1", { method: "CAC_BANK" }), + ).rejects.toThrow(/payerAccount/); + expect(initiate).not.toHaveBeenCalled(); + }); + + it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => { + const handlePaymentEvent = jest.fn(); + const { service } = build({ + initiate: jest.fn().mockResolvedValue({ + intentId: "intent-1", + immediateSuccess: false, + response: { + intentId: "intent-1", + status: "REQUIRES_ACTION", + clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" }, + }, + }), + handlePaymentEvent, + }); + + await service.payInvoice("inv-1", { + method: "CAC_BANK", + payerAccount: "77123456", + }); + + expect(handlePaymentEvent).not.toHaveBeenCalled(); + }); + + it("confirms the OTP against the intent stamped on the invoice", async () => { + const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" }); + const { service } = build({ confirmOtp }); + + await service.confirmInvoiceOtp("inv-1", "123456"); + + expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ca43295d1..525ccfefc 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm"; import { CompaniesService } from "../companies/companies.service"; import { PaymentService } from "../payment/payment.service"; -import { InitiateResponseDto } from "../payment/payments.dto"; +import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, @@ -352,6 +352,34 @@ export class BillingService { return this.payInvoice(id, opts); } + /** + * Submit the CAC Bank OTP for one of the customer's own invoices + * (ownership-checked). Settlement of the invoice happens inside the payment + * service when the OTP succeeds. + */ + async confirmInvoiceOtpForUser( + id: string, + userId: string, + otp: string, + ): Promise { + await this.findByIdForUser(id, userId); + return this.confirmInvoiceOtp(id, otp); + } + + /** OTP confirmation by invoice id — the intent is the one stamped at initiate. */ + async confirmInvoiceOtp( + invoiceId: string, + otp: string, + ): Promise { + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: invoiceId } }); + if (!invoice?.paymentId) { + throw new NotFoundException("No payment to confirm for this invoice"); + } + return this.payment.confirmOtp(invoice.paymentId, otp); + } + /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ async documentForUser( id: string, @@ -1035,6 +1063,17 @@ export class BillingService { throw new BadRequestException("Invoice has no outstanding balance."); } + // CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is + // required up front (the payment service rejects it otherwise, as a 502 here). + if ( + (opts.method ?? "").toUpperCase() === "CAC_BANK" && + !opts.payerAccount?.trim() + ) { + throw new BadRequestException( + "payerAccount (mobile number) is required for CAC Bank", + ); + } + const result = await this.payment.initiate({ referenceId: invoice.sourceId, source: invoice.source, @@ -1062,7 +1101,12 @@ export class BillingService { // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept commented for local demos only. - if (!result.immediateSuccess) { + // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the + // code — so the demo shortcut must never fire for it. + if ( + !result.immediateSuccess && + result.response.clientAction?.type !== "COLLECT_OTP" + ) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", eventId: `demo-${result.intentId}`, diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts index c29160ab7..354dd004e 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts @@ -1,5 +1,13 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsOptional, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */ +export class ConfirmOtpDto { + @ApiProperty({ description: "One-time password SMSed by the bank." }) + @IsString() + @IsNotEmpty() + otp!: string; +} /** Gateway options for paying an invoice from the customer portal. */ export class PayInvoiceDto { diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 94e917754..981233df0 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -18,7 +18,7 @@ import { } from "../../common/resolve-auth-user-id"; import { sendPdf } from "./billing.controller"; import { BillingService } from "./billing.service"; -import { PayInvoiceDto } from "./dto/pay-invoice.dto"; +import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto"; /** * Customer-facing billing endpoints. Unlike {@link BillingController} (admin, @@ -96,4 +96,20 @@ export class PortalBillingController { failureUrl: dto.failureUrl, }); } + + @Post("my-invoices/:id/confirm") + @ApiOperation({ + summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", + }) + confirmOtp( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: ConfirmOtpDto, + ) { + return this.billingService.confirmInvoiceOtpForUser( + id, + resolveAuthUserId(user), + dto.otp, + ); + } } diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 81d1a2a0d..c0fc6fc09 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -1,4 +1,9 @@ -import { BadGatewayException, Injectable, Logger } from "@nestjs/common"; +import { + BadGatewayException, + BadRequestException, + Injectable, + Logger, +} from "@nestjs/common"; import { HttpService } from "@nestjs/axios"; import { AxiosError } from "axios"; import { firstValueFrom } from "rxjs"; @@ -67,6 +72,33 @@ export class PaymentClientService { } } + /** + * POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider + * (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service; + * surface that as a BadRequest (retryable) rather than a 502, so the payer can + * re-enter the code. + */ + async confirmOtp(intentId: string, otp: string): Promise { + try { + return await this.call( + "POST", + `/payments/intents/${intentId}/confirm`, + { otp }, + ); + } catch (err) { + // `call` re-throws raw 404s and masks every other 4xx as BadGateway; an + // unknown intent or a bad OTP is client-fixable, so translate both to 400. + if (err instanceof AxiosError && err.response?.status === 404) { + throw new BadRequestException("PaymentIntent not found"); + } + if (err instanceof BadGatewayException) { + const detail = err.message.replace(/^Payment service error: /, ""); + throw new BadRequestException(detail); + } + throw err; + } + } + private async call(method: "GET" | "POST", path: string, body?: unknown): Promise { const url = `${this.baseUrl}${path}`; try { diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 6cb359dba..0c166d164 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -374,6 +374,53 @@ export class PaymentService { return this.formatIntentStatus(refreshed ?? local); } + /** + * Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent + * id (the invoice's `paymentId`) so the right invoice settles even when several + * invoices share a domain reference. The active gateway intent is looked up by + * reference, the OTP is forwarded, and the projection is refreshed. On success + * billing settles the linked invoice (idempotent — the outbox path converges too). + * A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry. + */ + async confirmOtp(intentId: string, otp: string): Promise { + const local = await this.paymentRepo.findOneBy({ id: intentId }); + if (!local) throw new NotFoundException("PaymentIntent not found"); + + const snapshot = await this.paymentClient.getIntentByReference( + (local.referenceType as PaymentReferenceType) ?? + PaymentReferenceType.SHIPMENT, + local.refId, + ); + if (!snapshot) { + throw new NotFoundException("No active payment to confirm"); + } + + const confirmed = await this.paymentClient.confirmOtp( + snapshot.intentId, + otp, + ); + + if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markIntentSucceeded(local.id, { + providerTxnId: confirmed.providerTxnId, + paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined, + notify: true, + }); + } else { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(confirmed.status), + failerCode: confirmed.failureCode ?? undefined, + failureMessage: confirmed.failureMessage ?? undefined, + }, + ); + } + + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); + } + /** * Mark a gateway intent paid and (by default) notify billing to settle the * linked invoice. Idempotent — no-op when already success. Pass `notify: false` diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index c6544c072..928281042 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -201,6 +201,8 @@ export const URL_CONSTANTS = { MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`, MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`, PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`, + CONFIRM_INVOICE_OTP: (id: string) => + `/api/billing/my-invoices/${id}/confirm`, }, WAREHOUSE_INVOICES: { diff --git a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts new file mode 100644 index 000000000..04c91a683 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts @@ -0,0 +1,115 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { useState } from "react"; + +import { invoicesService } from "@/services/invoices.service"; +import { + paymentsService, + type InitiateResponse, + type PaymentMethod, +} from "@/services/payments.service"; + +/** How the invoice is charged — overridable for warehouse fee invoices. */ +type InitiateFn = ( + invoiceId: string, + method: PaymentMethod, + payerAccount?: string, +) => Promise; + +const payViaBilling: InitiateFn = (invoiceId, method, payerAccount) => + invoicesService.pay(invoiceId, { method, platform: "web", payerAccount }); + +/** The server's message (`{ message }` / `{ message: [] }`), or a fallback. */ +function apiMessage(err: unknown, fallback: string): string { + const message = (err as AxiosError<{ message?: string | string[] }>)?.response + ?.data?.message; + const first = Array.isArray(message) ? message[0] : message; + return first || fallback; +} + +/** + * One payment flow for every "pay this invoice" entry point: initiate, then + * either redirect to the provider or — for CAC Bank, an OTP debit with no + * redirect — collect the SMS'd code and confirm it in-app. Pass `initiate` to + * charge through a different endpoint (warehouse fee invoices); OTP + * confirmation always goes through billing, which owns the intent either way. + */ +export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { + const [otpInvoiceId, setOtpInvoiceId] = useState(null); + const [otpMessage, setOtpMessage] = useState(); + + const payMutation = useMutation({ + mutationFn: (vars: { + invoiceId: string; + method: PaymentMethod; + payerAccount?: string; + }) => initiate(vars.invoiceId, vars.method, vars.payerAccount), + onSuccess: (data, vars) => { + if (data?.clientAction?.type === "COLLECT_OTP") { + setOtpMessage( + data.clientAction.message ?? "Enter the OTP sent to your phone", + ); + setOtpInvoiceId(vars.invoiceId); + return; + } + window.location.href = + data?.clientAction?.type === "REDIRECT" && data.clientAction.url + ? data.clientAction.url + : paymentsService.checkoutUrlForInvoice({ + invoiceId: vars.invoiceId, + method: vars.method, + }); + }, + }); + + const otpMutation = useMutation({ + mutationFn: (otp: string) => + invoicesService.confirmOtp(otpInvoiceId as string, otp), + // Settled — reload so the invoice/booking re-reads its now-paid state. + onSuccess: () => { + setOtpInvoiceId(null); + window.location.reload(); + }, + }); + + const reset = () => { + payMutation.reset(); + otpMutation.reset(); + setOtpInvoiceId(null); + }; + + return { + processing: payMutation.isPending, + error: payMutation.isError + ? apiMessage( + payMutation.error, + payMutation.error instanceof Error + ? payMutation.error.message + : "Could not start payment. Please try again.", + ) + : null, + pay: (invoiceId: string, method: PaymentMethod, payerAccount?: string) => + payMutation.mutate({ invoiceId, method, payerAccount }), + reset, + /** Drives the modal's OTP step; `open` only for CAC Bank. */ + otp: { + open: otpInvoiceId !== null, + message: otpMessage, + submitting: otpMutation.isPending, + // A wrong/expired OTP is a 400 — keep the step open so the payer retries. + error: otpMutation.isError + ? apiMessage( + otpMutation.error, + "Invalid or expired OTP. Please try again.", + ) + : null, + submit: (otp: string) => otpMutation.mutate(otp), + cancel: () => { + otpMutation.reset(); + setOtpInvoiceId(null); + }, + }, + }; +} + +export type InvoicePaymentFlow = ReturnType; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx index ecd3d7df2..9581a4f3d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core"; import { AlertTriangle, @@ -10,9 +10,8 @@ import { PackagePlus, } from "lucide-react"; -import { api } from "@/services/api"; import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper"; -import { paymentsService, type PaymentMethod } from "@/services/payments.service"; +import { useInvoicePayment } from "@/hooks/useInvoicePayment"; import { invoicesService } from "@/services/invoices.service"; import { isPayable } from "@/pages/billing/invoice-ui"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; @@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { isPayable(inv.status), )?.id; - const payMutation = useMutation({ - 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 url = - data?.clientAction?.type === "REDIRECT" && data.clientAction.url - ? data.clientAction.url - : paymentsService.checkoutUrlForInvoice({ - invoiceId: payableInvoiceId!, - method, - }); - window.location.href = url; - }, - }); + const pay = useInvoicePayment(); if (items.length === 0) return null; @@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { { - if (!payMutation.isPending) { + if (!pay.processing) { setPayItem(null); - payMutation.reset(); + pay.reset(); } }} currency={undefined} - processing={payMutation.isPending} + processing={pay.processing} error={ - payMutation.isError - ? payMutation.error instanceof Error - ? payMutation.error.message - : "Could not start payment. Please try again." - : null + pay.error ?? + (payItemInvoices.length > 0 && !payableInvoiceId + ? "No payable invoice found for this booking yet. Please refresh or contact support." + : null) + } + otp={pay.otp} + onConfirm={(method, payerAccount) => + payableInvoiceId && + pay.pay(payableInvoiceId, method, payerAccount) } - onConfirm={(method) => payMutation.mutate(method)} /> 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 8c0bbb884..30cb1597d 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Alert, Box, @@ -27,10 +27,7 @@ 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 { useInvoicePayment } from "@/hooks/useInvoicePayment"; import { warehouseInvoicesService } from "@/services/warehouse-invoices.service"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; import { saveBlob } from "@/utils/download"; @@ -76,17 +73,7 @@ export default function InvoiceDetailPage() { // 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: (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.checkoutUrlForInvoice({ invoiceId: id, method }); - window.location.href = redirectUrl; - }, - }); + const pay = useInvoicePayment(); if (isLoading) { return ( @@ -249,7 +236,7 @@ export default function InvoiceDetailPage() { radius="md" size="md" leftSection={} - loading={payMutation.isPending} + loading={pay.processing} onClick={handlePay} styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 }, @@ -376,22 +363,19 @@ export default function InvoiceDetailPage() { { - if (!payMutation.isPending) { + if (!pay.processing) { setPayModalOpen(false); - payMutation.reset(); + pay.reset(); } }} amountLabel={formatCurrency(amountDue, invoice.currency)} currency={invoice.currency} - processing={payMutation.isPending} - error={ - payMutation.isError - ? payMutation.error instanceof Error - ? payMutation.error.message - : "Could not start payment. Please try again." - : null + processing={pay.processing} + error={pay.error} + otp={pay.otp} + onConfirm={(method, payerAccount) => + pay.pay(id, method, payerAccount) } - onConfirm={(method) => payMutation.mutate(method)} /> 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 9d9116bab..e04b8f2cd 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,14 +1,8 @@ import { Group, Tabs } from "@mantine/core"; -import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, FileText, LayoutGrid } from "lucide-react"; -import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { api } from "@/services/api"; 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"; @@ -41,6 +35,7 @@ import { StatusHero } from "./components/StatusHero"; import { SupportCard } from "./components/SupportCard"; import { fmtDate, isNegative, priceTotal } from "./utils"; import { useScrollToHash } from "@/hooks/useScrollToHash"; +import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment"; export function ReadonlyBookingView({ booking, @@ -53,7 +48,6 @@ export function ReadonlyBookingView({ // Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice. useScrollToHash(); const status = booking.status as string; - const [payModalOpen, setPayModalOpen] = useState(false); const { viewer } = useFileViewer(); // Re-book opens the New Shipment Booking form for the same contract, not the @@ -63,44 +57,11 @@ export function ReadonlyBookingView({ : "/contracts/new"; const onRebook = () => navigate(rebookTo); - // 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) => { - 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.checkoutUrlForInvoice({ - invoiceId: payableInvoiceId!, - method, - }); - window.location.href = redirectUrl; - }, - }); + // Billing is invoice-centric — the shared hook resolves the booking's + // currently payable invoice (same query/key BookingPaymentPanel uses, so it + // shares that cache), charges it through the ownership-checked portal route, + // and handles redirect vs CAC Bank OTP. + const pay = useBookingPayment(booking.id); const pricing = booking.pricingBreakdown; // A general contract is paid once it's FULLY_EXECUTED (signed) — it never @@ -171,7 +132,7 @@ export function ReadonlyBookingView({ green icon={} label="Pay now" - onClick={() => setPayModalOpen(true)} + onClick={pay.open} /> )} @@ -278,8 +239,8 @@ export function ReadonlyBookingView({ setPayModalOpen(true)} - paying={payMutation.isPending} + onPay={pay.open} + paying={pay.processing} showCountdown={showCountdown} /> { - if (!payMutation.isPending) { - setPayModalOpen(false); - payMutation.reset(); - } - }} + opened={pay.modalOpen} + onClose={pay.close} amountLabel={pricing ? priceTotal(pricing) : undefined} currency={pricing?.currency ?? booking.paymentCurrency} - processing={payMutation.isPending} - error={ - payMutation.isError - ? payMutation.error instanceof Error - ? payMutation.error.message - : "Could not start payment. Please try again." - : null - } - onConfirm={(method) => payMutation.mutate(method)} + processing={pay.processing} + error={pay.error} + otp={pay.otp} + onConfirm={pay.confirm} /> {viewer} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 6e62888c8..0cc6d7bff 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -1,20 +1,32 @@ -import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core"; -import { Check, ShieldCheck } from "lucide-react"; +import { + Box, + Button, + Group, + Image, + Modal, + PinInput, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { Check, Landmark, ShieldCheck } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment"; import type { PaymentMethod } from "@/services/payments.service"; interface ProviderOption { method: PaymentMethod; label: string; description: string; - logo: string; + /** Logo asset; falls back to a bank glyph when the provider has none. */ + logo?: string; /** Currencies this provider settles in. */ currencies: string[]; accent: string; } -// Only Telebirr and Waafi are enabled for now. +// Only Telebirr, Waafi and CAC Bank are enabled for now. const PROVIDERS: ProviderOption[] = [ { method: "TELEBIRR", @@ -32,8 +44,18 @@ const PROVIDERS: ProviderOption[] = [ currencies: ["USD"], accent: "#2E5B96", }, + { + method: "CAC_BANK", + label: "CAC Bank", + description: "Djibouti bank debit · confirmed by SMS OTP", + currencies: ["USD"], + accent: "#8A5A17", + }, ]; +/** Providers that debit against an SMS OTP instead of redirecting to a page. */ +const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK"; + /** * Pick the provider that settles in the booking's currency. USD → Waafi, * ETB → Telebirr. Falls back to the first provider when unknown. @@ -91,13 +113,27 @@ function ProviderRow({ backgroundColor: "#fff", }} > - {`${option.label} + {option.logo ? ( + {`${option.label} + ) : ( + + + + )} @@ -135,6 +171,7 @@ export function PaymentMethodModal({ onConfirm, processing, error, + otp, }: { opened: boolean; onClose: () => void; @@ -142,12 +179,19 @@ export function PaymentMethodModal({ amountLabel?: string; /** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */ currency?: string | null; - onConfirm: (method: PaymentMethod) => void; + onConfirm: (method: PaymentMethod, payerAccount?: string) => void; processing?: boolean; error?: string | null; + /** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */ + otp?: InvoicePaymentFlow["otp"]; }) { - const providers = useMemo(() => providersForCurrency(currency), [currency]); + const providers = useMemo( + () => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)), + [currency, otp], + ); const [method, setMethod] = useState(providers[0].method); + const [mobile, setMobile] = useState(""); + const [code, setCode] = useState(""); // Keep the selection valid when the currency (and therefore provider list) changes. useEffect(() => { @@ -156,6 +200,89 @@ export function PaymentMethodModal({ } }, [providers, method]); + // A fresh OTP round always starts empty. + useEffect(() => { + if (otp?.open) setCode(""); + }, [otp?.open]); + + // CAC Bank debits the account behind this number and SMSes the OTP to it. + const needsMobile = isOtpMethod(method); + const canSubmit = !needsMobile || mobile.trim().length > 0; + + if (otp?.open) { + return ( + + + + Enter OTP + + + {otp.message} + + + + otp.submit(value)} + aria-label="One-time password" + /> + + + {otp.error && ( + + {otp.error} + + )} + + + + + + + + ); + } + return ( ))} + + {needsMobile && ( + setMobile(e.currentTarget.value)} + disabled={processing} + styles={{ + label: { fontSize: 12.5, fontWeight: 700, color: "#10202F" }, + description: { fontSize: 11.5 }, + }} + /> + )} {/* Footer */} @@ -228,7 +371,9 @@ export function PaymentMethodModal({ - Secured · you'll be redirected to your provider to pay + {needsMobile + ? "Secured · you'll confirm with the OTP sent to your phone" + : "Secured · you'll be redirected to your provider to pay"} @@ -248,15 +393,21 @@ export function PaymentMethodModal({ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index 927f1dbfb..a146dfb11 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -1,10 +1,10 @@ import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { CreditCard, Download, FileText, Receipt } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; -import { paymentsService, type PaymentMethod } from "@/services/payments.service"; +import { useInvoicePayment } from "@/hooks/useInvoicePayment"; import { warehouseInvoicesService, type PortalWarehouseInvoice, @@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { const [payInvoice, setPayInvoice] = useState(null); - const payMutation = useMutation({ - mutationFn: (method: PaymentMethod) => { - if (!payInvoice) throw new Error("No invoice selected for payment."); - return warehouseInvoicesService.payOnline(payInvoice.id, { - method, - platform: "web", - }); - }, - onSuccess: (data, method) => { - if (!payInvoice) return; - // Redirect to the provider (or the fallback checkout page) — same as the - // booking "Pay now" flow, so behaviour is identical everywhere. - const redirectUrl = - data?.clientAction?.type === "REDIRECT" && data.clientAction.url - ? data.clientAction.url - : paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method }); - window.location.href = redirectUrl; - }, - }); - - const payError = payMutation.isError - ? payMutation.error instanceof Error - ? payMutation.error.message - : "Could not start payment. Please try again." - : null; + // Warehouse fees are charged through the warehouse route, but they are the + // same central invoices — so redirect vs CAC Bank OTP is the shared flow. + const pay = useInvoicePayment((invoiceId, method, payerAccount) => + warehouseInvoicesService.payOnline(invoiceId, { + method, + platform: "web", + payerAccount, + }), + ); const closePayModal = () => { - if (!payMutation.isPending) { + if (!pay.processing) { setPayInvoice(null); - payMutation.reset(); + pay.reset(); } }; @@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined } currency={payInvoice?.currency} - onConfirm={(method) => payMutation.mutate(method)} - processing={payMutation.isPending} - error={payError} + onConfirm={(method, payerAccount) => + payInvoice && pay.pay(payInvoice.id, method, payerAccount) + } + processing={pay.processing} + error={pay.error} + otp={pay.otp} /> ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx index fca38aec7..7a9949e8a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx @@ -55,6 +55,7 @@ export function PayNowButton({ currency={pricing?.currency ?? booking.paymentCurrency} processing={pay.processing} error={pay.error} + otp={pay.otp} onConfirm={pay.confirm} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts index 9725fea12..bb1bd33ab 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts @@ -1,23 +1,22 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; -import { api } from "@/services/api"; -import { - paymentsService, - type PaymentMethod, -} from "@/services/payments.service"; +import { useInvoicePayment } from "@/hooks/useInvoicePayment"; +import { type PaymentMethod } from "@/services/payments.service"; import { invoicesService } from "@/services/invoices.service"; import { isPayable } from "@/pages/billing/invoice-ui"; /** * Shared payment flow for a single booking: opens the method modal, fires * POST /billing/my-invoices/:id/pay for the booking's currently payable - * invoice, and redirects the browser to the provider (or the fallback - * checkout page). Reused by the booking detail page, the booking list, and - * the home page so "Pay now" behaves identically everywhere. + * invoice, and redirects the browser to the provider (or, for CAC Bank, an + * OTP debit with no redirect, collects the SMS'd code in the modal). Reused by + * the booking detail page, the booking list, and the home page so "Pay now" + * behaves identically everywhere. */ export function useBookingPayment(bookingId: string) { const [modalOpen, setModalOpen] = useState(false); + const [noInvoice, setNoInvoice] = useState(false); const { data: invoices = [] } = useQuery({ queryKey: ["booking-invoices", bookingId], @@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) { }); const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id; - const mutation = useMutation({ - 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.checkoutUrlForInvoice({ - invoiceId: payableInvoiceId!, - method, - }); - window.location.href = redirectUrl; - }, - }); + const flow = useInvoicePayment(); const open = () => setModalOpen(true); const close = () => { - if (!mutation.isPending) { + if (!flow.processing) { setModalOpen(false); - mutation.reset(); + setNoInvoice(false); + flow.reset(); } }; - const error = mutation.isError - ? mutation.error instanceof Error - ? mutation.error.message - : "Could not start payment. Please try again." - : null; - return { modalOpen, open, close, - processing: mutation.isPending, - error, - confirm: (method: PaymentMethod) => mutation.mutate(method), + processing: flow.processing, + error: noInvoice + ? "No payable invoice found for this booking yet. Please refresh or contact support." + : flow.error, + otp: flow.otp, + confirm: (method: PaymentMethod, payerAccount?: string) => { + if (!payableInvoiceId) { + setNoInvoice(true); + return; + } + setNoInvoice(false); + flow.pay(payableInvoiceId, method, payerAccount); + }, }; } diff --git a/apps/edr-freight-web/portal/src/services/invoices.service.ts b/apps/edr-freight-web/portal/src/services/invoices.service.ts index 2ee18ec4e..6be426390 100644 --- a/apps/edr-freight-web/portal/src/services/invoices.service.ts +++ b/apps/edr-freight-web/portal/src/services/invoices.service.ts @@ -73,4 +73,10 @@ export const invoicesService = { }); return data.data ?? data; }, + + /** Submit the CAC Bank OTP for an invoice whose intent is awaiting confirmation. */ + confirmOtp: async (id: string, otp: string): Promise => { + const { data } = await client.post(B.CONFIRM_INVOICE_OTP(id), { otp }); + return data.data ?? data; + }, };