feat: (payment) add eBirr as synchronous API_PURCHASE wallet debit

This commit is contained in:
Abubeker Yasin
2026-08-10 11:36:05 +03:00
parent 78d69eb02b
commit 6471c3511a
19 changed files with 1591 additions and 380 deletions

View File

@@ -29,6 +29,13 @@ import {
Check,
} from "lucide-react";
/**
* Poll budget for the eBirr push flow. The payer has to notice a USSD prompt and type a PIN, so
* this is far longer than the redirect flows' 15 attempts: 80 * 1.5s ≈ 2 min, matching the
* server's EBIRR_PUSH_TTL_MS.
*/
const PUSH_POLL_ATTEMPTS = 80;
const getIconForMethod = (methodId: string) => {
if (methodId.includes('CARD')) return CreditCard;
if (methodId.includes('WALLET')) return Wallet;
@@ -60,6 +67,13 @@ export default function PaymentPage() {
} | null>(null);
const [billCopied, setBillCopied] = useState(false);
// eBirr push debit: the wallet has prompted the payer on their own handset for a PIN. There is
// nothing to navigate to — we show this and poll until the intent settles.
const [pushAction, setPushAction] = useState<{
message: string;
payerAccountMasked?: string;
} | null>(null);
// Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're
// polling our own status endpoint for the webhook-backed outcome.
const [verifyingPayment, setVerifyingPayment] = useState(false);
@@ -176,12 +190,14 @@ export default function PaymentPage() {
const res: any = await apiClient.get(`/payments/status/${bookingId}`);
if (res?.status === 'SUCCEEDED') {
setVerifyingPayment(false);
setPushAction(null);
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
return;
}
if (res?.status === 'FAILED' || res?.status === 'CANCELLED') {
setVerifyingPayment(false);
setPushAction(null);
setIsProcessing(false);
updateStatus("FAILED");
setPaymentError(res?.failureMessage || "Payment was not completed. Please try again.");
@@ -192,9 +208,10 @@ export default function PaymentPage() {
}
if (attemptsLeft <= 0) {
// Don't call it failed: telebirr may have taken the money and the webhook is simply
// still in flight. Stop spinning, tell the truth, and let the payer re-check.
// Don't call it failed: the gateway may have taken the money and the confirmation is
// simply still in flight. Stop spinning, tell the truth, and let the payer re-check.
setVerifyingPayment(false);
setPushAction(null);
setIsProcessing(false);
setPaymentError(
"We haven't received confirmation yet. If you completed the payment, your booking " +
@@ -236,8 +253,27 @@ export default function PaymentPage() {
platform: isTelebirrMiniApp() ? 'inapp' : 'web',
});
},
onSuccess: async (data: any) => {
onSuccess: (data: any) => {
setPaymentError(null);
setPaymentIntent(data.paymentIntentId || data.intentId);
// A terminal verdict always wins over any clientAction, so this is checked FIRST.
// eBirr settles inside initiate — its debit response is the settlement, there is no
// webhook — so it can come back SUCCEEDED/FAILED while the intent still carries the
// AWAIT_PUSH action it was created with. Reading clientAction first would show "check
// your phone" for a payment that is already decided, and poll until it timed out.
if (data?.status === 'SUCCEEDED') {
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
return;
}
if (data?.status === 'FAILED' || data?.status === 'CANCELLED') {
setIsProcessing(false);
updateStatus("FAILED");
setPaymentError(data?.failureMessage || "Payment was not completed. Please try again.");
return;
}
// CAC Bank: no redirect — the bank SMS'd an OTP. Collect it in-app and confirm.
if (data?.clientAction?.type === 'COLLECT_OTP') {
@@ -278,6 +314,21 @@ export default function PaymentPage() {
return;
}
// eBirr fallback only. The debit is normally settled inside initiate and caught by the
// terminal check above; reaching here means the payer outlasted EBIRR_PURCHASE_TIMEOUT_MS
// while the PIN prompt was still on their handset. The money may since have moved, so poll
// rather than guess.
if (data?.clientAction?.type === 'AWAIT_PUSH') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
setPushAction(data.clientAction);
setVerifyingPayment(true);
// Much longer budget than the redirect flows: the payer has to read a USSD prompt and
// type a PIN. PUSH_POLL_ATTEMPTS * 1.5s ≈ 2 min, matching EBIRR_PUSH_TTL_MS.
void pollPaymentStatus(PUSH_POLL_ATTEMPTS);
return;
}
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
@@ -285,11 +336,13 @@ export default function PaymentPage() {
return;
}
setPaymentIntent(data.paymentIntentId || data.intentId);
// Not terminal, and no clientAction we know how to drive. Never assume success: this
// fallthrough used to sleep 2s and route to /booking/confirmation, which showed the payer
// a confirmed booking for a payment that had not happened. Poll for the truth, and if it
// never settles say so rather than inventing an outcome.
updateStatus("PROCESSING");
await new Promise((resolve) => setTimeout(resolve, 2000));
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
setVerifyingPayment(true);
void pollPaymentStatus(15);
},
onError: (error: any) => {
updateStatus("FAILED");
@@ -352,7 +405,12 @@ export default function PaymentPage() {
}
};
// Fire the actual initiate. `mobile` is only used for CAC (OTP debit).
// Methods that debit an account we must know up front: CAC Bank SMSes an OTP to it, eBirr
// pushes a USSD PIN prompt to it. Neither has a hosted page that could collect it later.
const requiresPayerMobile = (method: string | null): boolean =>
method === 'CAC_BANK' || method === 'EBIRR';
// Fire the actual initiate. `mobile` is only used by the push-debit methods above.
const startPayment = (mobile?: string) => {
if (!selectedMethod || !bookingId || !selectedPaymentMethod) return;
setIsProcessing(true);
@@ -363,7 +421,7 @@ export default function PaymentPage() {
paymentMethodId: selectedPaymentMethod.id,
currency: displayCurrency,
amountMinor: totalAmount,
payerAccount: selectedMethod === 'CAC_BANK' ? mobile?.trim() : undefined,
payerAccount: requiresPayerMobile(selectedMethod) ? mobile?.trim() : undefined,
});
};
@@ -378,9 +436,14 @@ export default function PaymentPage() {
}
setPaymentError(null);
// CAC Bank needs the payer's mobile for the OTP — collect it in a modal before initiating.
if (selectedMethod === 'CAC_BANK') {
if (requiresPayerMobile(selectedMethod)) {
setPhoneError(null);
// Prefill with the contact phone we already hold, but leave it editable — the wallet
// paying is often not the number the booking was made under.
if (!payerMobile.trim()) {
const contactPhone = passengers?.find((p) => p.phone)?.phone;
if (contactPhone) setPayerMobile(contactPhone);
}
setPhoneModalOpen(true);
return;
}
@@ -620,12 +683,26 @@ export default function PaymentPage() {
{isProcessing && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-sm w-full mx-4 text-center shadow-2xl">
{verifyingPayment ? (
{pushAction ? (
<>
<Smartphone className="w-14 h-14 text-primary mx-auto mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Check your phone</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{pushAction.message}
</p>
{pushAction.payerAccountMasked && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
Sent to {pushAction.payerAccountMasked}
</p>
)}
<Loader2 className="w-6 h-6 text-primary animate-spin mx-auto mt-4" />
</>
) : verifyingPayment ? (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Confirming payment</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Checking with telebirr this only takes a moment.
Checking with your payment provider this only takes a moment.
</p>
</>
) : paymentMutation.isSuccess ? (
@@ -645,7 +722,7 @@ export default function PaymentPage() {
</div>
)}
{/* CAC Bank — collect payer mobile before initiating */}
{/* 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">
@@ -654,7 +731,9 @@ export default function PaymentPage() {
<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">
CAC Bank will send a one-time password to this number to authorize the payment.
{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"
@@ -663,7 +742,7 @@ export default function PaymentPage() {
value={payerMobile}
onChange={(e) => { setPayerMobile(e.target.value); setPhoneError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') submitPhone(); }}
placeholder="77 XX XX XX"
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 && (