mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
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>
116 lines
3.8 KiB
TypeScript
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>;
|