mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
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:
115
apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Normal file
115
apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user