diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 399f14123..264ffb811 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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"; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index c1f4114a7..e9c85a91c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -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 { 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)}`, ); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 1c6b66ea8..fa4be32bb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -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 }).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 }).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(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 3469413ca..8a348e625 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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 { // 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);