mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add cancellation for booking
This commit is contained in:
@@ -429,6 +429,20 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer self-service cancel, allowed only before payment — no fee.
|
||||
* SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses
|
||||
* take the plain cancel path (open invoices expired, nothing reserved yet).
|
||||
* Anything past payment falls through to cancel()'s status assertion.
|
||||
*/
|
||||
async customerCancel(bookingId: string, reason?: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (booking.status === "SELECTED_FOR_BATCH") {
|
||||
return this.cancelHold(bookingId, reason);
|
||||
}
|
||||
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
|
||||
}
|
||||
|
||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
@@ -978,6 +992,15 @@ export class BookingTransitionService {
|
||||
// booking through the space checks below AND is persisted so the accept /
|
||||
// reserve path locks onto that train (pickExportSchedule honors it).
|
||||
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
|
||||
// Export rail rides the exact train the customer picked — never an
|
||||
// auto-assigned one. Both portal flows (clearance + contract completion)
|
||||
// surface a picker, so a missing id is an invalid submission, not a
|
||||
// legitimate "let the system choose".
|
||||
if (isExportTrain && !requestedId) {
|
||||
throw new BadRequestException(
|
||||
"Select a train for the chosen shipment day.",
|
||||
);
|
||||
}
|
||||
const scheduledBooking = {
|
||||
...booking,
|
||||
scheduledDate: date,
|
||||
|
||||
@@ -1335,6 +1335,19 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/customer-cancel")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer cancels their own booking before payment — no cancellation fee",
|
||||
})
|
||||
async customerCancel(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.customerCancel(id, dto.reason);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/cancel-hold")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
|
||||
@@ -450,6 +450,35 @@ export default function ContractRequestDetailPage() {
|
||||
description={statusMeta.description}
|
||||
/>
|
||||
|
||||
{/* A contract resting in APPROVED means the automatic PDF generation on
|
||||
final approval failed — on success it moves straight to
|
||||
CONTRACT_READY. Offer the manual retry. */}
|
||||
{contract.status === "APPROVED" ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Contract document was not generated"
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Text size="sm">
|
||||
All approvals are complete, but generating the contract PDF
|
||||
failed. Retry the generation below.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
Regenerate contract
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
|
||||
<Alert
|
||||
color="red"
|
||||
|
||||
@@ -916,6 +916,11 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
{schedule.route?.name ?? "Train schedule"}
|
||||
</Title>
|
||||
{schedule.train?.trainName ? (
|
||||
<Text fw={700} style={{ color: "#0f172a" }}>
|
||||
{schedule.train.trainName}
|
||||
</Text>
|
||||
) : null}
|
||||
{schedule.train ? (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
Train {schedule.train.code}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Group, Tabs } from "@mantine/core";
|
||||
import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Clock,
|
||||
CreditCard,
|
||||
@@ -7,9 +8,12 @@ import {
|
||||
Package,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||
@@ -45,6 +49,27 @@ import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||
|
||||
// Pre-payment statuses the customer may self-cancel from this view (free of
|
||||
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
|
||||
// simply be deleted; anything at or past payment must go through support.
|
||||
const CUSTOMER_CANCELLABLE_STATUSES = [
|
||||
"SUBMITTED",
|
||||
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||
"PENDING_APPROVAL",
|
||||
"CONTRACT_READY",
|
||||
"OPERATION_REQUEST_PENDING",
|
||||
"SELECTED_FOR_BATCH",
|
||||
];
|
||||
|
||||
const cancelErrorMessage = (error: unknown) => {
|
||||
const data = (
|
||||
error as { response?: { data?: { message?: string | string[] } } }
|
||||
)?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return "Could not cancel the booking. Please try again.";
|
||||
};
|
||||
|
||||
export function ReadonlyBookingView({
|
||||
booking,
|
||||
onBookingUpdated,
|
||||
@@ -71,6 +96,23 @@ export function ReadonlyBookingView({
|
||||
// and handles redirect vs CAC Bank OTP.
|
||||
const pay = useBookingPayment(booking.id);
|
||||
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => bookingsService.customerCancel(booking.id),
|
||||
onSuccess: () => {
|
||||
setCancelOpen(false);
|
||||
toast.success(
|
||||
"Your booking has been cancelled — no cancellation fee was charged.",
|
||||
{ duration: 6000 },
|
||||
);
|
||||
onBookingUpdated?.();
|
||||
},
|
||||
onError: (e) => toast.error(cancelErrorMessage(e)),
|
||||
});
|
||||
const canCancel =
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
CUSTOMER_CANCELLABLE_STATUSES.includes(status);
|
||||
|
||||
const pricing = booking.pricingBreakdown;
|
||||
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
||||
// enters batch selection. A one-time booking can only pay once it's been
|
||||
@@ -149,6 +191,7 @@ export function ReadonlyBookingView({
|
||||
menuActions={{
|
||||
onRebook: canSelfRebook ? onRebook : undefined,
|
||||
onSupport: () => navigate("/support"),
|
||||
onCancel: canCancel ? () => setCancelOpen(true) : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -313,6 +356,52 @@ export function ReadonlyBookingView({
|
||||
bill={pay.bill}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
<Modal
|
||||
opened={cancelOpen}
|
||||
onClose={() => setCancelOpen(false)}
|
||||
title={
|
||||
<Text fw={800} fz={18} c="#10202F">
|
||||
Cancel this booking?
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
radius={16}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="#475569">
|
||||
You're about to cancel booking{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
. Since you haven't paid yet,{" "}
|
||||
<Text span fw={700}>
|
||||
no cancellation fee
|
||||
</Text>{" "}
|
||||
will be charged
|
||||
{status === "SELECTED_FOR_BATCH"
|
||||
? ", and your reserved wagon space will be released immediately"
|
||||
: ""}
|
||||
. This cannot be undone.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={10}
|
||||
onClick={() => setCancelOpen(false)}
|
||||
>
|
||||
Keep booking
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius={10}
|
||||
loading={cancelMutation.isPending}
|
||||
onClick={() => cancelMutation.mutate()}
|
||||
>
|
||||
Cancel booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{viewer}
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -284,6 +284,10 @@ function NewShipmentBookingForm({
|
||||
unitOfMeasure: bulkUnitOfMeasure(contract),
|
||||
// Intercity rides a passing train staff pick later — no date to choose.
|
||||
requiresDate: contract.tradeDirection !== "DOMESTIC",
|
||||
// Export completion locks onto a specific train — the pick is required
|
||||
// (mirrors the ScheduleStep picker's visibility).
|
||||
requiresTrain:
|
||||
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId),
|
||||
}),
|
||||
),
|
||||
mode: "onChange",
|
||||
@@ -1179,12 +1183,23 @@ function ScheduleStep({
|
||||
</Text>
|
||||
)}
|
||||
{isExportPick && scheduledDate ? (
|
||||
<ExportTrainPicker
|
||||
options={exportTrainsQuery.data ?? []}
|
||||
loading={exportTrainsQuery.isLoading}
|
||||
value={selectedTrainId ?? ""}
|
||||
onChange={(id) => form.setValue("trainScheduleId", id)}
|
||||
/>
|
||||
<>
|
||||
<ExportTrainPicker
|
||||
options={exportTrainsQuery.data ?? []}
|
||||
loading={exportTrainsQuery.isLoading}
|
||||
value={selectedTrainId ?? ""}
|
||||
onChange={(id) =>
|
||||
form.setValue("trainScheduleId", id, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.trainScheduleId?.message && (
|
||||
<Text fz="xs" c="red" mt={6}>
|
||||
{String(form.formState.errors.trainScheduleId.message)}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface ShipmentValidationContext {
|
||||
* staff pick later, so no shipment day is chosen. Defaults to true.
|
||||
*/
|
||||
requiresDate?: boolean;
|
||||
/**
|
||||
* EXPORT rail completion: the shipment must ride a specific train the
|
||||
* customer picks for the chosen day. Defaults to false.
|
||||
*/
|
||||
requiresTrain?: boolean;
|
||||
}
|
||||
|
||||
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
|
||||
@@ -102,6 +107,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
});
|
||||
}
|
||||
|
||||
// Train is only pickable once a day is chosen — the day error covers the
|
||||
// no-date case, so don't stack a second error on an invisible field.
|
||||
if (
|
||||
ctx.requiresTrain &&
|
||||
data.scheduledDate.trim() &&
|
||||
!data.trainScheduleId.trim()
|
||||
) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["trainScheduleId"],
|
||||
message: "Select a train for your shipment day.",
|
||||
});
|
||||
}
|
||||
|
||||
// No default currency — the customer must pick one before submitting.
|
||||
if (!data.paymentCurrency) {
|
||||
refineCtx.addIssue({
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createShipmentFormSchema, initialShipmentFormValues } from "./schema";
|
||||
|
||||
const schema = createShipmentFormSchema({
|
||||
isContainer: false,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
requiresTrain: true,
|
||||
});
|
||||
|
||||
const values = (over: Record<string, unknown> = {}) => ({
|
||||
...initialShipmentFormValues,
|
||||
cargoWeightTons: "10",
|
||||
paymentCurrency: "USD",
|
||||
scheduledDate: "2026-08-10",
|
||||
...over,
|
||||
});
|
||||
|
||||
const trainIssue = (input: Record<string, unknown>) => {
|
||||
const result = schema.safeParse(input);
|
||||
return result.success
|
||||
? undefined
|
||||
: result.error.issues.find((i) => i.path[0] === "trainScheduleId");
|
||||
};
|
||||
|
||||
describe("requiresTrain", () => {
|
||||
it("rejects a dated export completion without a train pick", () => {
|
||||
expect(trainIssue(values())?.message).toMatch(/select a train/i);
|
||||
});
|
||||
|
||||
it("passes once a train is picked", () => {
|
||||
expect(trainIssue(values({ trainScheduleId: "sched-1" }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays silent while no date is chosen (day error covers it)", () => {
|
||||
expect(trainIssue(values({ scheduledDate: "" }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is off by default (non-completion flows)", () => {
|
||||
const plain = createShipmentFormSchema({
|
||||
isContainer: false,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
});
|
||||
const result = plain.safeParse(values());
|
||||
expect(
|
||||
result.success ||
|
||||
result.error.issues.every((i) => i.path[0] !== "trainScheduleId"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -315,6 +315,16 @@ export const bookingsService = {
|
||||
return data.data;
|
||||
},
|
||||
|
||||
customerCancel: async (
|
||||
id: string,
|
||||
reason?: string,
|
||||
): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(`/api/bookings/${id}/customer-cancel`, {
|
||||
reason,
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
|
||||
reject: async (id: string, reason?: string): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(`/api/bookings/${id}/reject`, { reason });
|
||||
return data.data;
|
||||
|
||||
Reference in New Issue
Block a user