mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
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:
@@ -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> = {
|
||||
|
||||
@@ -19,7 +19,8 @@ export type PaymentMethod =
|
||||
| "waafi"
|
||||
| "card"
|
||||
| "dmoney"
|
||||
| "cac-bank";
|
||||
| "cac-bank"
|
||||
| "cbe-bill";
|
||||
|
||||
export interface PaymentRow {
|
||||
id: string;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user