mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
add payment success and failure pages with environment variable support
This commit is contained in:
@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
|
|||||||
TELEBIRR_PRIVATE_KEY=
|
TELEBIRR_PRIVATE_KEY=
|
||||||
TELEBIRR_PUBLIC_KEY=
|
TELEBIRR_PUBLIC_KEY=
|
||||||
TELEBIRR_INSECURE_TLS=false
|
TELEBIRR_INSECURE_TLS=false
|
||||||
|
|
||||||
|
# Portal pages the payment provider redirects the browser to after payment.
|
||||||
|
# Point these at the freight portal's public payment result routes.
|
||||||
|
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
||||||
|
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
|
||||||
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
JWT_ACCESS_TOKEN_SECRET=
|
JWT_ACCESS_TOKEN_SECRET=
|
||||||
|
|||||||
@@ -157,8 +157,8 @@ export class PaymentService {
|
|||||||
provider: dto.method as unknown as ProviderMethod,
|
provider: dto.method as unknown as ProviderMethod,
|
||||||
platform: dto.platform,
|
platform: dto.platform,
|
||||||
payerAccount: dto.payerAccount,
|
payerAccount: dto.payerAccount,
|
||||||
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
|
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
||||||
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
|
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
||||||
});
|
});
|
||||||
|
|
||||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||||
|
|||||||
@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
|
|||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
}
|
}
|
||||||
|
// A train can be rescheduled (with or without bookings) at any time UNLESS it
|
||||||
|
// is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
|
||||||
|
// cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
|
||||||
if (schedule.status === TrainScheduleStatus.Dispatched) {
|
if (schedule.status === TrainScheduleStatus.Dispatched) {
|
||||||
throw new BadRequestException('Cannot reschedule a dispatched train');
|
throw new BadRequestException('Cannot reschedule a train that is already dispatched');
|
||||||
|
}
|
||||||
|
if (schedule.status === TrainScheduleStatus.Arrived) {
|
||||||
|
throw new BadRequestException('Cannot reschedule a train that has already arrived');
|
||||||
|
}
|
||||||
|
if (schedule.status === TrainScheduleStatus.Cancelled) {
|
||||||
|
throw new BadRequestException('Cannot reschedule a cancelled train');
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentOnSchedule = (schedule.scheduleBookings ?? [])
|
const currentOnSchedule = (schedule.scheduleBookings ?? [])
|
||||||
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
// A train can be rescheduled even with no bookings (e.g. moved for
|
||||||
bookingIds: dto.finalBookingIds,
|
// maintenance). assignBookingsToSchedule requires at least one booking, so
|
||||||
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
// only call it when something is actually being (re)assigned — the new
|
||||||
});
|
// departure date above is the meaningful change for an empty train. The
|
||||||
|
// empty-train branch returns the same schedule-detail shape as the assign
|
||||||
|
// path so callers get a consistent response.
|
||||||
|
const assignResult = dto.finalBookingIds.length
|
||||||
|
? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||||
|
bookingIds: dto.finalBookingIds,
|
||||||
|
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
...(await this.trainSchedulingService.getContainerTrainScheduleById(
|
||||||
|
scheduleId,
|
||||||
|
)),
|
||||||
|
warnings: [] as string[],
|
||||||
|
deferredBookings: [] as unknown[],
|
||||||
|
};
|
||||||
|
|
||||||
await this.schedulingRescheduleRepository.createEvent({
|
await this.schedulingRescheduleRepository.createEvent({
|
||||||
trainScheduleId: scheduleId,
|
trainScheduleId: scheduleId,
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import EditBookingPage from "./pages/bookings/EditBookingPage";
|
|||||||
import MyBookings from "./pages/bookings/MyBookings";
|
import MyBookings from "./pages/bookings/MyBookings";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||||
|
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||||
|
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||||
|
|
||||||
function FullScreenSpinner() {
|
function FullScreenSpinner() {
|
||||||
@@ -158,6 +160,9 @@ const App = () => {
|
|||||||
path="/booking/check-status/:orderId"
|
path="/booking/check-status/:orderId"
|
||||||
element={<CheckPaymentPage />}
|
element={<CheckPaymentPage />}
|
||||||
/>
|
/>
|
||||||
|
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
||||||
|
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
||||||
|
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
||||||
|
|
||||||
{/* Auth pages — inaccessible once logged in */}
|
{/* Auth pages — inaccessible once logged in */}
|
||||||
<Route element={<RedirectIfAuthed />}>
|
<Route element={<RedirectIfAuthed />}>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { XCircle } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@edr/ui-common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public page the payment provider redirects the browser to after a failed or
|
||||||
|
* cancelled payment (PAYMENT_FAILURE_URL). Generic — it explains nothing was
|
||||||
|
* charged and sends the customer back to their bookings to retry from "Pay now".
|
||||||
|
*/
|
||||||
|
export default function PaymentFailurePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<XCircle className="size-9 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-foreground">
|
||||||
|
Payment was not completed
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your payment didn't go through and you haven't been charged. You can
|
||||||
|
try again from your booking using "Pay now".
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex w-full flex-col gap-2">
|
||||||
|
<Button type="button" onClick={() => navigate("/bookings")}>
|
||||||
|
Back to My Bookings
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
Back to home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@edr/ui-common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public page the payment provider redirects the browser to after a successful
|
||||||
|
* payment (PAYMENT_RETURN_URL). Generic — it confirms success and points the
|
||||||
|
* customer back to their bookings, where the booking reflects the paid state.
|
||||||
|
*/
|
||||||
|
export default function PaymentSuccessPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<CheckCircle2 className="size-9 text-primary" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-foreground">
|
||||||
|
Payment successful
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Thank you — your payment has been received. Your booking will be
|
||||||
|
updated shortly and is now confirmed for scheduling.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex w-full flex-col gap-2">
|
||||||
|
<Button type="button" onClick={() => navigate("/bookings")}>
|
||||||
|
Go to My Bookings
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
Back to home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user