diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index 4e01ba78e..540cedce8 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -100,4 +100,10 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
},
+
+ PAYMENTS: {
+ INITIATE: "/api/payments/initiate",
+ INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
+ CHECKOUT: "/api/payments/checkout",
+ },
};
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx
index 2bc44edab..622227674 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx
@@ -1,9 +1,11 @@
import { Box, Group, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { CreditCard, Download } from "lucide-react";
+import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
+import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import type { Freight } from "@edr/types";
import { ActivityCard } from "./components/ActivityCard";
@@ -13,21 +15,30 @@ import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout
import { CancelledBanner } from "./components/Notices";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
+import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { PaymentCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
-import { fmtDate, isNegative } from "./utils";
+import { fmtDate, isNegative, priceTotal } from "./utils";
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
const navigate = useNavigate();
const status = booking.status as string;
+ const [payModalOpen, setPayModalOpen] = useState(false);
+ // Two-step flow: POST /payments/initiate to create the intent, then send the
+ // browser to the public /payments/checkout page which redirects to the
+ // selected provider to complete payment.
const payMutation = useMutation({
- mutationFn: () => api.bookings.pay.call({ id: booking.id }),
- onSuccess: (data) => {
- if (data.redirectUrl) window.location.href = data.redirectUrl;
+ mutationFn: (method: PaymentMethod) =>
+ api.payments.initiate.call({ bookingId: booking.id, method }),
+ onSuccess: (_data, method) => {
+ window.location.href = paymentsService.checkoutUrl({
+ bookingId: booking.id,
+ method,
+ });
},
});
@@ -47,9 +58,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
}
- label={payMutation.isPending ? "Processing…" : "Pay now"}
- onClick={() => payMutation.mutate()}
- disabled={payMutation.isPending}
+ label="Pay now"
+ onClick={() => setPayModalOpen(true)}
/>
)
}
@@ -128,7 +138,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
{showCountdown && (
payMutation.mutate()}
+ onPay={() => setPayModalOpen(true)}
paying={payMutation.isPending}
/>
)}
@@ -142,6 +152,26 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
>
}
/>
+
+ {
+ if (!payMutation.isPending) {
+ setPayModalOpen(false);
+ payMutation.reset();
+ }
+ }}
+ amountLabel={pricing ? priceTotal(pricing) : undefined}
+ processing={payMutation.isPending}
+ error={
+ payMutation.isError
+ ? payMutation.error instanceof Error
+ ? payMutation.error.message
+ : "Could not start payment. Please try again."
+ : null
+ }
+ onConfirm={(method) => payMutation.mutate(method)}
+ />
);
}
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
new file mode 100644
index 000000000..18ab3a36c
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx
@@ -0,0 +1,203 @@
+import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
+import {
+ Banknote,
+ Building2,
+ CreditCard,
+ Smartphone,
+ Wallet,
+ type LucideIcon,
+} from "lucide-react";
+import { useState } from "react";
+
+import type { PaymentMethod } from "@/services/payments.service";
+
+interface ProviderOption {
+ method: PaymentMethod;
+ label: string;
+ description: string;
+ icon: LucideIcon;
+}
+
+const PROVIDERS: ProviderOption[] = [
+ {
+ method: "TELEBIRR",
+ label: "telebirr",
+ description: "Ethiopian mobile money",
+ icon: Smartphone,
+ },
+ {
+ method: "CBE_BIRR",
+ label: "CBE Birr",
+ description: "Commercial Bank of Ethiopia",
+ icon: Building2,
+ },
+ {
+ method: "EBIRR",
+ label: "E-Birr",
+ description: "Electronic payment gateway",
+ icon: Wallet,
+ },
+ {
+ method: "WAAFI",
+ label: "WAAFI",
+ description: "Djibouti mobile money",
+ icon: Smartphone,
+ },
+ {
+ method: "CARD",
+ label: "Card",
+ description: "Visa / Mastercard",
+ icon: CreditCard,
+ },
+ {
+ method: "DMONEY",
+ label: "D-Money",
+ description: "Djibouti D-money",
+ icon: Banknote,
+ },
+ {
+ method: "CAC_BANK",
+ label: "CAC Bank",
+ description: "CAC Int Bank (OTP)",
+ icon: Building2,
+ },
+];
+
+function ProviderRow({
+ option,
+ selected,
+ onSelect,
+}: {
+ option: ProviderOption;
+ selected: boolean;
+ onSelect: () => void;
+}) {
+ const Icon = option.icon;
+ return (
+
+
+
+
+
+
+ {option.label}
+
+
+ {option.description}
+
+
+
+
+ );
+}
+
+export function PaymentMethodModal({
+ opened,
+ onClose,
+ amountLabel,
+ onConfirm,
+ processing,
+ error,
+}: {
+ opened: boolean;
+ onClose: () => void;
+ /** Human-readable total, e.g. "ETB 12,500". */
+ amountLabel?: string;
+ onConfirm: (method: PaymentMethod) => void;
+ processing?: boolean;
+ error?: string | null;
+}) {
+ const [method, setMethod] = useState(null);
+
+ return (
+
+
+ Choose a payment method
+
+ {amountLabel && (
+
+ Amount due: {amountLabel}
+
+ )}
+
+ }
+ >
+
+ {PROVIDERS.map((option) => (
+ setMethod(option.method)}
+ />
+ ))}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ You'll be redirected to your provider to complete payment securely.
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index e1bd57729..c2a8fb02d 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -14,6 +14,12 @@ import {
CreateBookingPayload,
GeneratePriceResponse,
} from "./bookings.service";
+import {
+ paymentsService,
+ InitiatePaymentPayload,
+ InitiateResponse,
+ IntentStatus,
+} from "./payments.service";
import { consignmentsService } from "./consignments.service";
import { trackingService } from "./tracking.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
@@ -174,12 +180,6 @@ export const api = {
bookingsService.uploadDocuments(id, files),
),
- pay: endpoint<{ id: string }, { redirectUrl: string }>(
- "bookings",
- "pay",
- ({ id }) => bookingsService.pay(id),
- ),
-
checkPayment: endpoint<{ orderId: string }, { status: string }>(
"bookings",
"checkPayment",
@@ -194,6 +194,20 @@ export const api = {
),
},
+ payments: {
+ initiate: endpoint(
+ "payments",
+ "initiate",
+ paymentsService.initiate,
+ ),
+
+ getIntent: endpoint<{ bookingId: string }, IntentStatus>(
+ "payments",
+ "getIntent",
+ ({ bookingId }) => paymentsService.getIntent(bookingId),
+ ),
+ },
+
consignments: {
list: endpoint>(
"consignments",
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index f723e2730..ac6411505 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -139,11 +139,6 @@ export const bookingsService = {
return data.data ?? data;
},
- pay: async (id: string): Promise<{ redirectUrl: string }> => {
- const { data } = await client.post(`/api/bookings/${id}/payment/pay`);
- return data.data ?? data;
- },
-
signContract: async (
id: string,
payload: SignContractPayload,
diff --git a/apps/edr-freight-web/portal/src/services/payments.service.ts b/apps/edr-freight-web/portal/src/services/payments.service.ts
new file mode 100644
index 000000000..3c345f648
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/services/payments.service.ts
@@ -0,0 +1,86 @@
+import { URL_CONSTANTS } from "@/constants/URLS";
+import { client } from "../utils/api";
+
+const P = URL_CONSTANTS.PAYMENTS;
+
+/** Payment methods supported by the central payment microservice. */
+export type PaymentMethod =
+ | "TELEBIRR"
+ | "CBE_BIRR"
+ | "EBIRR"
+ | "WAAFI"
+ | "CARD"
+ | "DMONEY"
+ | "CAC_BANK";
+
+export type PaymentPlatform = "web" | "mobile";
+
+export interface InitiatePaymentPayload {
+ bookingId: string;
+ method: PaymentMethod;
+ platform?: PaymentPlatform;
+ payerAccount?: string;
+ returnUrl?: string;
+ failureUrl?: string;
+}
+
+export interface ClientAction {
+ type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
+ url?: string;
+ appId?: string;
+ receiveCode?: string;
+ shortCode?: string;
+ providerOrderId?: string;
+ message?: string;
+}
+
+export interface InitiateResponse {
+ intentId: string;
+ status: string;
+ clientAction?: ClientAction;
+ merchantOrderId?: string;
+}
+
+export interface IntentStatus extends InitiateResponse {
+ paidAt?: string;
+ failureCode?: string;
+ failureMessage?: string;
+}
+
+/**
+ * Builds the absolute URL for the public browser-checkout page, which
+ * (re)initiates the payment and auto-redirects to the provider's checkout.
+ * Used as the "pay" step after a successful `initiate`.
+ */
+function buildCheckoutUrl(payload: {
+ bookingId: string;
+ method: PaymentMethod;
+ platform?: PaymentPlatform;
+}): string {
+ const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
+ const params = new URLSearchParams({
+ bookingId: payload.bookingId,
+ method: payload.method,
+ platform: payload.platform ?? "web",
+ });
+ return `${base}${P.CHECKOUT}?${params.toString()}`;
+}
+
+export const paymentsService = {
+ initiate: async (
+ payload: InitiatePaymentPayload,
+ ): Promise => {
+ const { data } = await client.post(P.INITIATE, {
+ platform: "web",
+ ...payload,
+ });
+ return data.data ?? data;
+ },
+
+ getIntent: async (bookingId: string): Promise => {
+ const { data } = await client.get(P.INTENT(bookingId));
+ return data.data ?? data;
+ },
+
+ checkoutUrl: buildCheckoutUrl,
+};