diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 181c47b2d..52def42f0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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 { + 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 { 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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 837d0d801..e57f94eae 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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: diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 0caea8df5..8c0ef7b5c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -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" ? ( + } + title="Contract document was not generated" + > + + + All approvals are complete, but generating the contract PDF + failed. Retry the generation below. + + + + + ) : null} + {contract.status === "REJECTED" && contract.latestRejectionNote ? ( {schedule.route?.name ?? "Train schedule"} + {schedule.train?.trainName ? ( + + {schedule.train.trainName} + + ) : null} {schedule.train ? ( Train {schedule.train.code} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index b26a6562a..ba8a6d820 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -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} /> + setCancelOpen(false)} + title={ + + Cancel this booking? + + } + centered + radius={16} + > + + + You're about to cancel booking{" "} + + {booking.reference} + + . Since you haven't paid yet,{" "} + + no cancellation fee + {" "} + will be charged + {status === "SELECTED_FOR_BATCH" + ? ", and your reserved wagon space will be released immediately" + : ""} + . This cannot be undone. + + + + + + + {viewer} ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 54f678049..aa4ce723a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -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({ )} {isExportPick && scheduledDate ? ( - form.setValue("trainScheduleId", id)} - /> + <> + + form.setValue("trainScheduleId", id, { + shouldValidate: true, + }) + } + /> + {form.formState.errors.trainScheduleId?.message && ( + + {String(form.formState.errors.trainScheduleId.message)} + + )} + ) : null} )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index 825112980..a156a2174 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -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({ diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/train-required.test.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/train-required.test.ts new file mode 100644 index 000000000..edf4ec284 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/train-required.test.ts @@ -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 = {}) => ({ + ...initialShipmentFormValues, + cargoWeightTons: "10", + paymentCurrency: "USD", + scheduledDate: "2026-08-10", + ...over, +}); + +const trainIssue = (input: Record) => { + 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); + }); +}); diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 44da51c05..edcf8a76c 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -315,6 +315,16 @@ export const bookingsService = { return data.data; }, + customerCancel: async ( + id: string, + reason?: string, + ): Promise => { + const { data } = await client.post(`/api/bookings/${id}/customer-cancel`, { + reason, + }); + return data.data; + }, + reject: async (id: string, reason?: string): Promise => { const { data } = await client.post(`/api/bookings/${id}/reject`, { reason }); return data.data;