From 16f16e7d7e3521fabaeec8d10a487206ca317d74 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 16 Jun 2026 12:14:30 +0000 Subject: [PATCH] fix: booking payment --- .../portal/src/constants/URLS.ts | 6 + .../BookingDetailPage/ReadonlyBookingView.tsx | 46 +++- .../components/PaymentMethodModal.tsx | 203 ++++++++++++++++++ .../portal/src/services/api.ts | 26 ++- .../portal/src/services/bookings.service.ts | 5 - .../portal/src/services/payments.service.ts | 86 ++++++++ 6 files changed, 353 insertions(+), 19 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx create mode 100644 apps/edr-freight-web/portal/src/services/payments.service.ts 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, +};