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

@@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
{ value: "card", label: "Card" },
{ value: "dmoney", label: "D-Money" },
{ value: "cac-bank", label: "CAC Bank" },
{ value: "cbe-bill", label: "CBE Bill" },
];
const STATUS_COLORS: Record<string, string> = {

View File

@@ -19,7 +19,8 @@ export type PaymentMethod =
| "waafi"
| "card"
| "dmoney"
| "cac-bank";
| "cac-bank"
| "cbe-bill";
export interface PaymentRow {
id: string;

View File

@@ -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<string | null>(null);
const [otpMessage, setOtpMessage] = useState<string | undefined>();
const [billAction, setBillAction] = useState<BillAction | null>(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),
},
};
}

View File

@@ -33,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 (
@@ -361,7 +355,7 @@ export default function InvoiceDetailPage() {
</Paper>
<PaymentMethodModal
opened={payModalOpen}
opened={payModalOpen || pay.bill.open}
onClose={() => {
if (!pay.processing) {
setPayModalOpen(false);
@@ -373,6 +367,7 @@ export default function InvoiceDetailPage() {
processing={pay.processing}
error={pay.error}
otp={pay.otp}
bill={pay.bill}
onConfirm={(method, payerAccount) =>
pay.pay(id, method, payerAccount)
}

View File

@@ -26,7 +26,7 @@ interface ProviderOption {
accent: string;
}
// Only Telebirr, Waafi and CAC Bank are enabled for now.
// Only Telebirr, Waafi, CAC Bank and CBE bill payment are enabled for now.
const PROVIDERS: ProviderOption[] = [
{
method: "TELEBIRR",
@@ -51,10 +51,20 @@ const PROVIDERS: ProviderOption[] = [
currencies: ["USD"],
accent: "#8A5A17",
},
{
method: "CBE_BILL",
label: "CBE bill payment",
description: "Pay at any CBE branch, app or USSD · ETB",
logo: "/assets/edr-logo.png",
currencies: ["ETB"],
accent: "#5B2D8C",
},
];
/** 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,
@@ -172,6 +182,7 @@ export function PaymentMethodModal({
processing,
error,
otp,
bill,
}: {
opened: boolean;
onClose: () => void;
@@ -184,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<PaymentMethod>(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(() => {
@@ -209,6 +227,101 @@ export function PaymentMethodModal({
const needsMobile = isOtpMethod(method);
const canSubmit = !needsMobile || mobile.trim().length > 0;
if (bill?.open) {
return (
<Modal
opened={opened}
onClose={bill.close}
centered
radius={18}
size={440}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
>
<Box px={24} py={24}>
<Text fw={800} fz="18px" c="#10202F">
Pay at CBE
</Text>
<Text mt={4} fz="13px" c="#7A8794">
{bill.instructions ??
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
</Text>
<Group
mt={18}
justify="space-between"
align="center"
px={16}
py={13}
style={{ borderRadius: 12, border: "1.5px solid #E6ECF1" }}
>
<Text
ff="monospace"
fz="22px"
fw={800}
c="#10202F"
style={{ letterSpacing: 2 }}
>
{bill.billReference}
</Text>
<Button
variant="light"
size="xs"
leftSection={copied ? <Check size={14} /> : undefined}
onClick={() => {
if (bill.billReference) {
navigator.clipboard?.writeText(bill.billReference);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}}
>
{copied ? "Copied" : "Copy"}
</Button>
</Group>
{amountLabel && (
<Text mt={12} fz="13px" c="#7A8794">
Amount due: <Text span fw={700} c="#10202F">{amountLabel}</Text>
</Text>
)}
{bill.expiresAt && (
<Text mt={4} fz="13px" c="#7A8794">
Pay before:{" "}
<Text span fw={700} c="#10202F">
{new Date(bill.expiresAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Text>
)}
<Text mt={14} fz="11.5px" c="#9AA8B5">
This page updates automatically once CBE confirms your payment.
</Text>
<Button
fullWidth
mt={18}
variant="default"
radius={12}
onClick={bill.close}
styles={{
root: { height: 44 },
label: { fontSize: 13.5, fontWeight: 700, color: "#475569" },
}}
>
Close (pay later)
</Button>
</Box>
</Modal>
);
}
if (otp?.open) {
return (
<Modal

View File

@@ -12,7 +12,8 @@ export type PaymentMethod =
| "WAAFI"
| "CARD"
| "DMONEY"
| "CAC_BANK";
| "CAC_BANK"
| "CBE_BILL";
export type PaymentPlatform = "web" | "mobile";
@@ -26,13 +27,17 @@ export interface InitiatePaymentPayload {
}
export interface ClientAction {
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
url?: string;
appId?: string;
receiveCode?: string;
shortCode?: string;
providerOrderId?: string;
message?: string;
/** SHOW_BILL_REFERENCE (CBE bill payment) */
billReference?: string;
instructions?: string;
expiresAt?: string;
}
export interface InitiateResponse {