booking cancellation

This commit is contained in:
Marshal
2026-08-15 08:53:24 +00:00
parent 086c4fe8c9
commit c9c2d4dcb3
26 changed files with 751 additions and 163 deletions

View File

@@ -0,0 +1,42 @@
import { BadRequestException } from '@nestjs/common';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
/**
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
* cut is allowed and takes the exact cargo total; over-cut is rejected; a
* partial cut stays proportional.
*/
describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
const svc = Object.create(BookingWagonCancellationService.prototype) as {
resolveRequestedCut(booking: unknown, dto: unknown): Promise<{
wagons: number;
weightTons: number;
quantities: { bulkTons?: number };
}>;
};
const booking = {
id: 'b1',
freightType: 'BULK',
wagonsRequired: 4,
cargoTotalWeightVgm: 250.5,
bulkTotalWeightTons: null,
};
it('cancels every wagon with the exact total tonnage', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
});
it('rejects more wagons than the booking has', async () => {
await expect(svc.resolveRequestedCut(booking, { wagons: 5 })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('sizes a partial cut proportionally', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 1 });
expect(cut.wagons).toBe(1);
expect(cut.weightTons).toBeCloseTo(62.625, 3);
});
});

View File

@@ -7,8 +7,9 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In } from 'typeorm';
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
import { BillingService } from '../billing/billing.service';
import { ContractBookingService } from '../contracts/contract-booking.service';
@@ -18,9 +19,11 @@ import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.en
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 { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
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';
@@ -46,8 +49,9 @@ import {
/**
* 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.
* configure it in the normal rates UI, one PER_WAGON rate per trade direction
* + cargo kind + type (20ft / 40ft container type, or bulk commodity), so the
* fee scales with the cancelled wagon count and differs by what was booked.
*/
export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
@@ -62,8 +66,20 @@ interface RequestedCut {
quantities: CancelledQuantities;
}
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
interface PricedFee {
amount: number;
currency: string;
/** Effective per-wagon fee (amount / wagons) — one number for the customer. */
perWagon: number;
/** Rate rows used; the first is recorded on the ledger row. */
rates: Rate[];
}
/**
* Partial wagon cancellation on a PAID booking, with a rebooking credit.
* Wagon cancellation on a PAID booking (partial or whole), with a rebooking
* credit. Cutting every wagon ends the source booking CANCELLED at T2; the
* credit then rebooks as a fresh booking under the same contract.
*
* Lifecycle (one ledger row per cycle, see BookingWagonCancellation):
* T1 request — validate + price the fee, open the fee invoice. Nothing else
@@ -91,6 +107,7 @@ export class BookingWagonCancellationService {
private readonly repo: BookingWagonCancellationsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly billing: BillingService,
private readonly exchangeService: ExchangeService,
@Inject(forwardRef(() => ContractBookingService))
private readonly contractBooking: ContractBookingService,
@Inject(forwardRef(() => ClearanceMilestoneService))
@@ -120,14 +137,13 @@ export class BookingWagonCancellationService {
}> {
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);
const fee = await this.priceFee(booking, cut);
return {
wagons: cut.wagons,
weightTons: cut.weightTons,
feePerWagon: Number(rate.rateValue),
feeAmount,
feeCurrency: rate.currency,
feePerWagon: fee.perWagon,
feeAmount: fee.amount,
feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, cut.wagons),
};
}
@@ -146,8 +162,8 @@ export class BookingWagonCancellationService {
}
const cut = await this.resolveRequestedCut(booking, dto);
const rate = await this.feeRate();
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
const fee = await this.priceFee(booking, cut);
const feeAmount = fee.amount;
const creditAmount = this.creditFor(booking, cut.wagons);
const row = await this.repo.create({
@@ -156,9 +172,11 @@ export class BookingWagonCancellationService {
weightTons: cut.weightTons,
cancelledQuantities: cut.quantities,
creditAmount,
feeRateId: rate.id,
// ponytail: one FK for a mixed-size container cut records the first
// size's rate; the invoice line carries the effective per-wagon fee.
feeRateId: fee.rates[0].id,
feeAmount,
feeCurrency: rate.currency,
feeCurrency: fee.currency,
status: 'FEE_PENDING',
reason: dto.reason ?? null,
requestedByUserId: userId ?? null,
@@ -173,15 +191,15 @@ export class BookingWagonCancellationService {
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: rate.currency,
currency: fee.currency,
lines: [
{
chargeType: 'CANCELLATION_FEE',
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`,
quantity: cut.wagons,
unitRate: Number(rate.rateValue),
unitRate: fee.perWagon,
amount: feeAmount,
currency: rate.currency,
currency: fee.currency,
metadata: { wagonCancellationId: row.id },
},
],
@@ -343,12 +361,28 @@ export class BookingWagonCancellationService {
const preSplitQuantities =
booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight));
// Whole-booking cut: nothing is left to ship, so the booking ends
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
// train. The credit row still points at it for T3.
const wagonsLeft = round2(
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
);
const isFull = wagonsLeft <= 0;
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)),
wagonsRequired: Math.max(0, wagonsLeft),
cargoTotalWeightVgm: Math.max(
0,
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
),
totalAmount: Math.max(
0,
round2(Number(booking.totalAmount) - Number(row.creditAmount)),
),
isSplit: true,
preSplitQuantities,
...(isFull
? { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null }
: {}),
} as never);
await manager.getRepository(BookingWagonCancellation).update(row.id, {
@@ -360,11 +394,15 @@ export class BookingWagonCancellationService {
});
const booking = await this.bookingsRepository.findById(row.bookingId);
if (booking?.status === 'CANCELLED') await this.detachFromSchedule(booking);
if (booking) {
const whole = booking.status === 'CANCELLED';
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.`,
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
whole
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`
: `${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(
@@ -372,6 +410,33 @@ export class BookingWagonCancellationService {
);
}
/**
* Whole-booking cut: take the cancelled booking OFF its train entirely —
* schedule link, leftover wagon slots, window status — via the ops unassign
* path (no "removed from train" notice: the customer cancelled it). A stale
* link would keep showing the booking on the schedule AND poison every later
* auto wagon allocation on that train (the whole-train re-plan rejects a
* CANCELLED booking). Then re-run allocation so bookings held back by it
* (e.g. the rebooked credit) get their wagons.
*/
private async detachFromSchedule(booking: Booking): Promise<void> {
const links = await this.dataSource
.getRepository(TrainScheduleBooking)
.find({ where: { bookingId: booking.id } });
for (const link of links) {
try {
await this.trainScheduling.unassignBooking(link.trainScheduleId, booking.id, undefined, {
notifyCustomer: false,
});
await this.trainScheduling.tryAutoWagonAllocation(link.trainScheduleId);
} catch (err) {
this.logger.error(
`Detach of cancelled booking ${booking.reference} from schedule ${link.trainScheduleId} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
// ── T3: rebook ──────────────────────────────────────────────────────────────
async rebook(
@@ -401,6 +466,8 @@ export class BookingWagonCancellationService {
}
const createDto = this.buildRebookDto(row, dto.scheduledDate);
// Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
const created = await this.contractBooking.createUnderContract(
source.contractId,
createDto,
@@ -413,9 +480,13 @@ export class BookingWagonCancellationService {
// The freight is already paid (credit) — mark PAID and let the existing
// paid-booking machinery place it. No invoice is generated for it.
// Its price IS the credit (already paid, in the source currency) — not a
// fresh live-rate quote; a later cut of the rebooked booking credits from it.
await this.dataSource.getRepository(Booking).update(newBookingId, {
paymentStatus: 'PAID',
status: 'PAID',
totalAmount: Number(row.creditAmount),
paymentCurrency: source.paymentCurrency,
});
await this.copyClearanceState(source, newBookingId);
@@ -539,9 +610,9 @@ export class BookingWagonCancellationService {
wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize));
}
wagons = round2(wagons);
if (wagons >= totalWagons) {
if (wagons > totalWagons) {
throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.',
`Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
);
}
// Snapshot the LIFO-picked physical units up front (read-only — cargo is
@@ -577,9 +648,11 @@ export class BookingWagonCancellationService {
}
}
}
const weightShare = round3(
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons),
);
// Whole-booking cut takes the exact total, no ratio rounding.
const weightShare =
wagons >= totalWagons
? round3(Number(booking.cargoTotalWeightVgm))
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
return {
wagons,
weightTons: weightShare,
@@ -594,17 +667,19 @@ export class BookingWagonCancellationService {
if (!wagons || wagons <= 0) {
throw new BadRequestException('Specify how many wagons to cancel.');
}
if (wagons >= totalWagons) {
if (wagons > totalWagons) {
throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.',
`Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
);
}
// Whole-booking cut: all cargo, exactly. Otherwise proportional sizing.
// 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 isFull = wagons >= totalWagons;
let tons = Number(booking.cargoTotalWeightVgm) * (isFull ? 1 : wagons / totalWagons);
const isPerItem = booking.bulkTotalWeightTons != null;
tons = isPerItem ? Math.floor(tons) : round3(tons);
tons = isPerItem && !isFull ? Math.floor(tons) : round3(tons);
if (tons <= 0) {
throw new BadRequestException('The requested cut is too small to release cargo.');
}
@@ -641,19 +716,23 @@ export class BookingWagonCancellationService {
}
const wagons = allocations.length;
if (wagons >= totalWagons) {
if (wagons > totalWagons) {
throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.',
`Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
);
}
const isFull = wagons >= totalWagons;
if (booking.freightType !== 'CONTAINER') {
const allocated = allocations.reduce(
(s, a) => s + Number(a.allocatedWeightTons || 0),
0,
);
const tons =
allocated > 0
// Whole-booking cut takes the exact total; partial takes the wagons'
// allocated tonnage (ratio fallback when nothing is allocated yet).
const tons = isFull
? round3(Number(booking.cargoTotalWeightVgm))
: allocated > 0
? round3(allocated)
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
return {
@@ -714,21 +793,83 @@ export class BookingWagonCancellationService {
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
}
private async feeRate(): Promise<Rate> {
const rate = await this.dataSource.getRepository(Rate).findOne({
/**
* Price the cut off the LIVE per-wagon cancellation rates for the booking's
* trade direction. Bulk bills the rate scoped to the booking's commodity ×
* cancelled wagons; a container cut bills each size at its own container
* type's rate × the wagons that size occupies (two 20ft share one). A
* booking owned by a shipping line prices off that line's rates only —
* standard rates are never a fallback, matching booking pricing.
*/
private async priceFee(booking: Booking, cut: RequestedCut): Promise<PricedFee> {
const raw = await this.priceFeeInRateCurrency(booking, cut);
// Bill in the booking's own currency (rates are configured in USD; ETB
// bookings pay ETB) — same USD→ETB conversion booking pricing applies.
const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
const from = raw.currency === 'ETB' ? 'ETB' : 'USD';
if (from === target) return raw;
const fx = await this.exchangeService.getRate(from, target);
return {
...raw,
amount: round2(raw.amount * fx),
perWagon: round2(raw.perWagon * fx),
currency: target,
};
}
private async priceFeeInRateCurrency(
booking: Booking,
cut: RequestedCut,
): Promise<PricedFee> {
const rates = await this.dataSource.getRepository(Rate).find({
where: {
rateType: WAGON_CANCELLATION_FEE_RATE_TYPE,
rateUnit: 'PER_WAGON',
status: 'LIVE',
tradeDirection: booking.tradeDirection,
shippingLineCompanyId: booking.shippingLineCompanyId ?? IsNull(),
},
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).',
const missing = (scope: string): BadRequestException =>
new BadRequestException(
`No LIVE per-wagon cancellation fee is configured for ${scope} on ${booking.tradeDirection} — ask EDR to set it in the rate engine (surcharge: Cancellation).`,
);
if (booking.freightType !== 'CONTAINER') {
const rate = rates.find(
(r) => !r.containerTypeId && !!r.cargoTypeId && r.cargoTypeId === booking.cargoTypeId,
);
if (!rate) throw missing(`bulk cargo type ${booking.cargoType?.cargoTypeName ?? booking.cargoTypeId ?? '?'}`);
const amount = round2(Number(rate.rateValue) * cut.wagons);
return { amount, currency: rate.currency, perWagon: Number(rate.rateValue), rates: [rate] };
}
return rate;
// Container: split the cancelled wagons across sizes in proportion to the
// wagon-space each size's units occupy, so the total always equals
// cut.wagons (whole wagons on an allocation cut, fractional on a quantity cut).
const bySize = Object.entries(cut.quantities.bySize ?? {}).filter(([, qty]) => qty > 0);
const spaceOf = ([size, qty]: [string, number]) => qty * wagonsPerUnitForSize(Number(size));
const totalSpace = bySize.reduce((s, e) => s + spaceOf(e), 0);
if (!bySize.length || totalSpace <= 0) throw missing('containers');
const containerTypes = await this.dataSource.getRepository(ContainerType).find();
const used: Rate[] = [];
let amount = 0;
let currency = '';
for (const entry of bySize) {
const [size] = entry;
const sizeFt = Number(size);
const typeIds = new Set(
containerTypes.filter((ct) => Number(ct.sizeFt) === sizeFt).map((ct) => ct.id),
);
const rate = rates.find((r) => !!r.containerTypeId && typeIds.has(r.containerTypeId));
if (!rate) throw missing(`${sizeFt || '?'}ft containers`);
currency = rate.currency;
used.push(rate);
amount += Number(rate.rateValue) * cut.wagons * (spaceOf(entry) / totalSpace);
}
amount = round2(amount);
return { amount, currency, perWagon: round2(amount / cut.wagons), rates: used };
}
/**
@@ -902,9 +1043,9 @@ export class BookingWagonCancellationService {
booking: Booking,
tons: number,
): Promise<void> {
if (tons >= Number(booking.cargoTotalWeightVgm)) {
if (tons > Number(booking.cargoTotalWeightVgm)) {
throw new BadRequestException(
'Booking changed since the request: the cut no longer leaves any cargo.',
'Booking changed since the request: the cut exceeds the cargo left on the booking.',
);
}
if (booking.bulkTotalWeightTons != null) {