mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
add cancellation for booking
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'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}
|
{viewer}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user