mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1406 from Tria-plc/freight_feature/usermanagement
feat: enhance booking cancellation logic for consolidated pairs with …
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user