Merge pull request #1148 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-07 09:02:45 +03:00
committed by GitHub
20 changed files with 2468 additions and 12 deletions

View File

@@ -31,6 +31,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const BookingDocReviewAlert = () =>
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
/** Staff wagon-cancellation history list (admin side). */
export const WagonCancellationView = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView);
/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */
export const WagonCancellationVoid = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid);
/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */
export const WagonCancellationRebook = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook);
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Partial wagon cancellation with rebooking credit.
*
* One row per cancellation cycle on a PAID booking: the customer asks to drop
* N wagons, pays a per-wagon cancellation fee (rates row
* rate_type = 'CANCELLATION_FEE', rate_unit = 'PER_WAGON'), and the dropped cargo becomes a
* rebookable credit. The credit is redeemed by creating a fresh booking
* through the normal under-contract create path (which re-checks contract
* validity and caps), immediately marked PAID — the freight was already paid
* on the original booking, only the fee is new money.
*
* cancelled_quantities carries what was cut, in the booking's own terms:
* `{ bulkTons }` for bulk, `{ bySize: { "20": 4, "40": 3 } }` for container.
* Container numbers are NOT stored here — they are recovered at rebook time
* from the unit rows the reduction soft-deleted (same hybrid pattern as
* RemainderPlacementService).
*/
export class BookingWagonCancellations3300000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_wagon_cancellations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
rebooked_booking_id uuid REFERENCES freight.bookings(id),
wagons_cancelled numeric(6,2) NOT NULL CHECK (wagons_cancelled > 0),
weight_tons numeric(12,3) NOT NULL DEFAULT 0,
cancelled_quantities jsonb NOT NULL,
credit_amount numeric(14,2) NOT NULL DEFAULT 0,
fee_rate_id uuid REFERENCES freight.rates(id),
fee_amount numeric(14,2) NOT NULL CHECK (fee_amount >= 0),
fee_currency varchar(8) NOT NULL DEFAULT 'ETB',
fee_invoice_id uuid REFERENCES freight.invoices(id),
fee_paid_at timestamptz,
status varchar(30) NOT NULL DEFAULT 'FEE_PENDING',
reason text,
requested_by_user_id uuid,
rebooked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// One open (fee-unpaid) cancellation per booking — closes the double-click
// race without app-level locking.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_open_wagon_cancellation_per_booking
ON freight.booking_wagon_cancellations (booking_id)
WHERE status = 'FEE_PENDING' AND deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bwc_booking
ON freight.booking_wagon_cancellations (booking_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bwc_status
ON freight.booking_wagon_cancellations (status)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`);
}
}

View File

@@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { BookingsRepository } from "./bookings.repository";
import {
BookingWagonCancellationService,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from "./booking-wagon-cancellation.service";
import { Booking } from "./entities/booking.entity";
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
@@ -58,6 +62,8 @@ export class BookingInvoiceService {
private readonly firstMile: FirstMileService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService,
@Inject(forwardRef(() => BookingWagonCancellationService))
private readonly wagonCancellations: BookingWagonCancellationService,
) { }
/**
@@ -123,6 +129,11 @@ export class BookingInvoiceService {
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
await this.advanceBookingOnPayment(payload.sourceId);
break;
case WAGON_CANCEL_FEE_INVOICE_TYPE:
// Partial wagon cancellation: the fee settled — reduce the booking and
// release the cancelled wagons (T2 of the cancellation cycle).
await this.wagonCancellations.onFeePaid(payload.invoiceId);
break;
default:
this.logger.warn(
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,

View File

@@ -0,0 +1,778 @@
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In } from 'typeorm';
import { BillingService } from '../billing/billing.service';
import { ContractBookingService } from '../contracts/contract-booking.service';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { CreateBookingUnderContractDto } from '../contracts/dto/create-booking-under-contract.dto';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { FirstMileService } from '../first-mile/first-mile.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
BookingWagonCancellationsRepository,
WagonCancellationListFilter,
} from './booking-wagon-cancellations.repository';
import { BookingsRepository } from './bookings.repository';
import {
RebookCancelledWagonsDto,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import { Booking } from './entities/booking.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
import {
BookingWagonCancellation,
CancelledQuantities,
CancelledUnitSnapshot,
} from './entities/booking-wagon-cancellation.entity';
/**
* rates.rate_type of the cancellation fee — an existing rate-engine type
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
* configure it in the normal rates UI; the wagon flow requires the PER_WAGON
* unit so the fee scales with the cancelled wagon count.
*/
export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
const round2 = (n: number): number => Math.round(n * 100) / 100;
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
interface RequestedCut {
wagons: number;
weightTons: number;
quantities: CancelledQuantities;
}
/**
* Partial wagon cancellation on a PAID booking, with a rebooking credit.
*
* Lifecycle (one ledger row per cycle, see BookingWagonCancellation):
* T1 request — validate + price the fee, open the fee invoice. Nothing else
* moves: the wagons stay allocated until the fee is money.
* T2 fee paid — reduce the booking in place (applySplit mechanics: soft-delete
* the cut units LIFO), release the surplus wagon allocations,
* snapshot the cut units on the ledger row → CREDIT_AVAILABLE.
* T3 rebook — customer picks a day only. The credit becomes a REAL booking
* via ContractBookingService.createUnderContract (which re-checks
* contract validity + caps), immediately marked PAID — the
* freight was paid on the original booking; only the fee was new
* money. Clearance milestones are copied from the source booking
* (the cargo is already cleared; clearance follows cargo, not
* train date).
*
* The cycle is repeatable by construction: the rebooked booking is a normal
* PAID booking, so it can itself be partially cancelled again.
*/
@Injectable()
export class BookingWagonCancellationService {
private readonly logger = new Logger(BookingWagonCancellationService.name);
constructor(
private readonly dataSource: DataSource,
private readonly repo: BookingWagonCancellationsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly billing: BillingService,
@Inject(forwardRef(() => ContractBookingService))
private readonly contractBooking: ContractBookingService,
@Inject(forwardRef(() => ClearanceMilestoneService))
private readonly clearanceMilestones: ClearanceMilestoneService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService,
@Inject(forwardRef(() => FirstMileService))
private readonly firstMile: FirstMileService,
private readonly inbox: NotificationInboxService,
) {}
// ── T1: request ────────────────────────────────────────────────────────────
/** Fee/credit preview for the confirm dialog — same math as the request, no writes. */
async previewCancellation(
bookingId: string,
dto: RequestWagonCancellationDto,
): Promise<{
wagons: number;
weightTons: number;
feePerWagon: number;
feeAmount: number;
feeCurrency: string;
creditAmount: number;
}> {
const booking = await this.loadCancellableBooking(bookingId);
const cut = await this.resolveRequestedCut(booking, dto);
const rate = await this.feeRate();
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
return {
wagons: cut.wagons,
weightTons: cut.weightTons,
feePerWagon: Number(rate.rateValue),
feeAmount,
feeCurrency: rate.currency,
creditAmount: this.creditFor(booking, cut.wagons),
};
}
async requestCancellation(
bookingId: string,
dto: RequestWagonCancellationDto,
userId?: string,
): Promise<BookingWagonCancellation> {
const booking = await this.loadCancellableBooking(bookingId);
const open = await this.repo.findOpenForBooking(bookingId);
if (open) {
throw new ConflictException(
'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.',
);
}
const cut = await this.resolveRequestedCut(booking, dto);
const rate = await this.feeRate();
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
const creditAmount = this.creditFor(booking, cut.wagons);
const row = await this.repo.create({
bookingId,
wagonsCancelled: cut.wagons,
weightTons: cut.weightTons,
cancelledQuantities: cut.quantities,
creditAmount,
feeRateId: rate.id,
feeAmount,
feeCurrency: rate.currency,
status: 'FEE_PENDING',
reason: dto.reason ?? null,
requestedByUserId: userId ?? null,
});
// The fee invoice rides the booking's own invoice list (source=booking), so
// the portal's existing invoice/pay stack picks it up with zero new payment
// code. Settlement branches on type in BookingInvoiceService.
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.Booking,
sourceId: bookingId,
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: rate.currency,
lines: [
{
chargeType: 'CANCELLATION_FEE',
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`,
quantity: cut.wagons,
unitRate: Number(rate.rateValue),
amount: feeAmount,
currency: rate.currency,
metadata: { wagonCancellationId: row.id },
},
],
totalAmount: feeAmount,
status: Freight.InvoiceStatus.Issued,
});
const updated = await this.repo.update(row.id, { feeInvoiceId: invoice.id });
this.notifyStaff(
booking,
'Wagon cancellation requested',
`${booking.reference}: customer asked to cancel ${cut.wagons} wagon(s); fee invoice ${invoice.invoiceNumber} issued.`,
);
return updated ?? row;
}
/** Void a FEE_PENDING request: fee invoice cancelled, nothing was released. */
async withdraw(cancellationId: string): Promise<BookingWagonCancellation> {
const row = await this.mustFind(cancellationId);
if (row.status !== 'FEE_PENDING') {
throw new BadRequestException(
`Only a fee-pending cancellation can be withdrawn (status is ${row.status}).`,
);
}
if (row.feeInvoiceId) await this.billing.cancelInvoice(row.feeInvoiceId);
return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!;
}
// ── T2: fee settled ─────────────────────────────────────────────────────────
/**
* The fee invoice settled — reduce the booking and free the wagons. Called
* from BookingInvoiceService's paid handler. Idempotent: a duplicate webhook
* finds the row already past FEE_PENDING and returns.
*/
async onFeePaid(feeInvoiceId: string): Promise<void> {
const row = await this.repo.findByFeeInvoiceId(feeInvoiceId);
if (!row) {
this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`);
return;
}
if (row.status !== 'FEE_PENDING') return;
await this.dataSource.transaction(async (manager) => {
const booking = await manager.getRepository(Booking).findOne({
where: { id: row.bookingId },
lock: { mode: 'pessimistic_write' },
});
if (!booking) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
const quantities = { ...row.cancelledQuantities };
let droppedWeight = 0;
if (quantities.bySize && Object.keys(quantities.bySize).length) {
const units = await this.reduceContainerLines(manager, booking, quantities.bySize);
quantities.units = units;
droppedWeight = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
await this.releaseContainerAllocations(
manager,
booking.id,
units.map((u) => u.containerNumber),
);
} else {
droppedWeight = Number(quantities.bulkTons ?? row.weightTons);
await this.reduceBulk(manager, booking, droppedWeight);
await this.releaseBulkAllocations(manager, booking.id, Number(row.wagonsCancelled));
}
// Mirror applySplit's bookkeeping: preSplitQuantities feeds the ONE_TIME
// exact-remainder assertion at rebook time; isSplit releases the
// single-active-booking slot so the rebooked booking may be created.
const preSplitQuantities =
booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight));
await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)),
cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)),
isSplit: true,
preSplitQuantities,
} as never);
await manager.getRepository(BookingWagonCancellation).update(row.id, {
status: 'CREDIT_AVAILABLE',
feePaidAt: new Date(),
weightTons: droppedWeight,
cancelledQuantities: quantities,
});
});
const booking = await this.bookingsRepository.findById(row.bookingId);
if (booking) {
this.notifyCustomer(
booking,
'Wagon cancellation confirmed',
`${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
);
}
this.logger.log(
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
);
}
// ── T3: rebook ──────────────────────────────────────────────────────────────
async rebook(
cancellationId: string,
dto: RebookCancelledWagonsDto,
userId?: string,
): Promise<{ cancellation: BookingWagonCancellation; bookingId: string }> {
const row = await this.mustFind(cancellationId);
if (row.status !== 'CREDIT_AVAILABLE') {
throw new BadRequestException(
`This credit cannot be rebooked (status is ${row.status}).`,
);
}
const source = await this.bookingsRepository.findById(row.bookingId);
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
if (!source.contractId) {
throw new BadRequestException('The original booking has no contract to rebook under.');
}
// Friendly pre-check; createUnderContract re-asserts inside its own guards.
if (
source.contractValidUntil &&
new Date(source.contractValidUntil).getTime() < Date.now()
) {
throw new BadRequestException(
'Contract validity has expired — ask EDR staff to extend the contract before rebooking.',
);
}
const createDto = this.buildRebookDto(row, dto.scheduledDate);
const created = await this.contractBooking.createUnderContract(
source.contractId,
createDto,
{ id: userId ?? source.createdByUserId ?? undefined },
// System actor: carries the create-booking key so the GL gate passes on
// Path B (customs-clearance) contracts; harmless on Path A.
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
);
const newBookingId = created.booking.id;
// The freight is already paid (credit) — mark PAID and let the existing
// paid-booking machinery place it. No invoice is generated for it.
await this.dataSource.getRepository(Booking).update(newBookingId, {
paymentStatus: 'PAID',
status: 'PAID',
});
await this.copyClearanceState(source, newBookingId);
try {
await this.firstMile.acceptBooking(newBookingId);
} catch (err) {
this.logger.error(
`First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
try {
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
} catch (err) {
this.logger.error(
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
const updated = (await this.repo.update(row.id, {
status: 'REBOOKED',
rebookedBookingId: newBookingId,
rebookedAt: new Date(),
}))!;
this.notifyCustomer(
source,
'Cancelled wagons rebooked',
`Your ${row.wagonsCancelled} cancelled wagon(s) from ${source.reference} are rebooked for ${dto.scheduledDate}. No new freight charge — your credit covered it.`,
newBookingId,
);
return { cancellation: updated, bookingId: newBookingId };
}
// ── History ────────────────────────────────────────────────────────────────
list(filter: WagonCancellationListFilter) {
return this.repo.list(filter);
}
findById(id: string): Promise<BookingWagonCancellation> {
return this.mustFind(id);
}
// ── internals ──────────────────────────────────────────────────────────────
private async mustFind(id: string): Promise<BookingWagonCancellation> {
const row = await this.repo.findById(id);
if (!row) throw new NotFoundException(`Wagon cancellation ${id} not found.`);
return row;
}
/** PAID booking, not yet moving, with a contract to rebook under later. */
private async loadCancellableBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found.`);
if (booking.paymentStatus !== 'PAID' || booking.status !== 'PAID') {
throw new BadRequestException(
'Only a paid booking can cancel wagons. Before payment, cancel the booking itself — no fee applies.',
);
}
if (!booking.contractId) {
throw new BadRequestException(
'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).',
);
}
const moving = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId, status: In(['LOADED', 'DEPARTED']) },
});
if (moving > 0) {
throw new BadRequestException(
'Loading has started for this booking — wagons can no longer be cancelled.',
);
}
return booking;
}
/** Validate the requested cut against the live booking and size it in wagons/tons. */
private async resolveRequestedCut(
booking: Booking,
dto: RequestWagonCancellationDto,
): Promise<RequestedCut> {
const totalWagons = Number(booking.wagonsRequired ?? 0);
if (totalWagons <= 0) {
throw new BadRequestException('This booking has no wagon requirement to cancel from.');
}
if (booking.freightType === 'CONTAINER') {
if (!dto.containers?.length) {
throw new BadRequestException('Specify the container units to cancel per size.');
}
const lines = await this.dataSource.getRepository(BookingContainer).find({
where: { bookingId: booking.id },
});
const liveBySize = new Map<string, number>();
for (const line of lines) {
const size = line.containerSize ?? '';
liveBySize.set(size, (liveBySize.get(size) ?? 0) + Number(line.quantity ?? 0));
}
const bySize: Record<string, number> = {};
let wagons = 0;
for (const cut of dto.containers) {
const live = liveBySize.get(cut.containerSize) ?? 0;
if (cut.quantity > live) {
throw new BadRequestException(
`Cannot cancel ${cut.quantity} × ${cut.containerSize}ft — the booking only has ${live}.`,
);
}
bySize[cut.containerSize] = cut.quantity;
wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize));
}
wagons = round2(wagons);
if (wagons >= totalWagons) {
throw new BadRequestException(
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
);
}
const weightShare = round3(
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons),
);
return { wagons, weightTons: weightShare, quantities: { bySize } };
}
// BULK: the customer cancels wagons; tons follow the booking's own
// tons-per-wagon ratio.
const wagons = round2(Number(dto.wagons ?? 0));
if (!wagons || wagons <= 0) {
throw new BadRequestException('Specify how many wagons to cancel.');
}
if (wagons >= totalWagons) {
throw new BadRequestException(
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
);
}
// ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item
// rounding happens here too; switch to items_per_wagon_map sizing if bulk
// PER_ITEM cancels ever need to be exact per item.
let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons);
const isPerItem = booking.bulkTotalWeightTons != null;
tons = isPerItem ? Math.floor(tons) : round3(tons);
if (tons <= 0) {
throw new BadRequestException('The requested cut is too small to release cargo.');
}
return { wagons, weightTons: tons, quantities: { bulkTons: tons } };
}
/** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
private creditFor(booking: Booking, wagons: number): number {
const totalWagons = Number(booking.wagonsRequired ?? 0);
if (totalWagons <= 0) return 0;
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
}
private async feeRate(): Promise<Rate> {
const rate = await this.dataSource.getRepository(Rate).findOne({
where: {
rateType: WAGON_CANCELLATION_FEE_RATE_TYPE,
rateUnit: 'PER_WAGON',
status: 'LIVE',
},
order: { createdAt: 'DESC' },
});
if (!rate) {
throw new BadRequestException(
'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).',
);
}
return rate;
}
/**
* Trim `bySize` units off the booking's container lines, newest line first,
* LIFO within a line — the exact applySplit mechanics. Returns snapshots of
* every physical unit soft-deleted, for later reconstruction.
*/
private async reduceContainerLines(
manager: EntityManager,
booking: Booking,
bySize: Record<string, number>,
): Promise<CancelledUnitSnapshot[]> {
const snapshots: CancelledUnitSnapshot[] = [];
for (const [size, toDrop] of Object.entries(bySize)) {
let remaining = toDrop;
const lines = await manager.getRepository(BookingContainer).find({
where: { bookingId: booking.id, containerSize: size },
order: { createdAt: 'DESC' },
});
const live = lines.reduce((s, l) => s + Number(l.quantity ?? 0), 0);
if (live < toDrop) {
throw new BadRequestException(
`Booking changed since the request: only ${live} × ${size}ft left, cannot cancel ${toDrop}.`,
);
}
for (const line of lines) {
if (remaining <= 0) break;
const qty = Number(line.quantity ?? 0);
const drop = Math.min(remaining, qty);
remaining -= drop;
const units = await manager.getRepository(BookingContainerUnit).find({
where: { bookingContainerId: line.id },
order: { sortOrder: 'DESC', createdAt: 'DESC' },
take: drop,
});
for (const u of units) {
snapshots.push({
containerSize: size,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? null,
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,
});
}
if (units.length < drop) {
throw new BadRequestException(
`Booking line ${line.id} has ${units.length} physical unit record(s) but ${drop} must be cancelled — units out of sync.`,
);
}
const droppedVgm = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
if (drop === qty) {
await manager.getRepository(BookingContainer).softDelete(line.id);
await manager
.getRepository(BookingContainerUnit)
.softDelete(units.map((u) => u.id));
continue;
}
await manager.getRepository(BookingContainerUnit).softDelete(units.map((u) => u.id));
const keptUnits = await manager.getRepository(BookingContainerUnit).find({
where: { bookingContainerId: line.id },
});
await manager.getRepository(BookingContainer).update(line.id, {
quantity: qty - drop,
wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(Number(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm),
hazardousQuantity: keptUnits.filter((u) => u.isHazardous).length,
reeferQuantity: keptUnits.filter((u) => u.isReefer).length,
});
}
}
return snapshots;
}
private async reduceBulk(
manager: EntityManager,
booking: Booking,
tons: number,
): Promise<void> {
if (tons >= Number(booking.cargoTotalWeightVgm)) {
throw new BadRequestException(
'Booking changed since the request: the cut no longer leaves any cargo.',
);
}
if (booking.bulkTotalWeightTons != null) {
const share = tons / Number(booking.cargoTotalWeightVgm);
await manager.getRepository(Booking).update(booking.id, {
bulkTotalWeightTons: round3(Number(booking.bulkTotalWeightTons) * (1 - share)),
});
}
}
/**
* Free the wagon capacity of the cancelled container units. Items are matched
* by container number; an allocation left with no items is deleted whole
* (hard delete — the unassignBooking convention for allocation rows).
* A booking not yet placed on a train simply has nothing to release.
*/
private async releaseContainerAllocations(
manager: EntityManager,
bookingId: string,
containerNumbers: string[],
): Promise<void> {
if (!containerNumbers.length) return;
const allocations = await manager.getRepository(WagonBookingAllocation).find({
where: { bookingId },
relations: { containerItems: true },
});
for (const alloc of allocations) {
const items = alloc.containerItems ?? [];
const cut = items.filter(
(i) => i.containerNumber && containerNumbers.includes(i.containerNumber),
);
if (!cut.length) continue;
await manager
.getRepository(WagonAllocationContainerItem)
.delete(cut.map((i) => i.id));
if (cut.length === items.length) {
await manager.getRepository(WagonBookingAllocation).delete(alloc.id);
} else {
const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0);
await manager.getRepository(WagonBookingAllocation).update(alloc.id, {
allocatedWeightTons: round3(Number(alloc.allocatedWeightTons) - cutWeight),
});
}
}
}
/** Free whole bulk wagons, newest allocations first. */
private async releaseBulkAllocations(
manager: EntityManager,
bookingId: string,
wagons: number,
): Promise<void> {
const toFree = Math.round(wagons);
if (toFree <= 0) return;
const allocations = await manager.getRepository(WagonBookingAllocation).find({
where: { bookingId },
order: { createdAt: 'DESC' },
take: toFree,
});
if (!allocations.length) return;
const ids = allocations.map((a) => a.id);
await manager
.getRepository(WagonAllocationBulkLoad)
.delete({ wagonBookingAllocationId: In(ids) });
await manager.getRepository(WagonBookingAllocation).delete(ids);
}
/** Pre-reduction quantities snapshot (only when the booking was never split before). */
private async currentQuantities(
manager: EntityManager,
booking: Booking,
_droppedWeight: number,
): Promise<{ bulkTons?: number; bySize?: Record<string, number> }> {
if (booking.freightType !== 'CONTAINER') {
return { bulkTons: Number(booking.cargoTotalWeightVgm) };
}
// Lines were already reduced inside this transaction — read them with
// deleted rows included to reconstruct the pre-cut ledger.
const lines = await manager.getRepository(BookingContainer).find({
where: { bookingId: booking.id },
withDeleted: true,
});
const bySize: Record<string, number> = {};
for (const line of lines) {
const size = line.containerSize ?? '';
bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0);
}
return { bySize };
}
/** The create-DTO that reconstructs the cancelled cargo on the chosen day. */
private buildRebookDto(
row: BookingWagonCancellation,
scheduledDate: string,
): CreateBookingUnderContractDto {
const dto: CreateBookingUnderContractDto = { scheduledDate };
const q = row.cancelledQuantities;
if (q.bySize && Object.keys(q.bySize).length) {
const units = q.units ?? [];
dto.containers = Object.entries(q.bySize).map(([size, quantity]) => {
const sized = units.filter((u) => u.containerSize === size);
if (sized.length !== quantity) {
throw new BadRequestException(
`Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`,
);
}
return {
containerSize: size,
quantity,
units: sized.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? undefined,
vgmTons: u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
})),
hazardousQuantity: sized.filter((u) => u.isHazardous).length,
reeferQuantity: sized.filter((u) => u.isReefer).length,
};
});
return dto;
}
dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }];
return dto;
}
/**
* Carry the source booking's finished clearance onto the rebooked one: the
* cargo is already cleared; a new train date needs no new customs cycle.
* Seeds the standard milestone set idempotently, then mirrors every
* non-pending milestone status from the source by milestone code.
*/
private async copyClearanceState(source: Booking, newBookingId: string): Promise<void> {
const repo = this.dataSource.getRepository(ClearanceMilestone);
const sourceMilestones = await repo.find({ where: { bookingId: source.id } });
if (!sourceMilestones.length) return;
try {
await this.clearanceMilestones.ensureBookingMilestones(
newBookingId,
source.tradeDirection,
);
const targets = await repo.find({ where: { bookingId: newBookingId } });
const byCode = new Map(targets.map((m) => [m.milestoneCode, m]));
for (const src of sourceMilestones) {
if (src.status === 'PENDING') continue;
const target = byCode.get(src.milestoneCode);
if (!target) continue;
await repo.update(target.id, {
status: src.status,
triggeredAt: src.triggeredAt,
triggeredByUserId: src.triggeredByUserId,
triggeredByDoc: src.triggeredByDoc,
note: src.note,
metadata: src.metadata,
});
}
if (source.clearanceCurrentPhase) {
await this.dataSource.getRepository(Booking).update(newBookingId, {
clearanceCurrentPhase: source.clearanceCurrentPhase,
preClearanceFinalizedAt: source.preClearanceFinalizedAt,
dutyRequired: source.dutyRequired,
});
}
} catch (err) {
// Clearance copy must never lose a paid rebooking — staff can re-complete
// milestones by hand if this ever fails.
this.logger.error(
`Clearance copy ${source.id}${newBookingId} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
private notifyCustomer(booking: Booking, title: string, body: string, linkBookingId?: string): void {
void this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title,
body,
link: `/bookings/${linkBookingId ?? booking.id}`,
data: { bookingId: linkBookingId ?? booking.id, reference: booking.reference },
});
}
private notifyStaff(booking: Booking, title: string, body: string): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title,
body,
link: `/bookings/${booking.id}`,
data: { bookingId: booking.id, reference: booking.reference },
});
}
}

View File

@@ -0,0 +1,85 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
export interface WagonCancellationListFilter {
status?: string[];
/** Booking reference / company name search (staff list). */
search?: string;
companyId?: string;
bookingId?: string;
from?: Date;
to?: Date;
page?: number;
pageSize?: number;
}
@Injectable()
export class BookingWagonCancellationsRepository extends BaseRepository<BookingWagonCancellation> {
constructor(
@InjectRepository(BookingWagonCancellation)
repository: Repository<BookingWagonCancellation>,
) {
super(repository);
}
/** The one open (fee-unpaid) cancellation of a booking, if any. */
findOpenForBooking(bookingId: string): Promise<BookingWagonCancellation | null> {
return this.repository.findOne({
where: { bookingId, status: 'FEE_PENDING' },
});
}
findByFeeInvoiceId(feeInvoiceId: string): Promise<BookingWagonCancellation | null> {
return this.repository.findOne({ where: { feeInvoiceId } });
}
/** Paged history — staff see everything, customers are scoped by companyId. */
async list(
filter: WagonCancellationListFilter,
): Promise<{ items: BookingWagonCancellation[]; total: number }> {
const page = Math.max(1, filter.page ?? 1);
const pageSize = Math.min(100, Math.max(1, filter.pageSize ?? 10));
const qb = this.baseQuery();
if (filter.bookingId) {
qb.andWhere('(bwc.booking_id = :bookingId OR bwc.rebooked_booking_id = :bookingId)', {
bookingId: filter.bookingId,
});
}
if (filter.companyId) {
qb.andWhere('booking.company_id = :companyId', { companyId: filter.companyId });
}
if (filter.status?.length) {
qb.andWhere('bwc.status IN (:...statuses)', { statuses: filter.status });
}
if (filter.search) {
qb.andWhere('(booking.reference ILIKE :search OR company.name ILIKE :search)', {
search: `%${filter.search}%`,
});
}
if (filter.from) qb.andWhere('bwc.created_at >= :from', { from: filter.from });
if (filter.to) qb.andWhere('bwc.created_at <= :to', { to: filter.to });
// Property path (not raw column): skip/take builds a distinct-id subquery
// and the ORDER BY must resolve inside it.
const [items, total] = await qb
.orderBy('bwc.createdAt', 'DESC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
private baseQuery(): SelectQueryBuilder<BookingWagonCancellation> {
return this.repository
.createQueryBuilder('bwc')
.leftJoinAndSelect('bwc.booking', 'booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking')
.leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice');
}
}

View File

@@ -21,7 +21,11 @@ import {
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { BookingStaff, BookingView } from '../../common/booking-guards';
import {
BookingStaff,
BookingView,
WagonCancellationView,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import {
@@ -74,6 +78,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
FilterWagonCancellationsDto,
RebookCancelledWagonsDto,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -153,6 +163,7 @@ export class BookingsController {
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
) {}
@Post()
@@ -512,6 +523,134 @@ export class BookingsController {
return this.bookingsService.wagonAllocations(id);
}
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
// Customer endpoints are ownership-scoped (no portal permission keys); the
// staff history/void/rebook variants are permission-gated below.
@Post(':id/wagon-cancellations/preview')
@ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' })
async previewWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.previewCancellation(id, dto);
}
@Post(':id/wagon-cancellations')
@ApiOperation({
summary:
'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles',
})
async requestWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
}
@Get(':id/wagon-cancellations')
@ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' })
async listBookingWagonCancellations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
const staff =
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
if (!staff) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 });
}
@Get('wagon-cancellations/my')
@ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' })
async listMyWagonCancellations(
@Query() filter: FilterWagonCancellationsDto,
@CurrentUser() user: TCurrentUser,
) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? '');
if (!companyId) throw new ForbiddenException('No customer company for this user.');
return this.wagonCancellationService.list({
companyId,
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Get('wagon-cancellations/history')
@WagonCancellationView()
@ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' })
async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) {
return this.wagonCancellationService.list({
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Post('wagon-cancellations/:cancellationId/withdraw')
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
async withdrawWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
return this.wagonCancellationService.withdraw(cancellationId);
}
@Post('wagon-cancellations/:cancellationId/rebook')
@ApiOperation({
summary:
'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)',
})
async rebookWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@Body() dto: RebookCancelledWagonsDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
return this.wagonCancellationService.rebook(cancellationId, dto, user?.id);
}
/** Owner-or-staff gate shared by the per-cancellation actions. */
private async assertWagonCancellationActor(
cancellationId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
const row = await this.wagonCancellationService.findById(cancellationId);
const booking = await this.bookingsService.findById(row.bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(

View File

@@ -38,6 +38,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
@@ -65,6 +68,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
BookingWagonCancellation,
CustomerTruckAssignment,
CustomerTruckContainer,
]),
@@ -109,6 +113,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckAssignmentsRepository,
CustomerTruckService,
ContainerReceiptService,
BookingWagonCancellationsRepository,
BookingWagonCancellationService,
],
exports: [
BookingsService,
@@ -120,6 +126,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ConsolidationService,
CustomerTruckService,
ContainerReceiptService,
BookingWagonCancellationService,
],
})
export class BookingsModule { }

View File

@@ -0,0 +1,99 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { WAGON_CANCELLATION_STATUSES } from '../entities/booking-wagon-cancellation.entity';
export class CancelContainerLineDto {
@ApiProperty({ description: 'Container size (ft) as stored on the booking line, e.g. "20", "40"' })
@IsString()
containerSize!: string;
@ApiProperty({ description: 'How many units of this size to cancel' })
@IsInt()
@Min(1)
quantity!: number;
}
export class RequestWagonCancellationDto {
@ApiPropertyOptional({
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
})
@IsOptional()
@IsNumber()
@Min(0.5)
wagons?: number;
@ApiPropertyOptional({
description: 'CONTAINER bookings: units to cancel per size (wagons derived per size)',
type: [CancelContainerLineDto],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => CancelContainerLineDto)
containers?: CancelContainerLineDto[];
@ApiPropertyOptional({ description: 'Customer reason for the cancellation' })
@IsOptional()
@IsString()
@MaxLength(1000)
reason?: string;
}
export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString()
scheduledDate!: string;
}
export class FilterWagonCancellationsDto {
@ApiPropertyOptional({ enum: WAGON_CANCELLATION_STATUSES, isArray: true })
@IsOptional()
@IsArray()
@IsIn(WAGON_CANCELLATION_STATUSES as readonly string[], { each: true })
statuses?: string[];
@ApiPropertyOptional({ description: 'Booking reference / company name search' })
@IsOptional()
@IsString()
@MaxLength(120)
search?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 10 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
}

View File

@@ -0,0 +1,123 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
export const WAGON_CANCELLATION_STATUSES = [
// Requested; fee invoice open; wagons still allocated to the customer.
'FEE_PENDING',
// Fee settled; booking reduced, wagons freed; credit waiting for a rebook.
'CREDIT_AVAILABLE',
// Credit redeemed into a new PAID booking (rebookedBookingId).
'REBOOKED',
// Customer/staff voided the request before paying the fee. Nothing changed.
'WITHDRAWN',
// Reserved for a future expiry policy; not set by code today.
'EXPIRED',
] as const;
export type WagonCancellationStatus = (typeof WAGON_CANCELLATION_STATUSES)[number];
/** Snapshot of one physical container unit cut by the cancellation. */
export interface CancelledUnitSnapshot {
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous: boolean;
isReefer: boolean;
}
/** What the cancellation cut, in the booking's own quantity terms. */
export interface CancelledQuantities {
/** Bulk bookings: tons cut (PER_ITEM cargo: item count, matching cargoTotalWeightVgm). */
bulkTons?: number;
/** Container bookings: units cut per container size. */
bySize?: Record<string, number>;
/**
* Container bookings: the exact physical units cut, snapshotted at fee
* settlement. The rebook reconstructs the new booking from THESE — never
* from a soft-deleted-row scan, which could pick up units dropped by an
* unrelated batch split on the same booking.
*/
units?: CancelledUnitSnapshot[];
}
/**
* One partial-wagon-cancellation cycle on a PAID booking — the audit trail and
* the state machine. The credit itself is not a wallet balance: redeeming it
* creates a real booking through the under-contract create path and marks it
* PAID (see BookingWagonCancellationService).
*/
@Entity({ schema: 'freight', name: 'booking_wagon_cancellations' })
@Index(['bookingId'])
@Index(['status'])
export class BookingWagonCancellation extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'rebooked_booking_id', type: 'uuid', nullable: true })
rebookedBookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'rebooked_booking_id' })
rebookedBooking?: Booking | null;
@Column({ name: 'wagons_cancelled', type: 'numeric', precision: 6, scale: 2 })
wagonsCancelled!: number;
@Column({ name: 'weight_tons', type: 'numeric', precision: 12, scale: 3, default: 0 })
weightTons!: number;
@Column({ name: 'cancelled_quantities', type: 'jsonb' })
cancelledQuantities!: CancelledQuantities;
/**
* The freight value of the cancelled part at the ORIGINAL booking's price —
* informational (shown to the customer as "credit worth"); no refund is ever
* issued from it, the credit is redeemed by rebooking.
*/
@Column({ name: 'credit_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
creditAmount!: number;
@Column({ name: 'fee_rate_id', type: 'uuid', nullable: true })
feeRateId?: string | null;
@ManyToOne(() => Rate, { nullable: true })
@JoinColumn({ name: 'fee_rate_id' })
feeRate?: Rate | null;
@Column({ name: 'fee_amount', type: 'numeric', precision: 14, scale: 2 })
feeAmount!: number;
@Column({ name: 'fee_currency', type: 'varchar', length: 8, default: 'ETB' })
feeCurrency!: string;
@Column({ name: 'fee_invoice_id', type: 'uuid', nullable: true })
feeInvoiceId?: string | null;
@ManyToOne(() => Invoice, { nullable: true })
@JoinColumn({ name: 'fee_invoice_id' })
feeInvoice?: Invoice | null;
@Column({ name: 'fee_paid_at', type: 'timestamptz', nullable: true })
feePaidAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 30, default: 'FEE_PENDING' })
status!: string;
@Column({ name: 'reason', type: 'text', nullable: true })
reason?: string | null;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;
@Column({ name: 'rebooked_at', type: 'timestamptz', nullable: true })
rebookedAt?: Date | null;
}

View File

@@ -68,6 +68,11 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
// Header alarm for the document-review deadline: its own key so only the
// position types that actually decide operation requests are alerted.
perm('a1000001-0001-4000-8000-000000000025', 'edr_freight_app:bookings:doc_review_alert', 'See document-review deadline alarm'),
// Partial wagon cancellation (paid bookings): staff-side keys. The customer
// portal needs none — customer actions are ownership-scoped on the API.
perm('a1000001-0001-4000-8000-000000000026', 'edr_freight_app:bookings:wagon_cancellation_view', 'View wagon cancellation history'),
perm('a1000001-0001-4000-8000-000000000027', 'edr_freight_app:bookings:wagon_cancellation_void', 'Void a pending wagon cancellation'),
perm('a1000001-0001-4000-8000-000000000028', 'edr_freight_app:bookings:wagon_cancellation_rebook', 'Rebook cancelled wagons for a customer'),
];
/**
@@ -431,6 +436,9 @@ export const FREIGHT_PERMS = {
uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output',
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
docReviewAlert: 'edr_freight_app:bookings:doc_review_alert',
wagonCancellationView: 'edr_freight_app:bookings:wagon_cancellation_view',
wagonCancellationVoid: 'edr_freight_app:bookings:wagon_cancellation_void',
wagonCancellationRebook: 'edr_freight_app:bookings:wagon_cancellation_rebook',
},
contracts: {
view: 'edr_freight_app:contracts:view',
@@ -825,6 +833,8 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.wagonCancellationView,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
FREIGHT_PERMS.contracts.view,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
@@ -837,6 +847,7 @@ export const ROLE_PERMISSION_PRESETS = {
operationsOfficer: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
FREIGHT_PERMS.bookings.wagonCancellationView,
// They are the ones who accept/reject operation requests, so they are the
// ones the doc-review countdown is for.
FREIGHT_PERMS.bookings.docReviewAlert,
@@ -877,7 +888,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.approveCeo,
...allRuleEngineViewKeys(),
],
finance: [FREIGHT_PERMS.bookings.view],
finance: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.wagonCancellationView],
// Global Logistics: manages ONLY the customs-clearance queue. Scoped out of
// the general booking-request list (no bookings:view) — instead a dedicated
// clearance:view permission lists the clearance bookings. Reviews customer
@@ -920,6 +931,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.wagonCancellationView,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
FREIGHT_PERMS.bookings.generateContract,
FREIGHT_PERMS.bookings.signStaff,
FREIGHT_PERMS.bookings.reviewDocuments,

View File

@@ -29,6 +29,7 @@ import {
Wallet,
LifeBuoy,
TrainFront,
XCircle,
} from "lucide-react";
import { useEffect } from "react";
import {
@@ -54,6 +55,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage";
@@ -184,6 +186,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations",
icon: <XCircle />,
permission: FREIGHT_PERMS.bookings.wagonCancellationView,
},
// Operations hub: per-shipment clearance-document review for services
// WITHOUT customs clearing (self-clearance) — bookings only.
{
@@ -897,6 +905,16 @@ const App = () => {
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route
path="wagon-cancellations"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.wagonCancellationView}
>
<WagonCancellationsPage />
</RequirePermission>
}
/>
<Route
path="booking-requests/:id"
element={<BookingRequestDetailPage />}

View File

@@ -27,6 +27,10 @@ export const FREIGHT_PERMS = {
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
wagonCancellationView: "edr_freight_app:bookings:wagon_cancellation_view",
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook",
},
contracts: {
view: "edr_freight_app:contracts:view",

View File

@@ -0,0 +1,420 @@
import {
Anchor,
Badge,
Box,
Button,
Card,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Search, XCircle } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom";
import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
import { toDayString } from "@/hooks/useListControls";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
DataTable,
DataTableFooter,
usePagination,
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; company?: { name: string } };
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
}
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" },
};
const STATUS_FILTER_OPTIONS = (
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
).map((s) => ({ value: s, label: STATUS_CHIP[s].label }));
function StatusChip({ status }: { status: WagonCancellationStatus }) {
const chip = STATUS_CHIP[status] ?? { label: status, color: "gray" };
return (
<Badge
color={chip.color}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
style={{ fontSize: "0.7rem", letterSpacing: "0.05em" }}
>
{chip.label}
</Badge>
);
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`;
}
/**
* Staff view of partial wagon cancellations: every slice of capacity a
* customer gave back, its cancellation fee, and where the credit went
* (rebooked, still available, expired, or the request was voided).
*/
export default function WagonCancellationsPage() {
const { user } = useAuth();
const canVoid = hasPermission(
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [status, setStatus] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [from, setFrom] = useState<Date | null>(null);
const [to, setTo] = useState<Date | null>(null);
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(status ? { statuses: status } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(from ? { from: toDayString(from) } : {}),
...(to ? { to: toDayString(to) } : {}),
}),
[pagination.pageIndex, pagination.pageSize, status, debouncedSearch, from, to],
);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["bookings", "wagon-cancellations", filter],
queryFn: async () => {
const res = await api.get<WagonCancellationListResponse>(
"/bookings/wagon-cancellations/history",
{ params: filter },
);
return res.data;
},
});
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const withdraw = useMutation({
mutationFn: (id: string) =>
api.post(`/bookings/wagon-cancellations/${id}/withdraw`),
});
const columns: ColumnDef<WagonCancellation>[] = [
{
id: "requested",
header: () => <span>Requested</span>,
cell: ({ row }) => (
<Text size="xs" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
{
id: "booking",
header: () => <span>Booking</span>,
cell: ({ row }) => (
<Anchor
component={Link}
to={`/dashboard/booking-requests/${row.original.bookingId}`}
size="sm"
fw={600}
>
{row.original.booking?.reference ?? row.original.bookingId}
</Anchor>
),
},
{
id: "company",
header: () => <span>Company</span>,
cell: ({ row }) => (
<Text size="sm">{row.original.booking?.company?.name ?? "—"}</Text>
),
},
{
id: "wagons",
header: () => <span>Wagons</span>,
cell: ({ row }) => <Text size="sm">{row.original.wagonsCancelled}</Text>,
},
{
id: "fee",
header: () => <span>Fee</span>,
cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatAmount(row.original.feeAmount, row.original.feeCurrency)}
</Text>
),
},
{
id: "credit",
header: () => <span>Credit</span>,
cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatAmount(row.original.creditAmount, row.original.feeCurrency)}
</Text>
),
},
{
id: "status",
header: () => <span>Status</span>,
cell: ({ row }) => <StatusChip status={row.original.status} />,
},
{
id: "rebookedAs",
header: () => <span>Rebooked as</span>,
cell: ({ row }) => {
const r = row.original;
if (!r.rebookedBookingId) return <Text size="sm"></Text>;
return (
<Anchor
component={Link}
to={`/dashboard/booking-requests/${r.rebookedBookingId}`}
size="sm"
>
{r.rebookedBooking?.reference ?? r.rebookedBookingId}
</Anchor>
);
},
},
{
id: "actions",
header: () => <span />,
cell: ({ row }) => {
const r = row.original;
if (r.status !== "FEE_PENDING" || !canVoid) return null;
return (
<Group justify="flex-end" wrap="nowrap">
<Button
size="xs"
radius="md"
variant="subtle"
color="red"
onClick={() => setVoiding(r)}
>
Void
</Button>
</Group>
);
},
},
];
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Wagon cancellations"
subtitle="Partial wagon cancellations — fees charged, credits held, and where each credit was rebooked"
breadcrumbs={[
{ label: "Bookings", href: "/dashboard/booking-requests" },
{ label: "Wagon cancellations" },
]}
/>
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search booking ref or company…"
leftSection={<Search size={15} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
resetPage();
}}
w={260}
radius="md"
/>
<Select
placeholder="Status"
data={STATUS_FILTER_OPTIONS}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
w={190}
radius="md"
/>
<DateInput
placeholder="From"
value={from}
onChange={(v) => {
setFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={to ?? undefined}
clearable
radius="md"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="To"
value={to}
onChange={(v) => {
setTo(v ? new Date(v) : null);
resetPage();
}}
minDate={from ?? undefined}
clearable
radius="md"
style={{ minWidth: 140 }}
/>
<Button
variant="subtle"
radius="md"
onClick={() => {
setStatus(null);
setSearch("");
setFrom(null);
setTo(null);
resetPage();
}}
>
Clear
</Button>
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</Stack>
<Modal
opened={Boolean(voiding)}
onClose={() => setVoiding(null)}
radius="md"
title="Void this cancellation?"
>
{!voiding ? null : (
<Stack gap="sm">
<Text size="sm">
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
{voiding.wagonsCancelled} wagon(s) · fee{" "}
{formatAmount(voiding.feeAmount, voiding.feeCurrency)}
</Text>
<Text size="sm" c="dimmed">
The pending fee is dropped and the wagons stay on the booking.
Voiding can't be undone.
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setVoiding(null)}
>
Keep it
</Button>
<Button
color="red"
radius="md"
leftSection={<XCircle size={15} />}
loading={withdraw.isPending}
onClick={async () => {
try {
await withdraw.mutateAsync(voiding.id);
toast.success("Cancellation voided");
setVoiding(null);
void refetch();
} catch {
// interceptor surfaces the reason
}
}}
>
Void
</Button>
</Group>
</Stack>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -48,6 +48,7 @@ import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { WagonCancellationCard } from "./components/WagonCancellationCard";
import { WagonsTab } from "./components/WagonsTab";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
@@ -325,6 +326,11 @@ export function ReadonlyBookingView({
title="Consignment & Schedule"
consignment
/>
{/* Renders only on PAID + paid + contract-backed bookings. */}
<WagonCancellationCard
booking={booking}
onBookingUpdated={onBookingUpdated}
/>
<CompanyInfoCard booking={booking} />
<SupportCard />
</>

View File

@@ -35,6 +35,8 @@ export interface BookingContainerLineDetail {
isOverweight?: boolean;
overweightExcessTons?: number | string | null;
containerNumber?: string | null;
/** Size (ft) as stored on the line ("20"/"40") — the wagon-cancellation key. */
containerSize?: string | null;
containerType?: {
code: string;
label?: string | null;

View File

@@ -0,0 +1,495 @@
import {
Alert,
Box,
Button,
Group,
Modal,
NumberInput,
Stack,
Table,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Link, useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import {
bookingsService,
type RequestWagonCancellationPayload,
type WagonCancellation,
type WagonCancellationPreview,
} from "@/services/bookings.service";
import { OperationDatePicker } from "@/pages/bookings/clearance";
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
import type { BookingDetail } from "../booking-detail-types";
import { fmtDate } from "../utils";
import { CardTitle, SectionCard } from "./layout";
import { PaymentMethodModal } from "./PaymentMethodModal";
// Same pill treatment as WagonsTab's STATUS_TONES so the page reads as one.
const STATUS_TONES: Record<
WagonCancellation["status"],
{ bg: string; color: string; label: string }
> = {
FEE_PENDING: { bg: "#FFFBEB", color: "#92400E", label: "Fee pending" },
CREDIT_AVAILABLE: { bg: "#EAF1FE", color: "#1E40AF", label: "Credit available" },
REBOOKED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Rebooked" },
WITHDRAWN: { bg: "#F1F4F7", color: "#475569", label: "Withdrawn" },
EXPIRED: { bg: "#FEF2F2", color: "#B91C1C", label: "Expired" },
};
function StatusPill({ status }: { status: WagonCancellation["status"] }) {
const tone = STATUS_TONES[status] ?? STATUS_TONES.WITHDRAWN;
return (
<Text
component="span"
fz={11}
fw={700}
px={9}
py={3}
style={{ borderRadius: 999, backgroundColor: tone.bg, color: tone.color }}
>
{tone.label}
</Text>
);
}
const fmtMoney = (amount: number | string, currency: string) =>
`${Number(amount).toLocaleString()} ${currency}`;
const apiErrorMessage = (error: unknown, fallback: string) => {
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 fallback;
};
const th = { color: "#9AA8B5", fontSize: 11 } as const;
/**
* Partial wagon cancellation on a PAID contract booking: request a cut (fee
* previewed first), pay the cancellation fee, then rebook the freed credit
* onto another shipment day — plus the booking's cancellation history.
* Wagons stay allocated until the fee invoice settles.
*/
export function WagonCancellationCard({
booking,
onBookingUpdated,
}: {
booking: Freight.IBooking;
onBookingUpdated?: () => void;
}) {
const navigate = useNavigate();
const status = booking.status as string;
const eligible =
status === "PAID" &&
(booking.paymentStatus as string) === "PAID" &&
!!booking.contractId;
const isBulk = booking.freightType === "BULK";
const detail = booking as BookingDetail;
// The entity field the API serializes on the detail read; not on the DTO type.
const wagonsRequired = Number(
(booking as { wagonsRequired?: number | string | null }).wagonsRequired ?? 0,
);
// Live units per container size ("20"/"40"), summed across lines.
const containerLines = useMemo(() => {
const bySize = new Map<string, number>();
for (const line of detail.bookingContainers ?? []) {
const size =
line.containerSize ??
(line.containerType?.sizeFt != null
? String(line.containerType.sizeFt)
: null);
if (!size) continue;
bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0));
}
return [...bySize.entries()].map(([containerSize, quantity]) => ({
containerSize,
quantity,
}));
}, [detail.bookingContainers]);
const { data, refetch } = useQuery({
...api.bookings.listWagonCancellations.queryOptions({
input: { bookingId: booking.id },
}),
enabled: eligible,
});
// History includes rows where this booking is the rebooked TARGET — only
// rows this booking opened itself can be paid/withdrawn/rebooked from here.
const rows = data?.items ?? [];
const ownRows = rows.filter((r) => r.bookingId === booking.id);
const openRow = ownRows.find((r) => r.status === "FEE_PENDING");
const creditRow = ownRows.find((r) => r.status === "CREDIT_AVAILABLE");
const feePay = useFeeInvoicePayment(booking.id);
// ── Request modal state ──
const [modalOpen, setModalOpen] = useState(false);
const [wagons, setWagons] = useState<number | string>(1);
const [cancelBySize, setCancelBySize] = useState<Record<string, number>>({});
const [reason, setReason] = useState("");
const [preview, setPreview] = useState<WagonCancellationPreview | null>(null);
const closeModal = () => {
setModalOpen(false);
setWagons(1);
setCancelBySize({});
setReason("");
setPreview(null);
};
const requestPayload = (): RequestWagonCancellationPayload | null => {
const trimmedReason = reason.trim();
if (isBulk) {
const n = Number(wagons);
if (!n || n <= 0) return null;
return { wagons: n, ...(trimmedReason ? { reason: trimmedReason } : {}) };
}
const containers = containerLines
.map((l) => ({
containerSize: l.containerSize,
quantity: cancelBySize[l.containerSize] ?? 0,
}))
.filter((c) => c.quantity > 0);
if (!containers.length) return null;
return { containers, ...(trimmedReason ? { reason: trimmedReason } : {}) };
};
const payload = requestPayload();
const previewMutation = useMutation({
mutationFn: (body: RequestWagonCancellationPayload) =>
bookingsService.previewWagonCancellation(booking.id, body),
onSuccess: setPreview,
onError: (e) => {
setPreview(null);
toast.error(apiErrorMessage(e, "Could not calculate the fee. Please try again."));
},
});
const requestMutation = useMutation({
mutationFn: (body: RequestWagonCancellationPayload) =>
bookingsService.requestWagonCancellation(booking.id, body),
onSuccess: () => {
closeModal();
toast.success(
"Cancellation requested — pay the fee to release the wagons.",
{ duration: 6000 },
);
void refetch();
onBookingUpdated?.();
},
onError: (e) =>
toast.error(
apiErrorMessage(e, "Could not request the cancellation. Please try again."),
),
});
const withdrawMutation = useMutation({
mutationFn: () => bookingsService.withdrawWagonCancellation(openRow!.id),
onSuccess: () => {
toast.success("Cancellation withdrawn — the fee invoice was voided.");
void refetch();
onBookingUpdated?.();
},
onError: (e) =>
toast.error(
apiErrorMessage(e, "Could not withdraw the cancellation. Please try again."),
),
});
const [rebookDate, setRebookDate] = useState("");
const rebookMutation = useMutation({
mutationFn: () =>
bookingsService.rebookWagonCancellation(creditRow!.id, {
scheduledDate: rebookDate,
}),
onSuccess: ({ bookingId }) => {
toast.success("Wagons rebooked — taking you to the new booking.", {
duration: 6000,
});
navigate(`/bookings/${bookingId}`);
},
onError: (e) =>
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
});
if (!eligible) return null;
return (
<SectionCard>
<Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle>
{!openRow && !creditRow && (
<Button
variant="default"
radius="md"
leftSection={<TrainTrack size={16} />}
onClick={() => setModalOpen(true)}
>
Cancel wagons
</Button>
)}
</Group>
{openRow ? (
<Stack gap="sm">
<Alert color="yellow" radius="md" icon={<Clock size={18} />}>
A cancellation of {Number(openRow.wagonsCancelled)} wagon(s) is
awaiting its fee of{" "}
<Text span fw={700}>
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
</Text>
. Your wagons stay allocated until the fee is paid pay it to
release them and unlock the rebooking credit, or withdraw the
request to keep the booking as it is.
</Alert>
<Group gap={8}>
<Button
color="edr-green"
radius="md"
leftSection={<CreditCard size={16} />}
onClick={feePay.open}
>
Pay cancellation fee
</Button>
<Button
variant="default"
radius="md"
loading={withdrawMutation.isPending}
onClick={() => withdrawMutation.mutate()}
>
Withdraw request
</Button>
</Group>
</Stack>
) : creditRow ? (
<Stack gap="sm">
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
{Number(creditRow.wagonsCancelled)} wagon(s) were released a
credit of{" "}
<Text span fw={700}>
{fmtMoney(creditRow.creditAmount, booking.paymentCurrency)}
</Text>{" "}
is available. Pick a shipment day to rebook them as a new paid
booking (no further payment needed).
</Alert>
<OperationDatePicker
bookingId={booking.id}
value={rebookDate}
onChange={setRebookDate}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
disabled={!rebookDate}
loading={rebookMutation.isPending}
onClick={() => rebookMutation.mutate()}
>
Rebook wagons
</Button>
</Group>
</Stack>
) : (
<Text fz={13} c="#475569">
Need fewer wagons than you paid for? Cancel part of this booking for
a per-wagon fee the freed freight amount becomes a credit you can
rebook onto another shipment day.
</Text>
)}
{rows.length > 0 && (
<Box style={{ overflowX: "auto" }} mt="md">
<Table verticalSpacing={6} horizontalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th style={th}>Date</Table.Th>
<Table.Th style={th}>Wagons</Table.Th>
<Table.Th style={th}>Fee</Table.Th>
<Table.Th style={th}>Status</Table.Th>
<Table.Th style={th}>Rebooked as</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<Text fz={12.5} c="#475569">
{fmtDate(r.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} fw={700} c="#10202F">
{Number(r.wagonsCancelled)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{fmtMoney(r.feeAmount, r.feeCurrency)}
</Text>
</Table.Td>
<Table.Td>
<StatusPill status={r.status} />
</Table.Td>
<Table.Td>
{r.rebookedBookingId ? (
<Text
component={Link}
to={`/bookings/${r.rebookedBookingId}`}
fz={12.5}
fw={700}
c="#0A6F4D"
style={{ textDecoration: "underline" }}
>
{r.rebookedBooking?.reference ?? "View booking"}
</Text>
) : (
<Text fz={12.5} c="#9AA8B5">
</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
)}
<PaymentMethodModal
opened={feePay.modalOpen}
onClose={feePay.close}
amountLabel={
openRow ? fmtMoney(openRow.feeAmount, openRow.feeCurrency) : undefined
}
currency={openRow?.feeCurrency}
processing={feePay.processing}
error={feePay.error}
otp={feePay.otp}
bill={feePay.bill}
onConfirm={feePay.confirm}
/>
<Modal
opened={modalOpen}
onClose={closeModal}
title={
<Text fw={800} fz={18} c="#10202F">
Cancel wagons
</Text>
}
centered
radius={16}
>
<Stack gap="md">
<Text size="sm" c="#475569">
Choose how much of booking{" "}
<Text span fw={700} c="#10202F">
{booking.reference}
</Text>{" "}
to cancel. A per-wagon fee applies; once it&apos;s paid the wagons
are released and the freed amount becomes a rebooking credit. At
least one wagon must remain to cancel everything, cancel the
whole booking instead.
</Text>
{isBulk ? (
<NumberInput
label="Wagons to cancel"
min={1}
max={wagonsRequired > 1 ? wagonsRequired - 1 : undefined}
allowDecimal={false}
value={wagons}
onChange={(v) => {
setWagons(v);
setPreview(null);
}}
/>
) : (
containerLines.map((line) => (
<NumberInput
key={line.containerSize}
label={`${line.containerSize}ft containers to cancel`}
description={`${line.quantity} on this booking`}
min={0}
max={line.quantity}
allowDecimal={false}
value={cancelBySize[line.containerSize] ?? 0}
onChange={(v) => {
setCancelBySize((prev) => ({
...prev,
[line.containerSize]: Number(v) || 0,
}));
setPreview(null);
}}
/>
))
)}
<Textarea
label="Reason (optional)"
placeholder="Why are these wagons no longer needed?"
autosize
minRows={2}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
{preview && (
<Alert color="blue" radius="md">
<Text fz={13}>
Cancelling{" "}
<Text span fw={700}>
{preview.wagons} wagon(s)
</Text>{" "}
(~{preview.weightTons} t) costs a fee of{" "}
<Text span fw={700}>
{fmtMoney(preview.feeAmount, preview.feeCurrency)}
</Text>{" "}
({fmtMoney(preview.feePerWagon, preview.feeCurrency)} per
wagon) and frees a rebooking credit of{" "}
<Text span fw={700}>
{fmtMoney(preview.creditAmount, booking.paymentCurrency)}
</Text>
.
</Text>
</Alert>
)}
<Group justify="flex-end" gap={8}>
<Button
variant="default"
radius={10}
disabled={!payload}
loading={previewMutation.isPending}
onClick={() => payload && previewMutation.mutate(payload)}
>
Calculate fee
</Button>
<Button
color="red"
radius={10}
disabled={!payload}
loading={requestMutation.isPending}
onClick={() => payload && requestMutation.mutate(payload)}
>
Request cancellation
</Button>
</Group>
</Stack>
</Modal>
</SectionCard>
);
}

View File

@@ -3,18 +3,22 @@ import { useState } from "react";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { type PaymentMethod } from "@/services/payments.service";
import { invoicesService } from "@/services/invoices.service";
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui";
/** Fee invoice opened by a partial wagon cancellation (see WagonCancellationCard). */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = "WAGON_CANCEL_FEE";
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /billing/my-invoices/:id/pay for the booking's currently payable
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
* the booking detail page, the booking list, and the home page so "Pay now"
* behaves identically everywhere.
* Core of the booking payment flows: resolves the booking's invoices (shared
* query/key with BookingPaymentPanel, so they share that cache), picks the one
* matching `match`, and charges it through the ownership-checked portal route —
* redirect vs CAC Bank OTP handled by `useInvoicePayment`.
*/
export function useBookingPayment(bookingId: string) {
function useBookingInvoicePayment(
bookingId: string,
match: (invoice: PortalInvoice) => boolean,
) {
const [modalOpen, setModalOpen] = useState(false);
const [noInvoice, setNoInvoice] = useState(false);
@@ -22,7 +26,7 @@ export function useBookingPayment(bookingId: string) {
queryKey: ["booking-invoices", bookingId],
queryFn: () => invoicesService.listForSource("booking", bookingId),
});
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
const payableInvoiceId = invoices.find(match)?.id;
const flow = useInvoicePayment();
@@ -56,3 +60,28 @@ export function useBookingPayment(bookingId: string) {
},
};
}
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /billing/my-invoices/:id/pay for the booking's currently payable
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
* the booking detail page, the booking list, and the home page so "Pay now"
* behaves identically everywhere.
*/
export function useBookingPayment(bookingId: string) {
return useBookingInvoicePayment(bookingId, (inv) => isPayable(inv.status));
}
/**
* Same flow, but targets the booking's payable wagon-cancellation FEE invoice
* (type WAGON_CANCEL_FEE) — the freight invoice is already paid on these
* bookings, so the generic "first payable" pick would work today, but pinning
* the type keeps the two buttons from ever racing over the same invoice.
*/
export function useFeeInvoicePayment(bookingId: string) {
return useBookingInvoicePayment(
bookingId,
(inv) => inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE && isPayable(inv.status),
);
}

View File

@@ -311,7 +311,7 @@ function mapBookingToShipmentValues(
const sizes = (contract.cargoScope ?? [])
.map((s) => s.containerSize)
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
values.containers = (sizes.length ? sizes : ["20ft", "40ft"]).map(lineFor);
values.containers = (sizes.length ? sizes : (["20ft", "40ft"] as const)).map(lineFor);
} else {
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
const amount = Number(b.cargoTotalWeightVgm ?? 0);

View File

@@ -15,6 +15,8 @@ import {
GeneratePriceResponse,
type MyBookingWindow,
SubmitBookingResponse,
type WagonCancellationListFilter,
type WagonCancellationListResponse,
} from "./bookings.service";
import {
contractsService,
@@ -483,6 +485,22 @@ export const api = {
bookingsService.getExportTrains(bookingId, date, cargo),
),
listWagonCancellations: endpoint<
{ bookingId: string },
WagonCancellationListResponse
>("bookings", "listWagonCancellations", ({ bookingId }) =>
bookingsService.listWagonCancellations(bookingId),
),
listMyWagonCancellations: endpoint<
WagonCancellationListFilter | void,
WagonCancellationListResponse
>(
"bookings",
"listMyWagonCancellations",
bookingsService.listMyWagonCancellations,
),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
"train-scheduling",
"myBookingWindows",

View File

@@ -223,6 +223,70 @@ export interface BookingWagonAllocation {
containers: BookingWagonContainer[];
}
// ── Partial wagon cancellation (paid bookings) ──────────────────────────────
export type WagonCancellationStatus =
| "FEE_PENDING"
| "CREDIT_AVAILABLE"
| "REBOOKED"
| "WITHDRAWN"
| "EXPIRED";
/** One partial-cancellation ledger row of a paid booking. */
export interface WagonCancellation {
id: string;
bookingId: string;
rebookedBookingId?: string | null;
wagonsCancelled: number;
weightTons: number;
/** What was cut: bulk tons, or container units per size (ft). */
cancelledQuantities: { bulkTons?: number; bySize?: Record<string, number> };
/** Rebooking credit — the cancelled share of the original freight price. */
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 } | null;
rebookedBooking?: { id: string; reference: string } | null;
}
/** Fee/credit preview of a partial wagon cancellation (no writes). */
export interface WagonCancellationPreview {
wagons: number;
weightTons: number;
feePerWagon: number;
feeAmount: number;
feeCurrency: string;
creditAmount: number;
}
export interface RequestWagonCancellationPayload {
/** BULK bookings: number of wagons to cancel (tons derived proportionally). */
wagons?: number;
/** CONTAINER bookings: units to cancel per size ("20"/"40", as stored on the line). */
containers?: Array<{ containerSize: string; quantity: number }>;
reason?: string;
}
export interface WagonCancellationListFilter {
statuses?: string[];
search?: string;
from?: string;
to?: string;
page?: number;
pageSize?: number;
}
export interface WagonCancellationListResponse {
items: WagonCancellation[];
total: number;
}
export const bookingsService = {
list: async (
filter: BookingListFilter | void = {},
@@ -597,6 +661,73 @@ export const bookingsService = {
return (data.data ?? data) as BookingWagonAllocation[];
},
// ── Partial wagon cancellation ──
/** Fee/credit preview for the confirm dialog — same math as the request, no writes. */
previewWagonCancellation: async (
id: string,
payload: RequestWagonCancellationPayload,
): Promise<WagonCancellationPreview> => {
const { data } = await client.post(
`/api/bookings/${id}/wagon-cancellations/preview`,
payload,
);
return data.data ?? data;
},
/** Open a cancellation: issues the fee invoice; wagons release once the fee settles. */
requestWagonCancellation: async (
id: string,
payload: RequestWagonCancellationPayload,
): Promise<WagonCancellation> => {
const { data } = await client.post(
`/api/bookings/${id}/wagon-cancellations`,
payload,
);
return data.data ?? data;
},
/** Cancellation history of one booking (as source and as rebooked target). */
listWagonCancellations: async (
bookingId: string,
): Promise<WagonCancellationListResponse> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/wagon-cancellations`,
);
return data.data ?? data;
},
/** The signed-in customer's wagon cancellations (paginated, filterable). */
listMyWagonCancellations: async (
filter: WagonCancellationListFilter | void = {},
): Promise<WagonCancellationListResponse> => {
const { data } = await client.get("/api/bookings/wagon-cancellations/my", {
params: filter,
});
return data.data ?? data;
},
/** Void a FEE_PENDING request — the fee invoice is cancelled, nothing was released. */
withdrawWagonCancellation: async (
cancellationId: string,
): Promise<WagonCancellation> => {
const { data } = await client.post(
`/api/bookings/wagon-cancellations/${cancellationId}/withdraw`,
);
return data.data ?? data;
},
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
rebookWagonCancellation: async (
cancellationId: string,
payload: { scheduledDate: string },
): Promise<{ cancellation: WagonCancellation; bookingId: string }> => {
const { data } = await client.post(
`/api/bookings/wagon-cancellations/${cancellationId}/rebook`,
payload,
);
return data.data ?? data;
},
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).