feat(payment): integrate CAC Bank OTP payments into freight flows

CAC Bank is an OTP debit with no redirect and no webhook: initiate SMSes a
code to the payer's mobile, and the charge only settles when that code is
confirmed. The payment service already spoke it (passenger uses it); the
freight side had the enum values but none of the flow.

API:
- PaymentClientService.confirmOtp forwards the code to
  POST /payments/intents/:id/confirm, mapping 400/404 to BadRequest so a
  mistyped code stays retryable instead of surfacing as a gateway failure.
- PaymentService.confirmOtp is keyed by the LOCAL intent id (the invoice's
  paymentId) rather than the domain reference, so the right invoice settles
  when several share a booking. On success billing settles the invoice.
- payInvoice rejects CAC_BANK without payerAccount before calling the
  gateway, and no longer runs the demo auto-settle for a COLLECT_OTP intent
  (it is not paid until the payer confirms).
- POST /billing/my-invoices/:id/confirm — ownership-checked, and since
  warehouse fee invoices are central invoices it covers those too.

Portal:
- useInvoicePayment owns the whole flow (initiate, redirect-or-OTP, confirm)
  and replaces the five near-identical pay mutations at the call sites.
- PaymentMethodModal gains the CAC Bank option, the payer mobile field, and
  the OTP step. Click-outside is disabled there so a stray click cannot drop
  the payer out of a live OTP window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-07-31 08:11:23 +00:00
parent 5a02da91d2
commit 97bfe95ec3
16 changed files with 605 additions and 224 deletions

View File

@@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => {
expect(manager.update).not.toHaveBeenCalled(); 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<string, unknown>) => {
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");
});
});

View File

