fix: booking payment

This commit is contained in:
Nathnael
2026-06-16 12:14:30 +00:00
parent 1d4edfc98c
commit 16f16e7d7e
6 changed files with 353 additions and 19 deletions

View File

@@ -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",
},
};

View File

@@ -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 })
<HeaderButton
green
icon={<CreditCard size={16} />}
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 && (
<PaymentDeadlineCard
paymentDeadline={booking.paymentDeadline!}
onPay={() => payMutation.mutate()}
onPay={() => setPayModalOpen(true)}
paying={payMutation.isPending}
/>
)}
@@ -142,6 +152,26 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
</>
}
/>
<PaymentMethodModal
opened={payModalOpen}
onClose={() => {
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)}
/>
</PageShell>
);
}

View File

@@ -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 (
<Group
onClick={onSelect}
gap={12}
wrap="nowrap"
style={{
cursor: "pointer",
borderRadius: 12,
padding: "13px 14px",
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
backgroundColor: selected ? "#ECF6F1" : "#fff",
transition: "border-color .12s, background-color .12s",
}}
>
<Box
style={{
width: 40,
height: 40,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
color: selected ? "#fff" : "#475569",
}}
>
<Icon size={19} />
</Box>
<Box style={{ flex: 1 }}>
<Text fz="14px" fw={700} c="#10202F">
{option.label}
</Text>
<Text fz="12.5px" c="#9AA8B5">
{option.description}
</Text>
</Box>
<Box
style={{
width: 18,
height: 18,
flexShrink: 0,
borderRadius: "50%",
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
backgroundColor: selected ? "#0A6F4D" : "transparent",
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
}}
/>
</Group>
);
}
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<PaymentMethod | null>(null);
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size={460}
title={
<Stack gap={2}>
<Text fw={800} fz="17px" c="#10202F">
Choose a payment method
</Text>
{amountLabel && (
<Text fz="12.5px" c="#9AA8B5">
Amount due: {amountLabel}
</Text>
)}
</Stack>
}
>
<Stack gap={10}>
{PROVIDERS.map((option) => (
<ProviderRow
key={option.method}
option={option}
selected={method === option.method}
onSelect={() => setMethod(option.method)}
/>
))}
{error && (
<Text fz="12.5px" c="#C0392B" fw={600}>
{error}
</Text>
)}
<Button
fullWidth
mt={6}
radius={10}
color="edr-green"
disabled={!method || processing}
loading={processing}
onClick={() => method && onConfirm(method)}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
</Button>
<Text fz="11.5px" c="#9AA8B5" ta="center">
You'll be redirected to your provider to complete payment securely.
</Text>
</Stack>
</Modal>
);
}

View File

@@ -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<InitiatePaymentPayload, InitiateResponse>(
"payments",
"initiate",
paymentsService.initiate,
),
getIntent: endpoint<{ bookingId: string }, IntentStatus>(
"payments",
"getIntent",
({ bookingId }) => paymentsService.getIntent(bookingId),
),
},
consignments: {
list: endpoint<void, PaginatedResponse<Freight.IConsignment>>(
"consignments",

View File

@@ -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,

View File

@@ -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<InitiateResponse> => {
const { data } = await client.post(P.INITIATE, {
platform: "web",
...payload,
});
return data.data ?? data;
},
getIntent: async (bookingId: string): Promise<IntentStatus> => {
const { data } = await client.get(P.INTENT(bookingId));
return data.data ?? data;
},
checkoutUrl: buildCheckoutUrl,
};