feat(freight): GL rebook of cancelled wagons, seal+train required on completion, train/voyage in SMS

This commit is contained in:
marshal
2026-09-02 21:23:24 +00:00
parent 3e274cc2a2
commit 6efa9dab93
13 changed files with 713 additions and 308 deletions

View File

@@ -14,6 +14,8 @@ import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentModule } from "../payment/payment.module";
import { CompaniesModule } from "../companies/companies.module";
import { FilesModule } from "../files/files.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
@Module({
imports: [
@@ -24,6 +26,10 @@ import { FilesModule } from "../files/files.module";
DocumentsModule,
UserTradeAccessModule,
FilesModule,
// Customer notice when Finance confirms a manual payment. The inbox module
// reaches this one back through CompaniesModule, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
],
controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],

View File

@@ -83,6 +83,8 @@ describe("BillingService.generateInvoice", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
});
@@ -166,6 +168,8 @@ describe("BillingService.issueMemo", () => {
{} as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, manager, savedLines };
}
@@ -301,6 +305,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -357,6 +363,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -403,6 +411,8 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, mg, events };
}
@@ -517,6 +527,8 @@ describe("BillingService.recordPayment", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, mg, events };
}
@@ -635,6 +647,8 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, defaultManager, txManager, transaction };
};
@@ -709,6 +723,8 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, manager };
};
@@ -801,6 +817,8 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, repo };
};
@@ -885,6 +903,8 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, repo };
};
@@ -955,6 +975,8 @@ describe("BillingService.document", () => {
: undefined,
} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, render, renderThermal };
};
@@ -1047,6 +1069,8 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
function makeService(invoiceType: string) {
const invoice = {
id: "inv-1",
invoiceNumber: "INV-001",
companyId: "company-1",
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: invoiceType,
@@ -1057,9 +1081,16 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
const recordPayment = jest.fn().mockResolvedValue(invoice);
const dataSource = {
getRepository: () => ({
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
findOne: async () => ({
id: "booking-1",
reference: "BK-001",
paymentDeadline: PAST,
}),
}),
query: async () => [{ phone: "+251900000000", email: "c@x.com" }],
};
const directSend = jest.fn().mockResolvedValue(undefined);
const notify = jest.fn().mockResolvedValue(undefined);
const service = new BillingService(
dataSource as never,
{ findById: async () => invoice } as never,
@@ -1071,10 +1102,12 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true } as never,
{ directSend } as never,
{ notify } as never,
);
(service as unknown as { recordPayment: unknown }).recordPayment =
recordPayment;
return { service, recordPayment };
return { service, recordPayment, directSend, notify };
}
const slip = { originalname: "slip.pdf" } as never;
@@ -1097,6 +1130,42 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
);
});
it("notifies the customer (inbox + SMS + email) once the payment is confirmed", async () => {
const { service, notify, directSend } = makeService(
WAGON_CANCEL_FEE_INVOICE_TYPE,
);
await service.confirmOfflinePayment("inv-1", slip, {});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({
recipients: { companyId: "company-1" },
type: "PAYMENT_RECEIVED",
link: "/billing/inv-1",
body: expect.stringMatching(/500 ETB .*INV-001 \(booking BK-001\)/),
}),
);
expect(directSend).toHaveBeenCalledWith(
"sms",
"+251900000000",
expect.stringContaining("INV-001"),
);
expect(directSend).toHaveBeenCalledWith(
"email",
"c@x.com",
expect.stringContaining("INV-001"),
);
});
it("still settles when the customer notice fails", async () => {
const { service, notify, recordPayment } = makeService(
WAGON_CANCEL_FEE_INVOICE_TYPE,
);
notify.mockRejectedValueOnce(new Error("inbox down"));
await expect(
service.confirmOfflinePayment("inv-1", slip, {}),
).resolves.toBeDefined();
expect(recordPayment).toHaveBeenCalled();
});
it("still requires the bank slip for a cancellation fee", async () => {
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
await expect(

View File

@@ -1,4 +1,9 @@
import { Freight, PaymentReferenceType } from "@edr/types";
import {
Freight,
NotificationAudience,
NotificationType,
PaymentReferenceType,
} from "@edr/types";
import { ConfigService } from "@nestjs/config";
import {
BadRequestException,
@@ -20,6 +25,10 @@ import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wago
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { resolveShippingLineNotifyTarget } from "../notifications/resolve-shipping-line-contact.util";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
@@ -267,6 +276,8 @@ export class BillingService {
private readonly files: FilesService,
private readonly config: ConfigService,
private readonly manualPaymentSettings: ManualPaymentSettingsService,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -769,8 +780,9 @@ export class BillingService {
uploadedByName: input.userName ?? null,
});
return this.recordPayment(invoiceId, {
amount: Number(invoice.balanceAmount),
const amount = Number(invoice.balanceAmount);
const paid = await this.recordPayment(invoiceId, {
amount,
method: "BANK_TRANSFER",
reference: input.reference || slip.name,
metadata: {
@@ -780,6 +792,104 @@ export class BillingService {
confirmedByName: input.userName ?? null,
},
});
// The customer did not pay through the portal, so nothing else tells them
// Finance has settled their invoice — this is their only confirmation.
await this.notifyCustomerManualPaymentConfirmed(paid, amount);
return paid;
}
/**
* Tell the customer Finance confirmed their manual (bank transfer / counter)
* payment: portal inbox entry plus SMS and email to the company's contact
* (or the shipping line's own contact for a credit invoice). Best-effort —
* a notification failure never undoes the settlement, it is only logged.
*/
private async notifyCustomerManualPaymentConfirmed(
invoice: Invoice,
amount: number,
): Promise<void> {
try {
const bookingRef =
invoice.source === Freight.InvoiceSource.Booking
? await this.bookingReferenceFor(invoice.sourceId)
: null;
const body =
`Your payment of ${round2(amount)} ${invoice.currency} for invoice ${invoice.invoiceNumber}` +
(bookingRef ? ` (booking ${bookingRef})` : "") +
` has been received and confirmed. Thank you.`;
const title = "Payment confirmed";
const data = {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
bookingId: bookingRef ? invoice.sourceId : null,
};
if (invoice.companyId || invoice.companyProfileId) {
await this.inbox.notify({
recipients: invoice.companyId
? { companyId: invoice.companyId }
: { companyProfileId: invoice.companyProfileId! },
audience: NotificationAudience.PORTAL,
type: NotificationType.PAYMENT_RECEIVED,
title,
body,
link: `/billing/${invoice.id}`,
data,
});
if (invoice.companyId) {
await sendCompanyChannels(
this.dataSource,
this.notifications,
invoice.companyId,
body,
);
}
return;
}
if (invoice.shippingLineCompanyId) {
const target = await resolveShippingLineNotifyTarget(
this.dataSource,
invoice.shippingLineCompanyId,
);
if (target.userId) {
await this.inbox.notify({
recipients: { userIds: [target.userId] },
audience: NotificationAudience.PORTAL,
type: NotificationType.PAYMENT_RECEIVED,
title,
body,
link: `/shipping-line/invoices/${invoice.id}`,
data,
});
}
for (const [method, to] of [
["sms", target.phone],
["email", target.email],
] as const) {
if (!to) continue;
try {
await this.notifications.directSend(method, to, body);
} catch {
/* best-effort: provider unavailable */
}
}
}
} catch (err) {
this.logger.warn(
`Manual payment confirmed notify failed for invoice ${invoice.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** Booking reference for a booking id, or null when the booking is gone. */
private async bookingReferenceFor(bookingId: string): Promise<string | null> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
select: ["id", "reference"],
});
return booking?.reference ?? null;
}
/** Invoice header plus its line items. */

View File

@@ -664,9 +664,11 @@ export class BookingsController {
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// GL (createBooking) rebooks credits and must see the ledger for that.
const staff =
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking);
if (!staff) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
@@ -789,6 +791,14 @@ export class BookingsController {
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
// Rebooking a credit creates a booking under the contract — GL's booking
// creation key covers it even where the dedicated rebook key was never granted.
if (
staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook &&
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)
) {
return;
}
const row = await this.wagonCancellationService.findById(cancellationId);
const booking = await this.bookingsService.findById(row.bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(

View File

@@ -2814,6 +2814,11 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.finalizeClearance,
FREIGHT_PERMS.contracts.createBooking,
// GL rebooks cancelled-wagon credits on the customer's behalf — whoever
// cancelled (customer or staff) and whichever side was at fault. Needs to
// see the ledger rows and to redeem the credit.
FREIGHT_PERMS.bookings.wagonCancellationView,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDutyAdvise,
FREIGHT_PERMS.contracts.finalInvoiceConfirm,

View File

@@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/auth/http";
import { toDayString } from "@/hooks/useListControls";
import { formatMoney } from "@/lib/format";
import {
hasOddFt20,
type RebookPartnerCandidate,
type WagonCancellation,
} from "./types";
/** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] =>
(r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || "",
}));
const containersPayload = (drafts: RebookUnitDraft[]) => {
const bySize = new Map<string, RebookUnitDraft[]>();
for (const d of drafts) {
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
}
return [...bySize.entries()].map(([containerSize, units]) => ({
containerSize,
units: units.map((u) => ({
containerNumber: u.containerNumber.trim(),
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
})),
}));
};
/**
* Staff/GL rebook of a CREDIT_AVAILABLE wagon cancellation: pick the shipment
* day, correct container details if they changed, and — for an odd-20ft
* credit — pick the consolidation partner that shares the wagon. The server
* creates the new booking under the contract and marks it PAID from the credit.
* Used by the wagon-cancellations list, the GL clearance page and the staff
* booking page, so every desk gets the same flow.
*/
export function RebookWagonCancellationModal({
cancellation,
onClose,
onRebooked,
}: {
cancellation: WagonCancellation | null;
onClose: () => void;
/** Called after a successful rebook with the new booking id (when the API returns it). */
onRebooked?: (result: { bookingId?: string }) => void;
}) {
const [date, setDate] = useState<Date | null>(null);
const [partnerId, setPartnerId] = useState<string | null>(null);
const [drafts, setDrafts] = useState<RebookUnitDraft[]>([]);
// Fresh form per row: the modal instance is long-lived on the host page.
useEffect(() => {
setDate(null);
setPartnerId(null);
setDrafts(cancellation ? draftsFrom(cancellation) : []);
}, [cancellation]);
const needsPartner = cancellation ? hasOddFt20(cancellation) : false;
const partners = useQuery({
queryKey: [
"wagon-cancellations",
cancellation?.id,
"rebook-partners",
date ? toDayString(date) : null,
],
enabled: Boolean(cancellation && needsPartner && date),
queryFn: async () => {
const res = await api.get<RebookPartnerCandidate[]>(
`/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`,
{ params: { scheduledDate: toDayString(date!) } },
);
return res.data;
},
});
const rebook = useMutation({
mutationFn: async () => {
const res = await api.post<{ bookingId?: string }>(
`/bookings/wagon-cancellations/${cancellation!.id}/rebook`,
{
scheduledDate: toDayString(date!),
...(drafts.length ? { containers: containersPayload(drafts) } : {}),
...(partnerId ? { partnerBookingId: partnerId } : {}),
},
);
return res.data ?? {};
},
});
const patchDraft = (i: number, patch: Partial<RebookUnitDraft>) =>
setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x)));
return (
<Modal
opened={!!cancellation}
onClose={onClose}
title="Rebook cancelled wagons"
centered
radius="md"
>
{cancellation && (
<Stack gap="sm">
<Text size="sm">
{cancellation.booking?.reference ?? cancellation.bookingId} ·{" "}
{cancellation.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)}
</Text>
<DatePickerInput
label="Shipment day"
placeholder="Pick the day"
value={date}
onChange={(v) => {
setDate(v ? new Date(v) : null);
setPartnerId(null);
}}
minDate={new Date()}
radius="md"
/>
{needsPartner && (
<Select
label="Consolidation partner"
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
placeholder={
!date
? "Pick the day first"
: partners.isLoading
? "Loading…"
: "Pick the partner booking"
}
data={(partners.data ?? []).map((c) => ({
value: c.id,
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
}))}
value={partnerId}
onChange={setPartnerId}
disabled={!date}
searchable
radius="md"
/>
)}
{needsPartner &&
date &&
!partners.isLoading &&
(partners.data ?? []).length === 0 && (
<Text size="xs" c="orange">
No odd-20ft booking rides that day pick another day or wait for
a partner booking.
</Text>
)}
{drafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" c="dimmed">
Correct the container details if they changed sizes and
quantities stay as cancelled.
</Text>
{drafts.map((d, i) => (
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
<TextInput
label={`${d.containerSize} container`}
value={d.containerNumber}
onChange={(e) => patchDraft(i, { containerNumber: e.currentTarget.value })}
size="xs"
radius="md"
style={{ flex: 1.4 }}
/>
<TextInput
label="Seal no."
value={d.sealNumber}
onChange={(e) => patchDraft(i, { sealNumber: e.currentTarget.value })}
size="xs"
radius="md"
style={{ flex: 1 }}
/>
<TextInput
label="VGM (t)"
type="number"
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
onChange={(e) => {
const raw = e.currentTarget.value;
patchDraft(i, { vgmTons: raw === "" ? "" : Number(raw) });
}}
size="xs"
radius="md"
style={{ width: 90 }}
/>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Close
</Button>
<Button
color="green"
radius="md"
disabled={!date || (needsPartner && !partnerId)}
loading={rebook.isPending}
onClick={async () => {
try {
const result = await rebook.mutateAsync();
toast.success("Credit rebooked as a new paid booking");
onClose();
onRebooked?.(result);
} catch {
// interceptor surfaces the reason
}
}}
>
Rebook
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -0,0 +1,141 @@
import { useState } from "react";
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
import { Link } from "react-router-dom";
import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { formatDate, formatMoney } from "@/lib/format";
import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal";
import {
canRebookWagonCancellations,
isRebookableCredit,
WAGON_CANCELLATION_STATUS_CHIP,
type WagonCancellation,
type WagonCancellationListResponse,
} from "./types";
/**
* Per-booking wagon-cancellation ledger with the GL/staff "Rebook" action.
* Rendered on the booking pages GL and staff actually work from, so a credit
* never sits unredeemed just because the customer cannot rebook it from the
* portal (customs bookings, odd-20ft credits) — whoever cancelled the wagons
* and whichever side was at fault. Renders nothing when the booking has no
* cancellations.
*/
export function WagonCancellationCreditCard({
bookingId,
onRebooked,
}: {
bookingId: string;
onRebooked?: () => void;
}) {
const { user } = useAuth();
const qc = useQueryClient();
const canRebook = canRebookWagonCancellations(user);
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
const { data, refetch } = useQuery({
queryKey: ["bookings", bookingId, "wagon-cancellations"],
queryFn: async () => {
const res = await api.get<WagonCancellationListResponse | WagonCancellation[]>(
`/bookings/${bookingId}/wagon-cancellations`,
);
const body = res.data;
return Array.isArray(body) ? body : (body?.items ?? []);
},
});
const rows = data ?? [];
if (rows.length === 0) return null;
return (
<>
<SectionCard
icon={RotateCcw}
title="Cancelled wagons"
subtitle="Credits from cancelled wagons are rebooked here on the customer's behalf."
accent="grape"
>
<Stack gap="sm">
{rows.map((r) => {
const chip = WAGON_CANCELLATION_STATUS_CHIP[r.status] ?? {
label: r.status,
color: "gray",
};
const feeOpen =
r.fault === "CUSTOMER" && Number(r.feeAmount) > 0 && !r.feePaidAt;
return (
<Group key={r.id} justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600}>
{Number(r.wagonsCancelled)} wagon(s) · credit{" "}
{formatMoney(Number(r.creditAmount), r.feeCurrency, 2)}
</Text>
<Badge color={chip.color} variant="light" size="sm" radius="md">
{chip.label}
</Badge>
</Group>
<Text size="xs" c="dimmed">
Cancelled {formatDate(r.createdAt)}
{r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""}
{Number(r.feeAmount) > 0
? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, 2)}${
r.feePaidAt ? " paid" : " unpaid"
}`
: ""}
{r.reason ? ` · ${r.reason}` : ""}
</Text>
{r.status === "FEE_PENDING" && (
<Text size="xs" c="orange">
The customer must pay the cancellation fee from the portal
Payments tab before the credit can be rebooked.
</Text>
)}
{isRebookableCredit(r) && feeOpen && (
<Text size="xs" c="orange">
The cancellation fee invoice is still open the rebook is
allowed once it is paid.
</Text>
)}
{r.rebookedBookingId && (
<Text size="xs">
Rebooked as{" "}
<Link to={`/dashboard/booking-requests/${r.rebookedBookingId}`}>
{r.rebookedBooking?.reference ?? r.rebookedBookingId}
</Link>
</Text>
)}
</Stack>
{isRebookableCredit(r) && canRebook && (
<Button
size="xs"
radius="md"
variant="light"
color="green"
leftSection={<RotateCcw size={14} />}
onClick={() => setRebooking(r)}
>
Rebook
</Button>
)}
</Group>
);
})}
</Stack>
</SectionCard>
<RebookWagonCancellationModal
cancellation={rebooking}
onClose={() => setRebooking(null)}
onRebooked={() => {
void refetch();
void qc.invalidateQueries({ queryKey: ["bookings"] });
onRebooked?.();
}}
/>
</>
);
}

View File

@@ -0,0 +1,3 @@
export * from "./types";
export * from "./RebookWagonCancellationModal";
export * from "./WagonCancellationCreditCard";

View File

@@ -0,0 +1,93 @@
import type { AuthUser } from "@/auth/types";
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
export type WagonCancellationStatus =
| "FEE_PENDING"
| "CREDIT_AVAILABLE"
| "REBOOKED"
| "WITHDRAWN"
| "EXPIRED";
export interface WagonCancellation {
id: string;
bookingId: string;
rebookedBookingId?: string | null;
wagonsCancelled: number;
weightTons: number;
creditAmount: number;
feeAmount: number;
feeCurrency: string;
feeInvoiceId?: string | null;
feePaidAt?: string | null;
fault?: "CUSTOMER" | "EDR" | string | null;
status: WagonCancellationStatus;
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: {
id: string;
reference: string;
customsClearingEnabled?: boolean;
company?: { name: string };
};
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
cancelledQuantities?: {
bySize?: Record<string, number>;
units?: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
}>;
};
}
export interface WagonCancellationListResponse {
items: WagonCancellation[];
total: number;
}
export interface RebookPartnerCandidate {
id: string;
reference: string;
companyName: string | null;
status: string;
scheduledDate: string | null;
ft20Quantity: number;
}
export const WAGON_CANCELLATION_STATUS_CHIP: Record<
WagonCancellationStatus,
{ label: string; color: string }
> = {
FEE_PENDING: { label: "Fee pending", color: "yellow" },
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
REBOOKED: { label: "Rebooked", color: "indigo" },
WITHDRAWN: { label: "Withdrawn", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
export const hasOddFt20 = (r: WagonCancellation): boolean =>
Object.entries(r.cancelledQuantities?.bySize ?? {})
.filter(([size]) => parseInt(size, 10) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
2 ===
1;
/**
* Who may redeem a cancelled-wagon credit from the backoffice: anyone holding
* the dedicated rebook key, plus GL Ethiopia through its booking-creation key
* (a rebook is a new booking under the contract). GL Djibouti never creates
* bookings. Mirrors the API's rebook gate.
*/
export const canRebookWagonCancellations = (
user: AuthUser | null | undefined,
): boolean =>
hasPermission(user, FREIGHT_PERMS.bookings.wagonCancellationRebook) ||
(hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user));
/** A CREDIT_AVAILABLE row with real credit — whoever cancelled and whoever was at fault. */
export const isRebookableCredit = (r: WagonCancellation): boolean =>
r.status === "CREDIT_AVAILABLE" && Number(r.creditAmount) > 0;

View File

@@ -80,6 +80,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -653,6 +654,7 @@ function OverviewPanel({
/>
<BookingCargoCard booking={booking} />
<BookingContainerUnitsCard booking={booking} />
<WagonCancellationCreditCard bookingId={booking.id} onRebooked={onRefetch} />
</Stack>
);
}

View File

@@ -37,6 +37,7 @@ import {
} from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { AdditionalDocsRequestCard } from "@/components/bookings/detail/AdditionalDocsRequestCard";
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
@@ -374,6 +375,13 @@ export default function DocumentClearanceDetailPage() {
/>
{booking ? <BookingCompanyCard booking={booking} /> : null}
{booking ? <BookingContractCard booking={booking} /> : null}
{/* Cancelled-wagon credits: GL rebooks them for the customer. */}
{id ? (
<WagonCancellationCreditCard
bookingId={id}
onRebooked={() => void refetch()}
/>
) : null}
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}

View File

@@ -33,87 +33,15 @@ import {
type ColumnDef,
} from "@edr/ui-common";
type WagonCancellationStatus =
| "FEE_PENDING"
| "CREDIT_AVAILABLE"
| "REBOOKED"
| "WITHDRAWN"
| "EXPIRED";
interface WagonCancellation {
id: string;
bookingId: string;
rebookedBookingId?: string | null;
wagonsCancelled: number;
weightTons: number;
creditAmount: number;
feeAmount: number;
feeCurrency: string;
feeInvoiceId?: string | null;
feePaidAt?: string | null;
status: WagonCancellationStatus;
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: {
id: string;
reference: string;
customsClearingEnabled?: boolean;
company?: { name: string };
};
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
cancelledQuantities?: {
bySize?: Record<string, number>;
units?: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
}>;
};
}
interface RebookPartnerCandidate {
id: string;
reference: string;
companyName: string | null;
status: string;
scheduledDate: string | null;
ft20Quantity: number;
}
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
const hasOddFt20 = (r: WagonCancellation): boolean =>
Object.entries(r.cancelledQuantities?.bySize ?? {})
.filter(([size]) => parseInt(size, 10) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
2 ===
1;
/** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
interface WagonCancellationListResponse {
items: WagonCancellation[];
total: number;
}
const STATUS_CHIP: Record<
WagonCancellationStatus,
{ label: string; color: string }
> = {
FEE_PENDING: { label: "Fee pending", color: "yellow" },
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
REBOOKED: { label: "Rebooked", color: "indigo" },
WITHDRAWN: { label: "Withdrawn", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
import {
canRebookWagonCancellations,
isRebookableCredit,
RebookWagonCancellationModal,
WAGON_CANCELLATION_STATUS_CHIP as STATUS_CHIP,
type WagonCancellation,
type WagonCancellationListResponse,
type WagonCancellationStatus,
} from "@/components/bookings/wagon-cancellation";
const STATUS_FILTER_OPTIONS = (
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
@@ -155,72 +83,11 @@ export default function WagonCancellationsPage() {
const [from, setFrom] = useState<Date | null>(null);
const [to, setTo] = useState<Date | null>(null);
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
// GL rebook of a customs (Path B) credit: pick the day; container number /
// seal / VGM may be corrected. Non-customs credits are rebooked by the
// customer from the portal.
const canRebook = hasPermission(
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
// Rebook of a CREDIT_AVAILABLE row — any desk holding the rebook key, or GL
// Ethiopia through its booking-creation key. The modal handles the day pick,
// container corrections and the odd-20ft partner choice.
const canRebook = canRebookWagonCancellations(user);
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
const [rebookDate, setRebookDate] = useState<Date | null>(null);
const [rebookPartnerId, setRebookPartnerId] = useState<string | null>(null);
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
const openRebook = (r: WagonCancellation) => {
setRebooking(r);
setRebookDate(null);
setRebookPartnerId(null);
setRebookDrafts(
(r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || "",
})),
);
};
const rebookContainersPayload = () => {
const bySize = new Map<string, RebookUnitDraft[]>();
for (const d of rebookDrafts) {
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
}
return [...bySize.entries()].map(([containerSize, units]) => ({
containerSize,
units: units.map((u) => ({
containerNumber: u.containerNumber.trim(),
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
})),
}));
};
const rebook = useMutation({
mutationFn: () =>
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
scheduledDate: toDayString(rebookDate!),
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
...(rebookPartnerId ? { partnerBookingId: rebookPartnerId } : {}),
}),
});
// Odd-20ft credit: the rebooked booking shares a wagon again, so GL must pick
// the odd partner booking riding the chosen day. It ships once that partner pays.
const rebookNeedsPartner = rebooking ? hasOddFt20(rebooking) : false;
const rebookPartners = useQuery({
queryKey: [
"wagon-cancellations",
rebooking?.id,
"rebook-partners",
rebookDate ? toDayString(rebookDate) : null,
],
enabled: Boolean(rebooking && rebookNeedsPartner && rebookDate),
queryFn: async () => {
const res = await api.get<RebookPartnerCandidate[]>(
`/bookings/wagon-cancellations/${rebooking!.id}/rebook-partners`,
{ params: { scheduledDate: toDayString(rebookDate!) } },
);
return res.data;
},
});
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -340,14 +207,12 @@ export default function WagonCancellationsPage() {
cell: ({ row }) => {
const r = row.original;
const showVoid = r.status === "FEE_PENDING" && canVoid;
// Customs credits are GL's to rebook; non-customs ones the customer
// rebooks from the portal — EXCEPT odd-20ft credits: those must be
// re-paired with a partner booking, which only GL can pick.
const showRebook =
r.status === "CREDIT_AVAILABLE" &&
canRebook &&
(Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) &&
Number(r.creditAmount) > 0;
// Every available credit is rebookable from here — customs or not,
// customer- or staff-cancelled, EDR or customer fault. The customer can
// also self-rebook plain non-customs credits from the portal, but GL
// must be able to do it for them (customs bookings and odd-20ft
// credits are never self-booked). The API enforces the fee gate.
const showRebook = isRebookableCredit(r) && canRebook;
if (!showVoid && !showRebook) return null;
return (
<Group justify="flex-end" wrap="nowrap">
@@ -357,7 +222,7 @@ export default function WagonCancellationsPage() {
radius="md"
variant="light"
color="green"
onClick={() => openRebook(r)}
onClick={() => setRebooking(r)}
>
Rebook
</Button>
@@ -518,153 +383,11 @@ export default function WagonCancellationsPage() {
</Stack>
)}
</Modal>
<Modal
opened={!!rebooking}
<RebookWagonCancellationModal
cancellation={rebooking}
onClose={() => setRebooking(null)}
title="Rebook cancelled wagons"
centered
radius="md"
>
{rebooking && (
<Stack gap="sm">
<Text size="sm">
{rebooking.booking?.reference ?? rebooking.bookingId} ·{" "}
{rebooking.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(rebooking.creditAmount, rebooking.feeCurrency, 2)}
</Text>
<DatePickerInput
label="Shipment day"
placeholder="Pick the day"
value={rebookDate}
onChange={(v) => {
setRebookDate(v ? new Date(v) : null);
setRebookPartnerId(null);
}}
radius="md"
/>
{rebookNeedsPartner && (
<Select
label="Consolidation partner"
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
placeholder={
!rebookDate
? "Pick the day first"
: rebookPartners.isLoading
? "Loading…"
: "Pick the partner booking"
}
data={(rebookPartners.data ?? []).map((c) => ({
value: c.id,
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
}))}
value={rebookPartnerId}
onChange={setRebookPartnerId}
disabled={!rebookDate}
searchable
radius="md"
/>
)}
{rebookNeedsPartner &&
rebookDate &&
!rebookPartners.isLoading &&
(rebookPartners.data ?? []).length === 0 && (
<Text size="xs" c="orange">
No odd-20ft booking rides that day pick another day or wait
for a partner booking.
</Text>
)}
{rebookDrafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" c="dimmed">
Correct the container details if they changed sizes and
quantities stay as cancelled.
</Text>
{rebookDrafts.map((d, i) => (
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
<TextInput
label={`${d.containerSize} container`}
value={d.containerNumber}
onChange={(e) => {
const v = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, containerNumber: v } : x,
),
);
}}
size="xs"
radius="md"
style={{ flex: 1.4 }}
/>
<TextInput
label="Seal no."
value={d.sealNumber}
onChange={(e) => {
const v = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, sealNumber: v } : x,
),
);
}}
size="xs"
radius="md"
style={{ flex: 1 }}
/>
<TextInput
label="VGM (t)"
type="number"
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
onChange={(e) => {
const raw = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i
? { ...x, vgmTons: raw === "" ? "" : Number(raw) }
: x,
),
);
}}
size="xs"
radius="md"
style={{ width: 90 }}
/>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setRebooking(null)}
>
Close
</Button>
<Button
color="green"
radius="md"
disabled={
!rebookDate || (rebookNeedsPartner && !rebookPartnerId)
}
loading={rebook.isPending}
onClick={async () => {
try {
await rebook.mutateAsync();
toast.success("Credit rebooked as a new paid booking");
setRebooking(null);
void refetch();
} catch {
// interceptor surfaces the reason
}
}}
>
Rebook
</Button>
</Group>
</Stack>
)}
</Modal>
onRebooked={() => void refetch()}
/>
</PageContainer>
);
}

View File

@@ -311,7 +311,7 @@ export function WagonCancellationCard({
{isCustoms || oddFt20Credit ? (
<Text fz={13} c="#475569">
{isCustoms
? "This is a customs-cleared booking — Global Logistics will rebook the credit for you."
? "This is a customs-cleared booking — Global Logistics (EDR staff) will rebook the credit for you on a coming train day. Contact them if you have a preferred date."
: "Your credit includes an odd 20ft container that must share a wagon with another booking — Global Logistics will rebook it for you and pair the wagon. Please contact EDR staff."}
</Text>
) : (