diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts index 4f8dac4c7..4f060ca11 100644 --- a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -43,11 +43,17 @@ export function IsValidPhone(validationOptions?: ValidationOptions) { * Normalize a phone string to canonical E.164. Returns the canonical form when * parseable, otherwise the trimmed original (tolerant — never throws), or the * value unchanged when empty/nullish. + * + * Defaults the country to Ethiopia so bare local numbers (no "+", e.g. eTrade's + * "0355235416") resolve the same way the frontend's own toEthiopianE164 already + * assumes — without this hint, libphonenumber can't infer a country for a + * number with no "+" prefix and silently falls through to the untouched local + * string, which then never matches the "+251…" form submitted by the client. */ export function normalizeE164( value: string | null | undefined, ): string | null | undefined { if (value === undefined || value === null || value === '') return value; - const parsed = parsePhoneNumberFromString(value); + const parsed = parsePhoneNumberFromString(value, 'ET'); return parsed?.isValid() ? parsed.number : value.trim(); } diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 850791f02..8ede56f69 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1126,9 +1126,10 @@ export class BillingService { .update({ id: invoice.id }, { paymentId: result.intentId }); // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); - // billing must not simulate it. Kept commented for local demos only. + // billing must not simulate it. Kept for local demos only. // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the - // code — so the demo shortcut must never fire for it. + // code — so the demo shortcut must never fire for it. Same for CBE_BILL: its + // bill must stay open until CBE actually settles it via /cbe/payment. if ( !result.immediateSuccess && result.response.clientAction?.type !== "COLLECT_OTP" && diff --git a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts index 04c91a683..ffa86d381 100644 --- a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts +++ b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts @@ -34,9 +34,17 @@ function apiMessage(err: unknown, fallback: string): string { * 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 { + billReference: string; + instructions?: string; + expiresAt?: string; +} + export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { const [otpInvoiceId, setOtpInvoiceId] = useState(null); const [otpMessage, setOtpMessage] = useState(); + const [billAction, setBillAction] = useState(null); const payMutation = useMutation({ mutationFn: (vars: { @@ -52,6 +60,16 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { 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({ + 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 @@ -76,6 +94,7 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { payMutation.reset(); otpMutation.reset(); setOtpInvoiceId(null); + setBillAction(null); }; return { @@ -109,6 +128,14 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { 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), + }, }; } diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 1477b7189..775d9fe41 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -9,7 +9,6 @@ import { Divider, Group, Loader, - Modal, Paper, SimpleGrid, Stack, @@ -34,13 +33,7 @@ import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/component import { saveBlob } from "@/utils/download"; import { formatCurrency } from "@/lib/currency"; import { BORDER, INK, MUTED } from "../contracts/contract-ui"; -import { - billedTo, - fmtDate, - InvoiceStatusBadge, - isPayable, - titleCase, -} from "./invoice-ui"; +import { billedTo, fmtDate, InvoiceStatusBadge, isPayable, titleCase } from "./invoice-ui"; function MetaItem({ label, value }: { label: string; value: string }) { return ( @@ -70,12 +63,6 @@ export default function InvoiceDetailPage() { } = useQuery(api.invoices.get.queryOptions({ input: { id } })); const [payModalOpen, setPayModalOpen] = useState(false); - // CBE bill payment: the bill reference to pay at any CBE channel (no redirect). - const [billAction, setBillAction] = useState<{ - billReference?: string; - instructions?: string; - expiresAt?: string; - } | null>(null); // Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges // one of the signed-in customer's own invoices (unlike the admin-facing @@ -368,7 +355,7 @@ export default function InvoiceDetailPage() { { if (!pay.processing) { setPayModalOpen(false); @@ -380,66 +367,11 @@ export default function InvoiceDetailPage() { processing={pay.processing} error={pay.error} otp={pay.otp} + bill={pay.bill} onConfirm={(method, payerAccount) => pay.pay(id, method, payerAccount) } /> - - {/* CBE bill payment — show the bill number; settlement arrives via CBE, not the browser */} - setBillAction(null)} - centered - radius={18} - size={440} - title={Pay at CBE} - > - - - {billAction?.instructions ?? - "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} - - - - {billAction?.billReference} - - - - - Amount due:{" "} - - {formatCurrency(amountDue, invoice.currency)} - - - {billAction?.expiresAt && ( - - Pay before:{" "} - - {fmtDate(billAction.expiresAt)} - - - )} - - The invoice updates automatically once CBE confirms your payment. - - - ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 2e9477f80..e0fe11ca0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -26,7 +26,7 @@ interface ProviderOption { accent: string; } -// Only Telebirr, Waafi and CBE bill payment are enabled for now. +// Only Telebirr, Waafi, CAC Bank and CBE bill payment are enabled for now. const PROVIDERS: ProviderOption[] = [ { method: "TELEBIRR", @@ -63,6 +63,8 @@ const PROVIDERS: ProviderOption[] = [ /** Providers that debit against an SMS OTP instead of redirecting to a page. */ const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK"; +/** Providers that settle asynchronously via a bill reference instead of a redirect. */ +const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL"; /** * Pick the provider that settles in the booking's currency. USD → Waafi, @@ -180,6 +182,7 @@ export function PaymentMethodModal({ processing, error, otp, + bill, }: { opened: boolean; onClose: () => void; @@ -192,14 +195,21 @@ export function PaymentMethodModal({ error?: string | null; /** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */ otp?: InvoicePaymentFlow["otp"]; + /** CBE bill-reference step, from `useInvoicePayment`. Omit to disable CBE_BILL. */ + bill?: InvoicePaymentFlow["bill"]; }) { const providers = useMemo( - () => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)), - [currency, otp], + () => + providersForCurrency(currency).filter( + (p) => + (otp || !isOtpMethod(p.method)) && (bill || !isBillMethod(p.method)), + ), + [currency, otp, bill], ); const [method, setMethod] = useState(providers[0].method); const [mobile, setMobile] = useState(""); const [code, setCode] = useState(""); + const [copied, setCopied] = useState(false); // Keep the selection valid when the currency (and therefore provider list) changes. useEffect(() => { @@ -217,6 +227,101 @@ export function PaymentMethodModal({ const needsMobile = isOtpMethod(method); const canSubmit = !needsMobile || mobile.trim().length > 0; + if (bill?.open) { + return ( + + + + Pay at CBE + + + {bill.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} + + + + + {bill.billReference} + + + + + {amountLabel && ( + + Amount due: {amountLabel} + + )} + {bill.expiresAt && ( + + Pay before:{" "} + + {new Date(bill.expiresAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + )} + + + This page updates automatically once CBE confirms your payment. + + + + + + ); + } + if (otp?.open) { return (