mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
fix: booking payment
This commit is contained in:
@@ -100,4 +100,10 @@ export const URL_CONSTANTS = {
|
|||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
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 { Box, Group, Text } from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { CreditCard, Download } from "lucide-react";
|
import { CreditCard, Download } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
@@ -13,21 +15,30 @@ import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout
|
|||||||
import { CancelledBanner } from "./components/Notices";
|
import { CancelledBanner } from "./components/Notices";
|
||||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||||
|
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||||
import { PaymentCard } from "./components/pricing";
|
import { PaymentCard } from "./components/pricing";
|
||||||
import { ScheduleCard } from "./components/ScheduleCard";
|
import { ScheduleCard } from "./components/ScheduleCard";
|
||||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||||
import { StatusHero } from "./components/StatusHero";
|
import { StatusHero } from "./components/StatusHero";
|
||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { fmtDate, isNegative } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
|
|
||||||
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const status = booking.status as string;
|
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({
|
const payMutation = useMutation({
|
||||||
mutationFn: () => api.bookings.pay.call({ id: booking.id }),
|
mutationFn: (method: PaymentMethod) =>
|
||||||
onSuccess: (data) => {
|
api.payments.initiate.call({ bookingId: booking.id, method }),
|
||||||
if (data.redirectUrl) window.location.href = data.redirectUrl;
|
onSuccess: (_data, method) => {
|
||||||
|
window.location.href = paymentsService.checkoutUrl({
|
||||||
|
bookingId: booking.id,
|
||||||
|
method,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,9 +58,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
<HeaderButton
|
<HeaderButton
|
||||||
green
|
green
|
||||||
icon={<CreditCard size={16} />}
|
icon={<CreditCard size={16} />}
|
||||||
label={payMutation.isPending ? "Processing…" : "Pay now"}
|
label="Pay now"
|
||||||
onClick={() => payMutation.mutate()}
|
onClick={() => setPayModalOpen(true)}
|
||||||
disabled={payMutation.isPending}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -128,7 +138,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
{showCountdown && (
|
{showCountdown && (
|
||||||
<PaymentDeadlineCard
|
<PaymentDeadlineCard
|
||||||
paymentDeadline={booking.paymentDeadline!}
|
paymentDeadline={booking.paymentDeadline!}
|
||||||
onPay={() => payMutation.mutate()}
|
onPay={() => setPayModalOpen(true)}
|
||||||
paying={payMutation.isPending}
|
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>
|
</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,
|
CreateBookingPayload,
|
||||||
GeneratePriceResponse,
|
GeneratePriceResponse,
|
||||||
} from "./bookings.service";
|
} from "./bookings.service";
|
||||||
|
import {
|
||||||
|
paymentsService,
|
||||||
|
InitiatePaymentPayload,
|
||||||
|
InitiateResponse,
|
||||||
|
IntentStatus,
|
||||||
|
} from "./payments.service";
|
||||||
import { consignmentsService } from "./consignments.service";
|
import { consignmentsService } from "./consignments.service";
|
||||||
import { trackingService } from "./tracking.service";
|
import { trackingService } from "./tracking.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
@@ -174,12 +180,6 @@ export const api = {
|
|||||||
bookingsService.uploadDocuments(id, files),
|
bookingsService.uploadDocuments(id, files),
|
||||||
),
|
),
|
||||||
|
|
||||||
pay: endpoint<{ id: string }, { redirectUrl: string }>(
|
|
||||||
"bookings",
|
|
||||||
"pay",
|
|
||||||
({ id }) => bookingsService.pay(id),
|
|
||||||
),
|
|
||||||
|
|
||||||
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"checkPayment",
|
"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: {
|
consignments: {
|
||||||
list: endpoint<void, PaginatedResponse<Freight.IConsignment>>(
|
list: endpoint<void, PaginatedResponse<Freight.IConsignment>>(
|
||||||
"consignments",
|
"consignments",
|
||||||
|
|||||||
@@ -139,11 +139,6 @@ export const bookingsService = {
|
|||||||
return data.data ?? data;
|
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 (
|
signContract: async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: SignContractPayload,
|
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