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; 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> { async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [ assertBookingStatus(booking, [
@@ -978,6 +992,15 @@ export class BookingTransitionService {
// booking through the space checks below AND is persisted so the accept / // booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it). // reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null; 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 = { const scheduledBooking = {
...booking, ...booking,
scheduledDate: date, scheduledDate: date,

View File

@@ -1335,6 +1335,19 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); 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") @Post(":id/cancel-hold")
@ApiOperation({ @ApiOperation({
summary: summary:

View File

@@ -450,6 +450,35 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description} 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 ? ( {contract.status === "REJECTED" && contract.latestRejectionNote ? (
<Alert <Alert
color="red" color="red"

View File

@@ -916,6 +916,11 @@ export default function TrainScheduleV2DetailPage() {
<Title order={2} fw={700} style={{ color: "#0f172a" }}> <Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"} {schedule.route?.name ?? "Train schedule"}
</Title> </Title>
{schedule.train?.trainName ? (
<Text fw={700} style={{ color: "#0f172a" }}>
{schedule.train.trainName}
</Text>
) : null}
{schedule.train ? ( {schedule.train ? (
<Text size="xs" c="dimmed" ff="monospace"> <Text size="xs" c="dimmed" ff="monospace">
Train {schedule.train.code} 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 { import {
Clock, Clock,
CreditCard, CreditCard,
@@ -7,9 +8,12 @@ import {
Package, Package,
Truck, Truck,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -45,6 +49,27 @@ import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash"; import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment"; 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({ export function ReadonlyBookingView({
booking, booking,
onBookingUpdated, onBookingUpdated,
@@ -71,6 +96,23 @@ export function ReadonlyBookingView({
// and handles redirect vs CAC Bank OTP. // and handles redirect vs CAC Bank OTP.
const pay = useBookingPayment(booking.id); 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; const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never // 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 // enters batch selection. A one-time booking can only pay once it's been
@@ -149,6 +191,7 @@ export function ReadonlyBookingView({
menuActions={{ menuActions={{
onRebook: canSelfRebook ? onRebook : undefined, onRebook: canSelfRebook ? onRebook : undefined,
onSupport: () => navigate("/support"), onSupport: () => navigate("/support"),
onCancel: canCancel ? () => setCancelOpen(true) : undefined,
}} }}
/> />
@@ -313,6 +356,52 @@ export function ReadonlyBookingView({
bill={pay.bill} bill={pay.bill}
onConfirm={pay.confirm} 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} {viewer}
</PageShell> </PageShell>
); );

View File

@@ -284,6 +284,10 @@ function NewShipmentBookingForm({
unitOfMeasure: bulkUnitOfMeasure(contract), unitOfMeasure: bulkUnitOfMeasure(contract),
// Intercity rides a passing train staff pick later — no date to choose. // Intercity rides a passing train staff pick later — no date to choose.
requiresDate: contract.tradeDirection !== "DOMESTIC", 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", mode: "onChange",
@@ -1179,12 +1183,23 @@ function ScheduleStep({
</Text> </Text>
)} )}
{isExportPick && scheduledDate ? ( {isExportPick && scheduledDate ? (
<ExportTrainPicker <>
options={exportTrainsQuery.data ?? []} <ExportTrainPicker
loading={exportTrainsQuery.isLoading} options={exportTrainsQuery.data ?? []}
value={selectedTrainId ?? ""} loading={exportTrainsQuery.isLoading}
onChange={(id) => form.setValue("trainScheduleId", id)} 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} ) : null}
</Box> </Box>
)} )}

View File

@@ -30,6 +30,11 @@ export interface ShipmentValidationContext {
* staff pick later, so no shipment day is chosen. Defaults to true. * staff pick later, so no shipment day is chosen. Defaults to true.
*/ */
requiresDate?: boolean; 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. // 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. // No default currency — the customer must pick one before submitting.
if (!data.paymentCurrency) { if (!data.paymentCurrency) {
refineCtx.addIssue({ 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; 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> => { reject: async (id: string, reason?: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/reject`, { reason }); const { data } = await client.post(`/api/bookings/${id}/reject`, { reason });
return data.data; return data.data;