fix: ( supplementary-charges ) pay in the selected method's currency, add CAC Bank and CBE

This commit is contained in:
Abubeker Yasin
2026-08-18 10:16:59 +03:00
parent fa16087a4a
commit a9eac93fa3
7 changed files with 1047 additions and 17 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
@@ -8,7 +8,10 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { PaymentMethod } from "@/types";
import {
Loader2,
Check,
Copy,
CreditCard,
KeyRound,
Smartphone,
Wallet,
Landmark,
@@ -23,6 +26,28 @@ const getIconForMethod = (methodId: string) => {
return Smartphone;
};
// WALLET is an internal balance debit with no supplementary-charge path — the API refuses it, so
// it is never offered here.
const UNSUPPORTED_METHODS = ["WALLET"];
// Push-debit methods charge an account we must know before initiating: CAC Bank SMSes a one-time
// password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could
// collect the number afterwards, so it is asked for up front.
const requiresPayerMobile = (method: string | null) =>
method === "CAC_BANK" || method === "EBIRR";
// DJF has no minor unit; ETB and USD are quoted to cents. Matches the API's charge-side rounding,
// so the quote renders exactly the figure the provider will debit.
const formatAmount = (amount: number, currency: string) =>
amount.toFixed(currency.toUpperCase() === "DJF" ? 0 : 2);
interface AmountQuote {
chargeId: string;
method: string;
currency: string;
amount: number;
}
export default function PayBalancePage() {
const { token } = useParams<{ token: string }>();
const router = useRouter();
@@ -30,6 +55,26 @@ export default function PayBalancePage() {
const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
// Push-debit (CAC Bank / eBirr): collect the payer's mobile before initiating, then — for CAC —
// the OTP the bank SMSes to it.
const [phoneModalOpen, setPhoneModalOpen] = useState(false);
const [payerMobile, setPayerMobile] = useState("");
const [phoneError, setPhoneError] = useState<string | null>(null);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpMessage, setOtpMessage] = useState<string | null>(null);
const [otpError, setOtpError] = useState<string | null>(null);
const [pushMessage, setPushMessage] = useState<string | null>(null);
// CBE bill: no redirect and no OTP — the payer walks away with a bill number and pays it at a
// branch/app later, so the page shows the number and watches for settlement.
const [billAction, setBillAction] = useState<{
billReference: string;
instructions?: string;
expiresAt?: string;
} | null>(null);
const [billCopied, setBillCopied] = useState(false);
const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({
queryKey: ["supplementary-charge", token],
queryFn: () => apiClient.get<any>(`/payments/supplementary/by-token/${token}`),
@@ -45,18 +90,102 @@ export default function PayBalancePage() {
enabled: !!charge,
});
const availableMethods = useMemo(
() =>
paymentMethods.filter(
(m) => m.enabled && !UNSUPPORTED_METHODS.includes(m.type),
),
[paymentMethods],
);
// The charge is raised in ETB; this is what it costs before a method is chosen.
const chargeCurrency = charge?.currency ?? "ETB";
const chargeAmount = useMemo(
() => Number(charge?.amountMinor ?? 0) / 100,
[charge],
);
// Each method settles in its own currency (WAAFI/DMONEY in DJF, CARD in USD, Ethiopian wallets
// in ETB), so the price has to be re-quoted server-side whenever the selection changes — the
// stored ETB amount is not what a Djiboutian wallet would debit.
const {
data: quote,
isFetching: fetchingQuote,
error: quoteError,
} = useQuery<AmountQuote>({
queryKey: ["supplementaryAmount", token, selectedMethod],
queryFn: () =>
apiClient.get<AmountQuote>(
`/payments/supplementary/by-token/${token}/amount?method=${selectedMethod}`,
),
enabled: !!token && !!selectedMethod,
retry: false,
staleTime: 30_000,
});
// A quote is only usable once it belongs to the method currently selected — otherwise it is a
// leftover from the previous selection and would price the payment in the wrong currency.
const quoteReady = !fetchingQuote && quote?.method === selectedMethod;
const displayCurrency = selectedMethod ? (quote?.currency ?? "") : chargeCurrency;
const displayAmount = selectedMethod ? quote?.amount : chargeAmount;
const amountLabel =
quoteReady && displayAmount != null
? `${displayCurrency} ${formatAmount(displayAmount, displayCurrency)}`
: !selectedMethod && displayAmount != null
? `${chargeCurrency} ${formatAmount(displayAmount, chargeCurrency)}`
: null;
// Never let Pay fire against a price the payer has not been shown.
const awaitingQuote = !!selectedMethod && !quoteReady;
const payMutation = useMutation({
mutationFn: (method: string) =>
mutationFn: (vars: { method: string; payerAccount?: string }) =>
apiClient.post<any>(`/payments/supplementary/by-token/${token}/pay`, {
method,
method: vars.method,
platform: "web",
...(vars.payerAccount ? { payerAccount: vars.payerAccount } : {}),
}),
onSuccess: (data: any) => {
if (data?.clientAction?.type === "REDIRECT") {
window.location.href = resolvePaymentRedirectUrl(data.clientAction.url);
const action = data?.clientAction;
if (action?.type === "REDIRECT") {
window.location.href = resolvePaymentRedirectUrl(action.url);
return;
}
// Immediate success (e.g. wallet)
// CAC Bank: no redirect — the bank SMS'd an OTP. Collect it here and confirm.
if (action?.type === "COLLECT_OTP") {
setOtpMessage(action.message ?? "Enter the OTP sent to your phone");
setOtpCode("");
setOtpError(null);
setOtpModalOpen(true);
setIsProcessing(false);
return;
}
// CBE: the bill now exists in CBE's system. Nothing to navigate to — show the number.
if (action?.type === "SHOW_BILL_REFERENCE") {
setBillAction({
billReference: action.billReference,
instructions: action.instructions,
expiresAt: action.expiresAt,
});
setBillCopied(false);
setIsProcessing(false);
return;
}
// eBirr: the PIN prompt was pushed to the payer's handset; there is nothing to navigate to.
if (action?.type === "AWAIT_PUSH") {
setPushMessage(
action.message ??
`Approve the payment on your phone${action.payerAccountMasked ? ` (${action.payerAccountMasked})` : ""}.`,
);
setIsProcessing(false);
return;
}
// Immediate success
router.push(`/pay-balance/${token}/success`);
},
onError: (err: any) => {
@@ -67,11 +196,90 @@ export default function PayBalancePage() {
},
});
const handlePay = () => {
// CAC Bank OTP confirmation. A 200 means the debit settled; a 400 is a wrong/expired OTP —
// keep the modal open so the payer can re-enter it (the intent stays open).
const otpMutation = useMutation({
mutationFn: (otp: string) =>
apiClient.post<any>(`/payments/supplementary/by-token/${token}/confirm`, { otp }),
onSuccess: () => {
setOtpModalOpen(false);
router.push(`/pay-balance/${token}/success`);
},
onError: (err: any) => {
setOtpError(
err?.response?.data?.message ??
err?.message ??
"Invalid or expired OTP. Please try again.",
);
},
});
const startPayment = (mobile?: string) => {
if (!selectedMethod) return;
setIsProcessing(true);
setPaymentError(null);
payMutation.mutate(selectedMethod);
payMutation.mutate({
method: selectedMethod,
payerAccount: requiresPayerMobile(selectedMethod) ? mobile?.trim() : undefined,
});
};
const handlePay = () => {
if (!selectedMethod || awaitingQuote) return;
setPaymentError(null);
if (requiresPayerMobile(selectedMethod)) {
// Prefill with the number the charge was raised against, but leave it editable — the
// handset paying is often not the one the booking was made under.
if (!payerMobile.trim() && charge?.booking?.contactPhone) {
setPayerMobile(charge.booking.contactPhone);
}
setPhoneError(null);
setPhoneModalOpen(true);
return;
}
startPayment();
};
const submitPhone = () => {
if (!payerMobile.trim()) {
setPhoneError("Please enter your mobile number");
return;
}
setPhoneModalOpen(false);
startPayment(payerMobile);
};
// While a bill or a pushed PIN prompt is outstanding, watch the charge. Settlement happens
// server-side — a CBE teller, or the provider's webhook — so the browser has no other signal.
// Success is only ever claimed from this, never from a client-side guess.
const watching = !!billAction || !!pushMessage;
const { data: liveStatus } = useQuery<{ status: string; paid: boolean }>({
queryKey: ["supplementaryStatus", token],
queryFn: () =>
apiClient.get<{ status: string; paid: boolean }>(
`/payments/supplementary/by-token/${token}/status`,
),
enabled: !!token && watching,
refetchInterval: 5_000,
});
useEffect(() => {
if (watching && liveStatus?.paid) {
router.push(`/pay-balance/${token}/success`);
}
}, [watching, liveStatus?.paid, router, token]);
const copyBillReference = async () => {
if (!billAction) return;
try {
await navigator.clipboard.writeText(billAction.billReference);
setBillCopied(true);
setTimeout(() => setBillCopied(false), 2000);
} catch {
/* clipboard unavailable — the number is still shown on screen */
}
};
if (loadingCharge) {
@@ -95,8 +303,6 @@ export default function PayBalancePage() {
);
}
const amountDisplay = (charge.amountMinor / 100).toFixed(2);
const currency = charge.currency ?? "ETB";
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-start justify-center px-4 py-10">
<div className="w-full max-w-md space-y-4">
@@ -122,8 +328,25 @@ export default function PayBalancePage() {
)}
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
<span className="font-bold text-gray-900 dark:text-gray-100">Amount due</span>
<span className="text-2xl font-bold text-primary">{currency} {amountDisplay}</span>
{amountLabel ? (
<span className="text-2xl font-bold text-primary">{amountLabel}</span>
) : quoteError ? (
<span className="text-2xl font-bold text-gray-400"></span>
) : (
<Loader2 className="w-6 h-6 text-primary animate-spin" />
)}
</div>
{selectedMethod && quoteReady && displayCurrency !== chargeCurrency && (
<p className="text-xs text-gray-500 dark:text-gray-400 text-right">
Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today&apos;s rate
</p>
)}
{quoteError && (
<p className="text-xs text-red-600 dark:text-red-400 text-right">
{(quoteError as any)?.response?.data?.message ??
"This payment method is unavailable right now. Please choose another."}
</p>
)}
</div>
{/* Payment methods */}
@@ -136,7 +359,7 @@ export default function PayBalancePage() {
</div>
) : (
<div className="space-y-2">
{paymentMethods.filter((m) => m.enabled).map((method) => {
{availableMethods.map((method) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
@@ -173,19 +396,180 @@ export default function PayBalancePage() {
<button
onClick={handlePay}
disabled={!selectedMethod || isProcessing}
disabled={!selectedMethod || isProcessing || awaitingQuote}
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : quoteError ? (
"Choose another payment method"
) : awaitingQuote ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
</span>
) : (
`Pay ${currency} ${amountDisplay}`
`Pay ${amountLabel ?? ""}`.trim()
)}
</button>
<p className="text-xs text-gray-400 dark:text-gray-500 text-center">🔒 Secure & encrypted payment</p>
{/* CBE bill — show the number; confirmation only ever comes from the status poll */}
{billAction && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md w-full shadow-2xl">
<div className="flex items-center gap-2 mb-1">
<Landmark className="w-5 h-5 text-primary" />
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Pay at CBE</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{billAction.instructions ??
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
</p>
<div className="flex items-center justify-between gap-2 bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3">
<span className="font-mono text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100 select-all">
{billAction.billReference}
</span>
<button
onClick={copyBillReference}
className="btn-secondary px-3 py-2 flex items-center gap-1 text-sm"
title="Copy bill number"
>
{billCopied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
{billCopied ? "Copied" : "Copy"}
</button>
</div>
<div className="text-sm text-gray-600 dark:text-gray-300 mt-3 space-y-1">
<p>
Amount: <span className="font-semibold">ETB {formatAmount(chargeAmount, "ETB")}</span>
</p>
{billAction.expiresAt && (
<p>
Pay before:{" "}
<span className="font-semibold">
{new Date(billAction.expiresAt).toLocaleString()}
</span>
</p>
)}
</div>
<div className="flex items-center gap-2 mt-4 text-xs text-gray-500 dark:text-gray-400">
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
Waiting for payment confirmation this page updates automatically once CBE
confirms your payment.
</div>
<button
onClick={() => setBillAction(null)}
className="btn-secondary w-full py-2.5 mt-4"
>
Close
</button>
</div>
</div>
)}
{/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */}
{pushMessage && (
<div className="card flex items-start gap-3">
<Smartphone className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-semibold text-gray-900 dark:text-gray-100">Check your phone</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{pushMessage}</p>
</div>
</div>
)}
{/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */}
{phoneModalOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
<div className="flex items-center gap-2 mb-1">
<Smartphone className="w-5 h-5 text-primary" />
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Your mobile number</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{selectedMethod === "EBIRR"
? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you."
: "CAC Bank will send a one-time password to this number to authorize the payment."}
</p>
<input
type="tel"
inputMode="numeric"
autoFocus
value={payerMobile}
onChange={(e) => { setPayerMobile(e.target.value); setPhoneError(null); }}
onKeyDown={(e) => { if (e.key === "Enter") submitPhone(); }}
placeholder={selectedMethod === "EBIRR" ? "09XX XXX XXX" : "77 XX XX XX"}
className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
/>
{phoneError && (
<p className="text-red-600 dark:text-red-400 text-xs mt-2"> {phoneError}</p>
)}
<div className="flex gap-2 mt-4">
<button onClick={() => setPhoneModalOpen(false)} className="btn-secondary flex-1 py-2.5">
Cancel
</button>
<button
onClick={submitPhone}
disabled={!payerMobile.trim()}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
</button>
</div>
</div>
</div>
)}
{/* CAC Bank OTP entry */}
{otpModalOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
<div className="flex items-center gap-2 mb-1">
<KeyRound className="w-5 h-5 text-primary" />
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Enter OTP</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{otpMessage}</p>
<input
type="text"
inputMode="numeric"
autoFocus
value={otpCode}
onChange={(e) => { setOtpCode(e.target.value.replace(/\D/g, "")); setOtpError(null); }}
onKeyDown={(e) => { if (e.key === "Enter" && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }}
placeholder="Enter code"
maxLength={10}
className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
/>
{otpError && (
<p className="text-red-600 dark:text-red-400 text-xs mt-2"> {otpError}</p>
)}
<div className="flex gap-2 mt-4">
<button
onClick={() => setOtpModalOpen(false)}
disabled={otpMutation.isPending}
className="btn-secondary flex-1 py-2.5"
>
Cancel
</button>
<button
onClick={() => otpCode.trim() && otpMutation.mutate(otpCode.trim())}
disabled={otpMutation.isPending || !otpCode.trim()}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{otpMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Verifying...
</span>
) : (
"Confirm payment"
)}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);