mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: (payment) add eBirr as synchronous API_PURCHASE wallet debit
This commit is contained in:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user