mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
Merge pull request #1472 from Tria-plc/freight_feature/usermanagement
feat: Implement handling for partially loaded bookings in train sched…
This commit is contained in:
@@ -232,3 +232,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', ()
|
||||
expect(dto.requestedWagons).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The cancellation fee is paid BEFORE the credit is redeemed.
|
||||
*
|
||||
* An at-loading cut applies immediately and opens the credit while its fee
|
||||
* invoice stays open, so CREDIT_AVAILABLE on its own never means the fee was
|
||||
* settled. Without the gate the customer rebooks the same wagons and the
|
||||
* cancellation fee is simply never collected. EDR-fault cuts carry no fee and
|
||||
* must stay freely rebookable — partial or whole, container or bulk.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.rebook (cancellation fee gate)', () => {
|
||||
const source = {
|
||||
id: 'b1',
|
||||
contractId: 'c1',
|
||||
paymentCurrency: 'ETB',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
};
|
||||
|
||||
const makeSvc = (row: Record<string, unknown>) => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & { rebook(id: string, dto: unknown): Promise<unknown> };
|
||||
svc.repo = { findById: async () => row };
|
||||
svc.bookingsRepository = {
|
||||
findById: async () => source,
|
||||
findByIdWithFiles: async () => null,
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
/** Bulk credit — no bySize, so nothing depends on container snapshots. */
|
||||
const bulkRow = (over: Record<string, unknown>) => ({
|
||||
id: 'wc1',
|
||||
bookingId: 'b1',
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
creditAmount: 5000,
|
||||
wagonsCancelled: 2,
|
||||
cancelledQuantities: { bulkTons: 100 },
|
||||
feeCurrency: 'ETB',
|
||||
...over,
|
||||
});
|
||||
|
||||
it('blocks a rebook while a customer-fault fee is unpaid', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: null }),
|
||||
);
|
||||
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
|
||||
/pay the ETB 1500\.00 cancellation fee for 2 wagon\(s\)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks a WHOLE-booking customer-fault cancel just the same', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 4000, feePaidAt: null, wagonsCancelled: 4 }),
|
||||
);
|
||||
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
|
||||
/4 wagon\(s\) before rebooking/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the rebook through once the fee is paid', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: new Date() }),
|
||||
);
|
||||
// Past the gate it fails later (no contract/create wiring in this harness) —
|
||||
// what matters is that it is no longer the fee that stops it.
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
|
||||
it('never charges an EDR-fault cut', async () => {
|
||||
const svc = makeSvc(bulkRow({ fault: 'EDR', feeAmount: 0, feePaidAt: null }));
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
|
||||
it('leaves legacy rows without a fee untouched', async () => {
|
||||
const svc = makeSvc(bulkRow({ fault: null, feeAmount: 0, feePaidAt: null }));
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1003,6 +1003,18 @@ export class BookingWagonCancellationService {
|
||||
'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.',
|
||||
);
|
||||
}
|
||||
// Customer-fault fee settles BEFORE the credit is redeemed. An at-loading
|
||||
// cut applies immediately and opens the credit while its invoice stays
|
||||
// open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without
|
||||
// this the customer rebooks the wagons and never pays the cancellation
|
||||
// fee the notice already promised. EDR fault carries no fee and is
|
||||
// unaffected; onFeePaid stamps feePaidAt and the gate opens by itself.
|
||||
if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) {
|
||||
throw new BadRequestException(
|
||||
`Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` +
|
||||
`${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`,
|
||||
);
|
||||
}
|
||||
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
|
||||
if (!source.contractId) {
|
||||
|
||||
Reference in New Issue
Block a user