mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: booking payment
This commit is contained in:
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
86
apps/edr-freight-web/portal/src/services/payments.service.ts
Normal file
86
apps/edr-freight-web/portal/src/services/payments.service.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user