Merge remote-tracking branch 'origin/staging' into freight/fix/pay

# Conflicts:
#	apps/edr-freight-api/src/modules/billing/billing.service.ts
#	apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx
#	apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx
#	pnpm-lock.yaml
This commit is contained in:
ghost2023
2026-08-01 13:09:14 +03:00
57 changed files with 2365 additions and 108 deletions

View File

@@ -200,6 +200,7 @@ export default function PaymentMethodsPage() {
const paymentTypes = [
{ value: 'TELEBIRR', label: 'Telebirr' },
{ value: 'CBE_BIRR', label: 'CBE Birr' },
{ value: 'CBE_BILL', label: 'CBE Bill Payment' },
{ value: 'EBIRR', label: 'eBirr' },
{ value: 'WAAFI', label: 'Waafi' },
{ value: 'DMONEY', label: 'dMoney' },

View File

@@ -20,6 +20,8 @@ import {
ChevronLeft,
KeyRound,
Landmark,
Copy,
Check,
} from "lucide-react";
const getIconForMethod = (methodId: string) => {
@@ -45,6 +47,13 @@ export default function PaymentPage() {
const [otpCode, setOtpCode] = useState("");
const [otpMessage, setOtpMessage] = useState<string | null>(null);
const [otpError, setOtpError] = useState<string | null>(null);
// CBE bill payment: the bill reference the customer pays at any CBE channel.
const [billAction, setBillAction] = useState<{
billReference: string;
instructions?: string;
expiresAt?: string;
} | null>(null);
const [billCopied, setBillCopied] = useState(false);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
@@ -144,6 +153,16 @@ export default function PaymentPage() {
return;
}
// CBE bill: no redirect — show the bill reference and wait for the customer to pay
// at a CBE channel. Confirmation only ever comes from polling the intent status.
if (data?.clientAction?.type === 'SHOW_BILL_REFERENCE') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
setBillAction(data.clientAction);
setIsProcessing(false);
return;
}
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
@@ -190,6 +209,34 @@ export default function PaymentPage() {
// While the CBE bill dialog is open, poll the intent; the booking confirms server-side
// once CBE settles the bill. Never claim success on any client-side signal.
const { data: billIntentStatus } = useQuery<any>({
queryKey: ["cbe-bill-intent-status", bookingId],
queryFn: () => apiClient.get(`/payments/intents/${bookingId}`),
enabled: !!billAction && !!bookingId,
refetchInterval: 5_000,
});
useEffect(() => {
if (billAction && billIntentStatus?.status === "SUCCEEDED") {
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [billAction, billIntentStatus?.status]);
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 */
}
};
// Fire the actual initiate. `mobile` is only used for CAC (OTP debit).
const startPayment = (mobile?: string) => {
if (!selectedMethod || !bookingId || !selectedPaymentMethod) return;
@@ -569,6 +616,62 @@ export default function PaymentPage() {
</div>
)}
{/* CBE bill payment — show the bill reference; confirmation comes from polling */}
{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 {(totalAmountDisplay ?? 0).toFixed(2)}
</span>
</p>
{billAction.expiresAt && (
<p>
Pay before:{" "}
<span className="font-semibold">
{format(new Date(billAction.expiresAt), "MMM d, yyyy HH:mm")}
</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 (pay later)
</button>
</div>
</div>
)}
{/* Two-column grid */}
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">