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

@@ -246,6 +246,71 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
describe('expire — consolidated pair, one side paid', () => {
const pairBooking = (id: string, partnerId: string, paid: boolean): Booking =>
({
id,
reference: id,
status: paid ? 'PAID' : 'SELECTED_FOR_BATCH',
paymentStatus: paid ? 'PAID' : 'PENDING',
consolidationPartnerId: partnerId,
trainScheduleId: null,
bookingContainers: [],
}) as unknown as Booking;
let emit: jest.Mock;
let unpaid: Booking;
let paid: Booking;
beforeEach(() => {
unpaid = pairBooking('unpaid-1', 'paid-1', false);
paid = pairBooking('paid-1', 'unpaid-1', true);
emit = jest.fn();
(service as unknown as { eventEmitter: { emit: jest.Mock } }).eventEmitter = { emit };
(bookingsRepository as unknown as { clearConsolidationPair: jest.Mock }).clearConsolidationPair =
jest.fn().mockResolvedValue(undefined);
dataSource.getRepository().findOne.mockImplementation(
async ({ where }: { where: { id: string } }) =>
where.id === 'paid-1' ? paid : unpaid,
);
});
it('expires the unpaid side fee-free and cancels the PAID partner via partnerLapsed', async () => {
await (service as unknown as { expire(b: Booking): Promise<void> }).expire(unpaid);
// Paid partner is NOT rescued onto a train — the listener cancels it with the fee.
expect(emit).toHaveBeenCalledWith('booking.consolidation.partnerLapsed', {
paidBookingId: 'paid-1',
});
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
// The unpaid side itself just expires.
expect(bookingsRepository.update).toHaveBeenCalledWith(
'unpaid-1',
expect.objectContaining({ status: 'EXPIRED' }),
);
expect(notifier.expired).toHaveBeenCalledTimes(1);
});
it('wrong side called first: PAID booking is cancelled via partnerLapsed, never rescued', async () => {
await (service as unknown as { expire(b: Booking): Promise<void> }).expire(paid);
expect(emit).toHaveBeenCalledWith('booking.consolidation.partnerLapsed', {
paidBookingId: 'paid-1',
});
// The unpaid partner expired fee-free…
expect(bookingsRepository.update).toHaveBeenCalledWith(
'unpaid-1',
expect.objectContaining({ status: 'EXPIRED' }),
);
// …and the paid side was neither expired nor allocated here.
expect(bookingsRepository.update).not.toHaveBeenCalledWith(
'paid-1',
expect.objectContaining({ status: 'EXPIRED' }),
);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
});
});
describe('extendPaymentPhaseForTopUp', () => {
const schedRepo = () => dataSource.getRepository();

View File

@@ -3991,9 +3991,10 @@ export class BookingBatchService implements OnModuleInit {
* taken, so it boards, even when the webhook arrived after the deadline or the
* settle read a stale row. It allocates onto the train it was selected for; if
* the wagon planner then finds no physical wagon, the booking stays linked and
* staff assign wagons manually. Consolidated bookings are exempt from the
* rescue: the shared wagon is both-or-neither, and settleReserved owns that
* pair decision.
* staff assign wagons manually. EXCEPTION — a consolidated booking whose
* partner lapsed unpaid is NOT rescued: its odd 20ft cannot board without the
* partner, so the paid side is cancelled with the cancellation fee (the
* partnerLapsed listener in BookingWagonCancellationService).
*/
private async expire(
booking: Booking,
@@ -4001,10 +4002,11 @@ export class BookingBatchService implements OnModuleInit {
): Promise<void> {
// Consolidated pair: break the link FIRST, then settle each side singly.
// - neither paid → both expire, no fee.
// - one side paid → the paid half keeps the whole wagon (rescued by the
// paid guard below at no extra cost); the lapsed half expires and owes
// the cancellation fee (the 'partnerLapsed' event opens the fee invoice
// in BookingWagonCancellationService).
// - one side paid → BOTH die: the unpaid half expires fee-free (fees only
// apply to paid bookings); the paid half cannot board alone, so the
// 'partnerLapsed' event cancels it with the cancellation fee on ceil of
// its wagons (BookingWagonCancellationService) — paid freight kept as
// rebooking credit for GL staff.
// - both paid → nothing to expire; the paid guard rescues.
if (booking.consolidationPartnerId) {
const partnerId = booking.consolidationPartnerId;
@@ -4029,19 +4031,22 @@ export class BookingBatchService implements OnModuleInit {
if (partnerRow) partnerRow.consolidationPartnerId = null;
if (selfPaid && !partnerPaid) {
// Wrong side called first: the lapsed partner is the one that expires
// (with its fee); this paid booking falls through to the rescue below.
// Wrong side called first: the unpaid partner expires fee-free; this
// PAID booking cannot board without it, so the listener cancels it
// with the cancellation fee — never rescued.
if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) {
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: partnerRow.id,
});
await this.expire(partnerRow, reason);
}
} else if (!selfPaid && partnerPaid) {
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: booking.id,
paidBookingId: booking.id,
});
return;
} else if (!selfPaid && partnerPaid) {
// This unpaid side expires below, fee-free; the PAID partner cannot
// board alone, so the listener cancels it with the cancellation fee.
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
paidBookingId: partnerId,
});
// fall through: this side expires below; the paid partner is untouched.
} else if (!selfPaid && !partnerPaid) {
if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) {
await this.expire(partnerRow, reason);
@@ -4335,8 +4340,8 @@ export class BookingBatchService implements OnModuleInit {
swept.add(booking.id);
// Consolidated pair: the partner may sit outside this route-day's result
// set (different yards/day/status), so cascade explicitly — an unpaid
// partner expires with this booking; a PAID partner keeps the whole
// wagon and this booking owes the cancellation fee (partnerLapsed).
// partner expires with this booking, fee-free; a PAID partner cannot
// board alone, so partnerLapsed cancels it with the cancellation fee.
if (booking.consolidationPartnerId) {
const partner = await this.dataSource.getRepository(Booking).findOne({
where: { id: booking.consolidationPartnerId },
@@ -4352,7 +4357,7 @@ export class BookingBatchService implements OnModuleInit {
partner.paymentStatus === "PAID" || partner.status === "PAID";
if (partnerPaid) {
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: booking.id,
paidBookingId: partner.id,
});
} else if (!["EXPIRED", "CANCELLED"].includes(partner.status)) {
swept.add(partner.id);