feat: enhance booking cancellation logic for consolidated pairs with one paid side

This commit is contained in:
Marshal
2026-08-24 07:13:50 +00:00
parent 31fa313c66
commit 979f024869
4 changed files with 125 additions and 38 deletions

View File

@@ -440,7 +440,9 @@ export class BookingTransitionService {
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
// Consolidated pair: the shared wagon dies with this hold. An unpaid
// partner's hold is released with it (both cancel, no fee); a PAID partner
// keeps the whole wagon and this canceller owes the cancellation fee.
// cannot board alone, so the partnerLapsed listener cancels it too, with
// the cancellation fee — this unpaid canceller owes nothing (fees only
// apply to paid bookings).
const partnerId = booking.consolidationPartnerId;
if (partnerId) {
const partner = await this.bookingsService.findById(partnerId);
@@ -452,7 +454,7 @@ export class BookingTransitionService {
);
if (partnerPaid) {
this.events.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: booking.id,
paidBookingId: partnerId,
});
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
const partnerReason = "Cancelled with its consolidation partner";
@@ -592,10 +594,11 @@ export class BookingTransitionService {
// Consolidated pair: a shared wagon never ships half-full, so cancelling
// one half settles the other too. Neither paid → both cancel, no fee. A
// PAID partner instead keeps the whole wagon and the unpaid canceller
// owes the cancellation fee (opened by the partnerLapsed listener). A
// PAID booking itself never comes through here (status gate above) — it
// cancels via wagon cancellation, where the fee machinery lives.
// PAID partner cannot board alone, so the partnerLapsed listener cancels
// it too, with the cancellation fee — the unpaid canceller owes nothing
// (fees only apply to paid bookings). A PAID booking itself never comes
// through here (status gate above) — it cancels via wagon cancellation,
// where the fee machinery lives.
const partnerId = booking.consolidationPartnerId;
if (partnerId) {
const partner = await this.bookingsService.findById(partnerId);
@@ -607,7 +610,7 @@ export class BookingTransitionService {
);
if (partnerPaid) {
this.events.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: booking.id,
paidBookingId: partnerId,
});
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
const partnerReason = "Cancelled with its consolidation partner";

View File

@@ -538,37 +538,51 @@ export class BookingWagonCancellationService {
}
/**
* The batch engine expired an UNPAID booking whose consolidation partner had
* already PAID: the paid partner keeps the whole wagon at no extra cost; the
* lapsed side owes the cancellation fee on its own wagons — shared wagon
* included (ceil). Credit is 0 (nothing was paid); once the fee settles GL
* rebooks the customer through a normal new booking.
* A consolidation pair broke with only one side PAID: the unpaid half
* expired/cancelled fee-free (cancellation fees only ever apply to a paid
* booking), and the PAID half cannot board either — its odd 20ft has no
* partner for the shared wagon. So the PAID booking is cancelled too, owing
* the cancellation fee on ceil of its own fractional wagons (shared wagon
* included); its paid freight is kept as rebooking credit. Once the fee
* settles, GL staff rebook it through a normal new booking, where its odd
* 20ft goes through consolidation pairing again.
*/
@OnEvent('booking.consolidation.partnerLapsed')
async onConsolidationPartnerLapsed(payload: {
expiredBookingId: string;
paidBookingId: string;
}): Promise<void> {
try {
const booking = await this.bookingsRepository.findById(
payload.expiredBookingId,
payload.paidBookingId,
);
if (!booking) return;
if (['CANCELLED', 'EXPIRED', 'COMPLETED'].includes(booking.status)) return;
if (await this.repo.findOpenForBooking(booking.id)) return; // already charged
const row = await this.openConsolidationBreak(
booking,
'ceil',
0,
'Expired while its consolidation partner had paid — cancellation fee applies',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
);
if (row.status !== 'FEE_PENDING') return; // nothing owed
await this.dataSource.getRepository(Booking).update(booking.id, {
status: 'CANCELLED',
trainScheduleId: null,
requestedTrainScheduleId: null,
});
await this.detachFromSchedule(booking);
this.notifyCustomer(
booking,
'Cancellation fee due',
`${booking.reference} expired unpaid while sharing a wagon with a paid booking. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced — settle it before booking again.`,
'Consolidated booking cancelled',
`${booking.reference} shared a wagon with a booking that was never paid, so it cannot board and is cancelled. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced; your paid freight is kept as credit — settle the fee and EDR staff will rebook you.`,
);
this.notifyStaff(
booking,
'Consolidation partner lapsed — paid booking cancelled',
`${booking.reference}: its consolidation partner lapsed unpaid, so the paid booking is cancelled with a cancellation fee invoice. Rebook it from its credit once the fee settles (it must pair up again).`,
);
} catch (err) {
this.logger.error(
`Consolidation-lapse fee failed for booking ${payload.expiredBookingId}: ${err instanceof Error ? err.message : String(err)}`,
`Consolidation-lapse cancellation failed for paid booking ${payload.paidBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}