mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 19:28:17 +00:00
162 lines
5.5 KiB
TypeScript
162 lines
5.5 KiB
TypeScript
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import { Freight } from "@edr/types";
|
|
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.
|
|
*/
|
|
/** CBE bill payment: no redirect — the payer takes this reference to any CBE channel. */
|
|
interface BillAction {
|
|
invoiceId: string;
|
|
billReference: string;
|
|
instructions?: string;
|
|
expiresAt?: string;
|
|
}
|
|
|
|
export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
|
const [otpInvoiceId, setOtpInvoiceId] = useState<string | null>(null);
|
|
const [otpMessage, setOtpMessage] = useState<string | undefined>();
|
|
const [billAction, setBillAction] = useState<BillAction | null>(null);
|
|
|
|
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;
|
|
}
|
|
// CBE_BILL settles asynchronously via CBE, not the browser — show the
|
|
// bill reference instead of redirecting to a (nonexistent) checkout page.
|
|
if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") {
|
|
setBillAction({
|
|
invoiceId: vars.invoiceId,
|
|
billReference: data.clientAction.billReference ?? "",
|
|
instructions: data.clientAction.instructions,
|
|
expiresAt: data.clientAction.expiresAt,
|
|
});
|
|
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();
|
|
},
|
|
});
|
|
|
|
// Poll the invoice while the CBE bill dialog is open — CBE settles out of
|
|
// band (branch/app/USSD), so this is the only way the browser learns it paid.
|
|
useQuery({
|
|
queryKey: ["invoice-bill-poll", billAction?.invoiceId],
|
|
queryFn: async () => {
|
|
const invoice = await invoicesService.get(billAction!.invoiceId);
|
|
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
|
setBillAction(null);
|
|
window.location.reload();
|
|
}
|
|
return invoice;
|
|
},
|
|
enabled: billAction !== null,
|
|
refetchInterval: 5000,
|
|
});
|
|
|
|
const reset = () => {
|
|
payMutation.reset();
|
|
otpMutation.reset();
|
|
setOtpInvoiceId(null);
|
|
setBillAction(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);
|
|
},
|
|
},
|
|
/** Drives the modal's "pay at CBE" step; `open` only for CBE_BILL. */
|
|
bill: {
|
|
open: billAction !== null,
|
|
billReference: billAction?.billReference,
|
|
instructions: billAction?.instructions,
|
|
expiresAt: billAction?.expiresAt,
|
|
close: () => setBillAction(null),
|
|
},
|
|
};
|
|
}
|
|
|
|
export type InvoicePaymentFlow = ReturnType<typeof useInvoicePayment>;
|