Files
edr-platform/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Nathnael 97bfe95ec3 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>
2026-07-31 08:11:23 +00:00

116 lines
3.8 KiB
TypeScript

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>;