add cancellation for booking

This commit is contained in:
Marshal
2026-08-06 20:05:13 +00:00
parent 8e75ebf5dc
commit 9a50df2be3
9 changed files with 262 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;re about to cancel booking{" "}
<Text span fw={700} c="#10202F">
{booking.reference}
</Text>
. Since you haven&apos;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>
);

View File

@@ -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>
)}

View File

@@ -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({

View File

@@ -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);
});
});

View File

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