@@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
import { CompaniesService } from "../companies/companies.service"; import { CompaniesService } from "../companies/companies.service";
import { PaymentService } from "../payment/payment.service"; import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto"; import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import { import {
InvoiceDocumentModel, InvoiceDocumentModel,
InvoiceDocumentService, InvoiceDocumentService,
@@ -352,6 +352,34 @@ export class BillingService {
return this.payInvoice(id, opts); 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<IntentStatusDto> {
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<IntentStatusDto> {
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). */ /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
async documentForUser( async documentForUser(
id: string, id: string,
@@ -1035,6 +1063,17 @@ export class BillingService {
throw new BadRequestException("Invoice has no outstanding balance."); 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({ const result = await this.payment.initiate({
referenceId: invoice.sourceId, referenceId: invoice.sourceId,
source: invoice.source, source: invoice.source,
@@ -1062,7 +1101,12 @@ export class BillingService {
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only. // 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({ await this.payment.handlePaymentEvent({
eventType: "payment.succeeded", eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`, eventId: `demo-${result.intentId}`,

View File

@@ -1,5 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator"; 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. */ /** Gateway options for paying an invoice from the customer portal. */
export class PayInvoiceDto { export class PayInvoiceDto {

View File

@@ -18,7 +18,7 @@ import {
} from "../../common/resolve-auth-user-id"; } from "../../common/resolve-auth-user-id";
import { sendPdf } from "./billing.controller"; import { sendPdf } from "./billing.controller";
import { BillingService } from "./billing.service"; 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, * Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
@@ -96,4 +96,20 @@ export class PortalBillingController {
failureUrl: dto.failureUrl, 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,
);
}
} }

View File

@@ -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 { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs"; 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<PaymentIntentSnapshot> {
try {
return await this.call<PaymentIntentSnapshot>(
"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<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> { private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`; const url = `${this.baseUrl}${path}`;
try { try {

View File

@@ -374,6 +374,53 @@ export class PaymentService {
return this.formatIntentStatus(refreshed ?? local); 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<IntentStatusDto> {
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 * Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false` * linked invoice. Idempotent — no-op when already success. Pass `notify: false`

View File

@@ -201,6 +201,8 @@ export const URL_CONSTANTS = {
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`, MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`, MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`, PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
CONFIRM_INVOICE_OTP: (id: string) =>
`/api/billing/my-invoices/${id}/confirm`,
}, },
WAREHOUSE_INVOICES: { WAREHOUSE_INVOICES: {

View File

@@ -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<InitiateResponse>;
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<string | null>(null);
const [otpMessage, setOtpMessage] = useState<string | undefined>();
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<typeof useInvoicePayment>;

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; 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 { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { import {
AlertTriangle, AlertTriangle,
@@ -10,9 +10,8 @@ import {
PackagePlus, PackagePlus,
} from "lucide-react"; } from "lucide-react";
import { api } from "@/services/api";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper"; 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 { invoicesService } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui"; import { isPayable } from "@/pages/billing/invoice-ui";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
@@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
isPayable(inv.status), isPayable(inv.status),
)?.id; )?.id;
const payMutation = useMutation({ const pay = useInvoicePayment();
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;
},
});
if (items.length === 0) return null; if (items.length === 0) return null;
@@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
<PaymentMethodModal <PaymentMethodModal
opened={payItem !== null} opened={payItem !== null}
onClose={() => { onClose={() => {
if (!payMutation.isPending) { if (!pay.processing) {
setPayItem(null); setPayItem(null);
payMutation.reset(); pay.reset();
} }
}} }}
currency={undefined} currency={undefined}
processing={payMutation.isPending} processing={pay.processing}
error={ error={
payMutation.isError pay.error ??
? payMutation.error instanceof Error (payItemInvoices.length > 0 && !payableInvoiceId
? payMutation.error.message ? "No payable invoice found for this booking yet. Please refresh or contact support."
: "Could not start payment. Please try again." : null)
: null }
otp={pay.otp}
onConfirm={(method, payerAccount) =>
payableInvoiceId &&
pay.pay(payableInvoiceId, method, payerAccount)
} }
onConfirm={(method) => payMutation.mutate(method)}
/> />
</ModalSafeWrapper> </ModalSafeWrapper>
</Card> </Card>

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Alert, Alert,
Box, Box,
@@ -27,10 +27,7 @@ import toast from "react-hot-toast";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service"; import { invoicesService } from "@/services/invoices.service";
import { import { useInvoicePayment } from "@/hooks/useInvoicePayment";
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service"; import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { saveBlob } from "@/utils/download"; import { saveBlob } from "@/utils/download";
@@ -76,17 +73,7 @@ export default function InvoiceDetailPage() {
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges // Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
// one of the signed-in customer's own invoices (unlike the admin-facing // one of the signed-in customer's own invoices (unlike the admin-facing
// /payments/initiate, which takes any invoiceId with no ownership check). // /payments/initiate, which takes any invoiceId with no ownership check).
const payMutation = useMutation({ const pay = useInvoicePayment();
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;
},
});
if (isLoading) { if (isLoading) {
return ( return (
@@ -249,7 +236,7 @@ export default function InvoiceDetailPage() {
radius="md" radius="md"
size="md" size="md"
leftSection={<CreditCard size={16} />} leftSection={<CreditCard size={16} />}
loading={payMutation.isPending} loading={pay.processing}
onClick={handlePay} onClick={handlePay}
styles={{ styles={{
root: { fontWeight: 600, height: 42, paddingInline: 18 }, root: { fontWeight: 600, height: 42, paddingInline: 18 },
@@ -376,22 +363,19 @@ export default function InvoiceDetailPage() {
<PaymentMethodModal <PaymentMethodModal
opened={payModalOpen} opened={payModalOpen}
onClose={() => { onClose={() => {
if (!payMutation.isPending) { if (!pay.processing) {
setPayModalOpen(false); setPayModalOpen(false);
payMutation.reset(); pay.reset();
} }
}} }}
amountLabel={formatCurrency(amountDue, invoice.currency)} amountLabel={formatCurrency(amountDue, invoice.currency)}
currency={invoice.currency} currency={invoice.currency}
processing={payMutation.isPending} processing={pay.processing}
error={ error={pay.error}
payMutation.isError otp={pay.otp}
? payMutation.error instanceof Error onConfirm={(method, payerAccount) =>
? payMutation.error.message pay.pay(id, method, payerAccount)
: "Could not start payment. Please try again."
: null
} }
onConfirm={(method) => payMutation.mutate(method)}
/> />
</Stack> </Stack>
</Box> </Box>

View File

@@ -1,14 +1,8 @@
import { Group, Tabs } from "@mantine/core"; import { Group, Tabs } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, FileText, LayoutGrid } from "lucide-react"; import { CreditCard, FileText, LayoutGrid } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { useFileViewer } from "@/hooks/useFileViewer"; 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 type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -41,6 +35,7 @@ import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard"; import { SupportCard } from "./components/SupportCard";
import { fmtDate, isNegative, priceTotal } from "./utils"; import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash"; import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
export function ReadonlyBookingView({ export function ReadonlyBookingView({
booking, booking,
@@ -53,7 +48,6 @@ export function ReadonlyBookingView({
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice. // Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
useScrollToHash(); useScrollToHash();
const status = booking.status as string; const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false);
const { viewer } = useFileViewer(); const { viewer } = useFileViewer();
// Re-book opens the New Shipment Booking form for the same contract, not the // Re-book opens the New Shipment Booking form for the same contract, not the
@@ -63,44 +57,11 @@ export function ReadonlyBookingView({
: "/contracts/new"; : "/contracts/new";
const onRebook = () => navigate(rebookTo); const onRebook = () => navigate(rebookTo);
// Billing is invoice-centric — resolve the booking's currently payable // Billing is invoice-centric — the shared hook resolves the booking's
// invoice (same query/key BookingPaymentPanel uses, so this shares its // currently payable invoice (same query/key BookingPaymentPanel uses, so it
// cache) and pay it through the ownership-checked portal route. // shares that cache), charges it through the ownership-checked portal route,
const { data: bookingInvoices = [] } = useQuery({ // and handles redirect vs CAC Bank OTP.
queryKey: ["booking-invoices", booking.id], const pay = useBookingPayment(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;
},
});
const pricing = booking.pricingBreakdown; const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never // A general contract is paid once it's FULLY_EXECUTED (signed) — it never
@@ -171,7 +132,7 @@ export function ReadonlyBookingView({
green green
icon={<CreditCard size={16} />} icon={<CreditCard size={16} />}
label="Pay now" label="Pay now"
onClick={() => setPayModalOpen(true)} onClick={pay.open}
/> />
)} )}
</Group> </Group>
@@ -278,8 +239,8 @@ export function ReadonlyBookingView({
<BookingPaymentPanel <BookingPaymentPanel
booking={booking} booking={booking}
pricing={pricing} pricing={pricing}
onPay={() => setPayModalOpen(true)} onPay={pay.open}
paying={payMutation.isPending} paying={pay.processing}
showCountdown={showCountdown} showCountdown={showCountdown}
/> />
<ScheduleCard <ScheduleCard
@@ -301,24 +262,14 @@ export function ReadonlyBookingView({
</Tabs> </Tabs>
<PaymentMethodModal <PaymentMethodModal
opened={payModalOpen} opened={pay.modalOpen}
onClose={() => { onClose={pay.close}
if (!payMutation.isPending) {
setPayModalOpen(false);
payMutation.reset();
}
}}
amountLabel={pricing ? priceTotal(pricing) : undefined} amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency} currency={pricing?.currency ?? booking.paymentCurrency}
processing={payMutation.isPending} processing={pay.processing}
error={ error={pay.error}
payMutation.isError otp={pay.otp}
? payMutation.error instanceof Error onConfirm={pay.confirm}
? payMutation.error.message
: "Could not start payment. Please try again."
: null
}
onConfirm={(method) => payMutation.mutate(method)}
/> />
{viewer} {viewer}
</PageShell> </PageShell>

View File

@@ -1,20 +1,32 @@
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core"; import {
import { Check, ShieldCheck } from "lucide-react"; 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 { useEffect, useMemo, useState } from "react";
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
import type { PaymentMethod } from "@/services/payments.service"; import type { PaymentMethod } from "@/services/payments.service";
interface ProviderOption { interface ProviderOption {
method: PaymentMethod; method: PaymentMethod;
label: string; label: string;
description: 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 this provider settles in. */
currencies: string[]; currencies: string[];
accent: string; accent: string;
} }
// Only Telebirr and Waafi are enabled for now. // Only Telebirr, Waafi and CAC Bank are enabled for now.
const PROVIDERS: ProviderOption[] = [ const PROVIDERS: ProviderOption[] = [
{ {
method: "TELEBIRR", method: "TELEBIRR",
@@ -32,8 +44,18 @@ const PROVIDERS: ProviderOption[] = [
currencies: ["USD"], currencies: ["USD"],
accent: "#2E5B96", 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, * Pick the provider that settles in the booking's currency. USD → Waafi,
* ETB → Telebirr. Falls back to the first provider when unknown. * ETB → Telebirr. Falls back to the first provider when unknown.
@@ -91,13 +113,27 @@ function ProviderRow({
backgroundColor: "#fff", backgroundColor: "#fff",
}} }}
> >
<Image {option.logo ? (
src={option.logo} <Image
alt={`${option.label} logo`} src={option.logo}
w={52} alt={`${option.label} logo`}
h={52} w={52}
fit="cover" h={52}
/> fit="cover"
/>
) : (
<Box
style={{
width: 52,
height: 52,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Landmark size={24} color={option.accent} />
</Box>
)}
</Box> </Box>
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="15px" fw={800} c="#10202F" tt="capitalize"> <Text fz="15px" fw={800} c="#10202F" tt="capitalize">
@@ -135,6 +171,7 @@ export function PaymentMethodModal({
onConfirm, onConfirm,
processing, processing,
error, error,
otp,
}: { }: {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
@@ -142,12 +179,19 @@ export function PaymentMethodModal({
amountLabel?: string; amountLabel?: string;
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */ /** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
currency?: string | null; currency?: string | null;
onConfirm: (method: PaymentMethod) => void; onConfirm: (method: PaymentMethod, payerAccount?: string) => void;
processing?: boolean; processing?: boolean;
error?: string | null; 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<PaymentMethod>(providers[0].method); const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
const [mobile, setMobile] = useState("");
const [code, setCode] = useState("");
// Keep the selection valid when the currency (and therefore provider list) changes. // Keep the selection valid when the currency (and therefore provider list) changes.
useEffect(() => { useEffect(() => {
@@ -156,6 +200,89 @@ export function PaymentMethodModal({
} }
}, [providers, method]); }, [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 (
<Modal
opened={opened}
onClose={otp.cancel}
centered
radius={18}
size={420}
padding={0}
withCloseButton={false}
// A stray click must not drop the payer out of a live OTP window —
// Cancel is the only way back.
closeOnClickOutside={false}
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
>
<Box px={24} py={24}>
<Text fw={800} fz="18px" c="#10202F">
Enter OTP
</Text>
<Text mt={4} fz="13px" c="#7A8794">
{otp.message}
</Text>
<Box mt={18}>
<PinInput
length={6}
type="number"
inputMode="numeric"
oneTimeCode
value={code}
onChange={setCode}
onComplete={(value) => otp.submit(value)}
aria-label="One-time password"
/>
</Box>
{otp.error && (
<Text mt={10} fz="12.5px" c="#C0392B" fw={600}>
{otp.error}
</Text>
)}
<Group gap={10} wrap="nowrap" mt={20}>
<Button
variant="default"
radius={12}
onClick={otp.cancel}
disabled={otp.submitting}
styles={{
root: { height: 46, flex: "0 0 38%" },
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
}}
>
Cancel
</Button>
<Button
radius={12}
color="edr-green"
loading={otp.submitting}
disabled={otp.submitting || code.trim().length === 0}
onClick={() => otp.submit(code.trim())}
styles={{
root: { height: 46, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
Confirm payment
</Button>
</Group>
</Box>
</Modal>
);
}
return ( return (
<Modal <Modal
opened={opened} opened={opened}
@@ -215,6 +342,22 @@ export function PaymentMethodModal({
/> />
))} ))}
</Stack> </Stack>
{needsMobile && (
<TextInput
mt={12}
label="Mobile number"
description="CAC Bank sends a one-time password to this number to authorise the debit."
placeholder="77xxxxxx"
value={mobile}
onChange={(e) => setMobile(e.currentTarget.value)}
disabled={processing}
styles={{
label: { fontSize: 12.5, fontWeight: 700, color: "#10202F" },
description: { fontSize: 11.5 },
}}
/>
)}
</Box> </Box>
{/* Footer */} {/* Footer */}
@@ -228,7 +371,9 @@ export function PaymentMethodModal({
<Group gap={6} align="center" justify="center" mb={12}> <Group gap={6} align="center" justify="center" mb={12}>
<ShieldCheck size={14} color="#0A8A5F" /> <ShieldCheck size={14} color="#0A8A5F" />
<Text fz="11.5px" c="#7A8794"> <Text fz="11.5px" c="#7A8794">
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"}
</Text> </Text>
</Group> </Group>
@@ -248,15 +393,21 @@ export function PaymentMethodModal({
<Button <Button
radius={12} radius={12}
color="edr-green" color="edr-green"
disabled={processing} disabled={processing || !canSubmit}
loading={processing} loading={processing}
onClick={() => onConfirm(method)} onClick={() =>
onConfirm(method, needsMobile ? mobile.trim() : undefined)
}
styles={{ styles={{
root: { height: 48, flex: 1 }, root: { height: 48, flex: 1 },
label: { fontSize: 14, fontWeight: 800 }, label: { fontSize: 14, fontWeight: 800 },
}} }}
> >
{processing ? "Redirecting…" : "Continue to payment"} {processing
? needsMobile
? "Sending OTP"
: "Redirecting"
: "Continue to payment"}
</Button> </Button>
</Group> </Group>
</Box> </Box>

View File

@@ -1,10 +1,10 @@
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core"; 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 { CreditCard, Download, FileText, Receipt } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { import {
warehouseInvoicesService, warehouseInvoicesService,
type PortalWarehouseInvoice, type PortalWarehouseInvoice,
@@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null); const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
const payMutation = useMutation({ // Warehouse fees are charged through the warehouse route, but they are the
mutationFn: (method: PaymentMethod) => { // same central invoices — so redirect vs CAC Bank OTP is the shared flow.
if (!payInvoice) throw new Error("No invoice selected for payment."); const pay = useInvoicePayment((invoiceId, method, payerAccount) =>
return warehouseInvoicesService.payOnline(payInvoice.id, { warehouseInvoicesService.payOnline(invoiceId, {
method, method,
platform: "web", platform: "web",
}); payerAccount,
}, }),
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;
const closePayModal = () => { const closePayModal = () => {
if (!payMutation.isPending) { if (!pay.processing) {
setPayInvoice(null); setPayInvoice(null);
payMutation.reset(); pay.reset();
} }
}; };
@@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
} }
currency={payInvoice?.currency} currency={payInvoice?.currency}
onConfirm={(method) => payMutation.mutate(method)} onConfirm={(method, payerAccount) =>
processing={payMutation.isPending} payInvoice && pay.pay(payInvoice.id, method, payerAccount)
error={payError} }
processing={pay.processing}
error={pay.error}
otp={pay.otp}
/> />
</SectionCard> </SectionCard>
); );

View File

@@ -55,6 +55,7 @@ export function PayNowButton({
currency={pricing?.currency ?? booking.paymentCurrency} currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing} processing={pay.processing}
error={pay.error} error={pay.error}
otp={pay.otp}
onConfirm={pay.confirm} onConfirm={pay.confirm}
/> />
</ModalSafeWrapper> </ModalSafeWrapper>

View File

@@ -1,23 +1,22 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { api } from "@/services/api"; import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { import { type PaymentMethod } from "@/services/payments.service";
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { invoicesService } from "@/services/invoices.service"; import { invoicesService } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui"; import { isPayable } from "@/pages/billing/invoice-ui";
/** /**
* Shared payment flow for a single booking: opens the method modal, fires * Shared payment flow for a single booking: opens the method modal, fires
* POST /billing/my-invoices/:id/pay for the booking's currently payable * POST /billing/my-invoices/:id/pay for the booking's currently payable
* invoice, and redirects the browser to the provider (or the fallback * invoice, and redirects the browser to the provider (or, for CAC Bank, an
* checkout page). Reused by the booking detail page, the booking list, and * OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
* the home page so "Pay now" behaves identically everywhere. * the booking detail page, the booking list, and the home page so "Pay now"
* behaves identically everywhere.
*/ */
export function useBookingPayment(bookingId: string) { export function useBookingPayment(bookingId: string) {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [noInvoice, setNoInvoice] = useState(false);
const { data: invoices = [] } = useQuery({ const { data: invoices = [] } = useQuery({
queryKey: ["booking-invoices", bookingId], queryKey: ["booking-invoices", bookingId],
@@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) {
}); });
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id; const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
const mutation = useMutation({ const flow = useInvoicePayment();
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 open = () => setModalOpen(true); const open = () => setModalOpen(true);
const close = () => { const close = () => {
if (!mutation.isPending) { if (!flow.processing) {
setModalOpen(false); 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 { return {
modalOpen, modalOpen,
open, open,
close, close,
processing: mutation.isPending, processing: flow.processing,
error, error: noInvoice
confirm: (method: PaymentMethod) => mutation.mutate(method), ? "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);
},
}; };
} }

View File

@@ -73,4 +73,10 @@ export const invoicesService = {
}); });
return data.data ?? data; return data.data ?? data;
}, },
/** Submit the CAC Bank OTP for an invoice whose intent is awaiting confirmation. */
confirmOtp: async (id: string, otp: string): Promise<InitiateResponse> => {
const { data } = await client.post(B.CONFIRM_INVOICE_OTP(id), { otp });
return data.data ?? data;
},
}; };