mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
import { BookingPricingService } from './booking-pricing.service';
|
||
import type { Booking } from './entities/booking.entity';
|
||
import type { Rate } from '../rule-engine/entities/rate.entity';
|
||
|
||
const MOCK_CBE_RATE = 130;
|
||
|
||
// Base freight is configured per leg, so every rate and every booking names the
|
||
// route it runs. MOJO → DIRE is the corridor these rates are priced for.
|
||
const MOJO = 'yard-mojo';
|
||
const DIRE = 'yard-dire-dawa';
|
||
const LEBU = 'yard-lebu';
|
||
|
||
describe('BookingPricingService — domestic corridor', () => {
|
||
const intercityBulkUsd: Rate = {
|
||
id: 'rate-intercity-bulk-usd',
|
||
rateType: 'INTERCITY_BULK',
|
||
currency: 'USD',
|
||
rateValue: 35,
|
||
rateUnit: 'PER_TON',
|
||
status: 'LIVE',
|
||
containerTypeId: null,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
} as Rate;
|
||
|
||
const intercityContainerUsd: Rate = {
|
||
id: 'rate-intercity-container-usd',
|
||
rateType: 'INTERCITY_CONTAINER',
|
||
currency: 'USD',
|
||
rateValue: 400,
|
||
rateUnit: 'PER_CONTAINER',
|
||
status: 'LIVE',
|
||
containerTypeId: null,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
} as Rate;
|
||
|
||
let service: BookingPricingService;
|
||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||
let ratesService: { findLiveRates: jest.Mock };
|
||
let exchangeService: { getRate: jest.Mock };
|
||
|
||
beforeEach(() => {
|
||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||
ratesService = {
|
||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
|
||
};
|
||
exchangeService = {
|
||
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||
};
|
||
|
||
service = new BookingPricingService(
|
||
bookingsRepository as never,
|
||
{} as never,
|
||
{} as never,
|
||
ratesService as never,
|
||
exchangeService as never,
|
||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||
{} as never,
|
||
);
|
||
});
|
||
|
||
it('prices domestic bulk in ETB using INTERCITY_BULK USD rate × CBE exchange rate', async () => {
|
||
const booking = {
|
||
id: 'b-1',
|
||
freightType: 'BULK',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'ETB',
|
||
cargoTotalWeightVgm: 120,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: { containers: [] },
|
||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||
|
||
expect(result.lineItems).toHaveLength(1);
|
||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||
expect(result.lineItems[0].currency).toBe('ETB');
|
||
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
|
||
});
|
||
|
||
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
|
||
const booking = {
|
||
id: 'b-1-usd',
|
||
freightType: 'BULK',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'USD',
|
||
cargoTotalWeightVgm: 120,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: { containers: [] },
|
||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||
|
||
expect(result.lineItems).toHaveLength(1);
|
||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||
expect(result.lineItems[0].currency).toBe('USD');
|
||
expect(result.lineItems[0].amount).toBe(35 * 120);
|
||
});
|
||
|
||
it('prices domestic container in ETB using INTERCITY_CONTAINER USD fallback × CBE rate', async () => {
|
||
const booking = {
|
||
id: 'b-2',
|
||
freightType: 'CONTAINER',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'ETB',
|
||
cargoTotalWeightVgm: 50,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: {
|
||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||
},
|
||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, {
|
||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||
});
|
||
|
||
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
|
||
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
|
||
expect(line.currency).toBe('ETB');
|
||
});
|
||
|
||
// Rates are quoted per leg, so one configured for MOJO → DIRE must not price a
|
||
// shipment that runs LEBU → DIRE. Charging the wrong corridor's price because
|
||
// nobody configured this one yet is worse than billing no base freight.
|
||
it('does not price bulk off a rate configured for a different leg', async () => {
|
||
const booking = {
|
||
id: 'b-3',
|
||
freightType: 'BULK',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'USD',
|
||
cargoTotalWeightVgm: 120,
|
||
originYardId: LEBU,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: { containers: [] },
|
||
) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||
|
||
expect(result.lineItems).toHaveLength(0);
|
||
expect(result.blocked).toHaveLength(1);
|
||
});
|
||
|
||
it('does not price containers off a rate configured for a different leg', async () => {
|
||
const booking = {
|
||
id: 'b-4',
|
||
freightType: 'CONTAINER',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'USD',
|
||
cargoTotalWeightVgm: 50,
|
||
originYardId: LEBU,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: {
|
||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||
},
|
||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, {
|
||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||
});
|
||
|
||
expect(result.lineItems).toHaveLength(0);
|
||
});
|
||
|
||
// A mixed booking where only one container size has a configured rate must
|
||
// hard-block, not silently carry the unconfigured size for free.
|
||
it('blocks the unconfigured container size and prices the configured one', async () => {
|
||
const fortyOnly: Rate = {
|
||
...intercityContainerUsd,
|
||
id: 'rate-ct-40-only',
|
||
containerTypeId: 'ct-40',
|
||
} as Rate;
|
||
ratesService.findLiveRates.mockResolvedValue([fortyOnly]);
|
||
|
||
const booking = {
|
||
id: 'b-5',
|
||
freightType: 'CONTAINER',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'USD',
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: {
|
||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||
},
|
||
) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, {
|
||
containers: [
|
||
{ containerTypeId: 'ct-40', quantity: 2 },
|
||
{ containerTypeId: 'ct-20', quantity: 3 },
|
||
],
|
||
});
|
||
|
||
expect(result.lineItems).toHaveLength(1);
|
||
expect(result.blocked).toHaveLength(1);
|
||
expect(result.blocked[0]).toContain('rate is configured');
|
||
});
|
||
});
|
||
|
||
describe('BookingPricingService — customs clearance fee billed on the booking price', () => {
|
||
const DJ = 'yard-dj';
|
||
|
||
const containerFee20: Rate = {
|
||
id: 'rate-cc-20',
|
||
rateType: 'CUSTOMS_CLEARANCE',
|
||
trigger: 'CUSTOMS_CLEARANCE',
|
||
currency: 'USD',
|
||
rateValue: 100,
|
||
rateUnit: 'PER_CONTAINER',
|
||
status: 'LIVE',
|
||
containerTypeId: 'ct-20',
|
||
tradeDirection: 'IMPORT',
|
||
originYardId: DJ,
|
||
destinationYardId: DIRE,
|
||
} as Rate;
|
||
|
||
const bulkFeePerTon: Rate = {
|
||
...containerFee20,
|
||
id: 'rate-cc-bulk',
|
||
rateValue: 5,
|
||
rateUnit: 'PER_TON',
|
||
containerTypeId: null,
|
||
} as Rate;
|
||
|
||
const emptyEval = {
|
||
priorityScore: 0,
|
||
appliedModifiers: [],
|
||
containerWeightResults: [],
|
||
warnings: [],
|
||
hardBlocked: [],
|
||
requiresDirectorApproval: false,
|
||
};
|
||
|
||
const makeService = (opts: {
|
||
snapshots?: unknown[];
|
||
liveRates?: Rate[];
|
||
wagonCapacity?: number;
|
||
}) =>
|
||
new BookingPricingService(
|
||
{
|
||
calculateWagonCount: jest.fn().mockResolvedValue(0),
|
||
findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []),
|
||
} as never,
|
||
{ evaluate: jest.fn().mockResolvedValue(emptyEval) } as never,
|
||
{
|
||
findById: jest.fn(async (id: string) => ({
|
||
id,
|
||
sizeFt: id === 'ct-40' ? 40 : 20,
|
||
isReefer: false,
|
||
code: id === 'ct-40' ? 'C40' : 'C20',
|
||
})),
|
||
} as never,
|
||
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
|
||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||
{
|
||
findById: jest.fn().mockResolvedValue({
|
||
wagonTypes:
|
||
opts.wagonCapacity !== undefined
|
||
? [{ capacityTons: opts.wagonCapacity }]
|
||
: [],
|
||
}),
|
||
} as never,
|
||
);
|
||
|
||
const containerBooking = (overrides: Record<string, unknown> = {}) =>
|
||
({
|
||
id: 'b-cc',
|
||
freightType: 'CONTAINER',
|
||
tradeDirection: 'IMPORT',
|
||
paymentCurrency: 'USD',
|
||
customsClearingEnabled: true,
|
||
originYardId: DJ,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [
|
||
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 },
|
||
],
|
||
...overrides,
|
||
}) as unknown as Booking;
|
||
|
||
const bulkBooking = (overrides: Record<string, unknown> = {}) =>
|
||
({
|
||
id: 'b-cc-bulk',
|
||
freightType: 'BULK',
|
||
tradeDirection: 'IMPORT',
|
||
paymentCurrency: 'USD',
|
||
customsClearingEnabled: true,
|
||
cargoTypeId: 'cargo-1',
|
||
cargoTotalWeightVgm: 120,
|
||
originYardId: DJ,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
...overrides,
|
||
}) as unknown as Booking;
|
||
|
||
it('bills a container booking per box at its own container type fee', async () => {
|
||
const service = makeService({ liveRates: [containerFee20] });
|
||
const result = await service.computePriceForBooking(containerBooking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||
expect(line).toBeDefined();
|
||
expect(line!.unit).toBe('PER_CONTAINER');
|
||
expect(line!.quantity).toBe(4);
|
||
expect(line!.amount).toBe(400);
|
||
});
|
||
|
||
it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => {
|
||
const service = makeService({
|
||
liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate],
|
||
});
|
||
const result = await service.computePriceForBooking(containerBooking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||
expect(line!.unit).toBe('PER_WAGON');
|
||
expect(line!.quantity).toBe(2);
|
||
expect(line!.amount).toBe(200);
|
||
});
|
||
|
||
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
|
||
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||
const result = await service.computePriceForBooking(containerBooking());
|
||
|
||
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true);
|
||
});
|
||
|
||
it('bills a bulk booking per ton at the route bulk fee', async () => {
|
||
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||
const result = await service.computePriceForBooking(bulkBooking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||
expect(line!.unit).toBe('PER_TON');
|
||
expect(line!.quantity).toBe(120);
|
||
expect(line!.amount).toBe(600);
|
||
});
|
||
|
||
it('the fee scoped to the booking commodity wins over the catch-all', async () => {
|
||
const service = makeService({
|
||
liveRates: [
|
||
{ ...bulkFeePerTon, id: 'rate-cc-catchall', rateValue: 5 } as Rate,
|
||
{
|
||
...bulkFeePerTon,
|
||
id: 'rate-cc-sugar',
|
||
rateValue: 9,
|
||
cargoTypeId: 'cargo-1',
|
||
} as Rate,
|
||
],
|
||
});
|
||
const result = await service.computePriceForBooking(bulkBooking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||
expect(line!.unitAmount).toBe(9); // commodity rate, not the 5 USD catch-all
|
||
expect(line!.amount).toBe(1080);
|
||
});
|
||
|
||
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
|
||
const service = makeService({
|
||
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],
|
||
wagonCapacity: 60,
|
||
});
|
||
const result = await service.computePriceForBooking(bulkBooking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||
expect(line!.unit).toBe('PER_WAGON');
|
||
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
|
||
expect(line!.amount).toBe(100);
|
||
});
|
||
|
||
it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => {
|
||
const service = makeService({
|
||
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate],
|
||
});
|
||
const result = await service.computePriceForBooking(bulkBooking());
|
||
|
||
expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true);
|
||
});
|
||
|
||
it('prefers the contract frozen per-size snapshot over the live rate', async () => {
|
||
const service = makeService({
|
||
liveRates: [containerFee20],
|
||
snapshots: [
|
||
{
|
||
rateCode: 'CUSTOMS_CLEARANCE_20FT',
|
||
unitPrice: 80,
|
||
currency: 'USD',
|
||
unitOfMeasure: 'per_container',
|
||
isClearance: true,
|
||
},
|
||
],
|
||
});
|
||
const result = await service.computePriceForBooking(
|
||
containerBooking({ contractId: 'c-1' }),
|
||
);
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||
expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100
|
||
});
|
||
|
||
it('honours a legacy FLAT snapshot once for the whole container booking', async () => {
|
||
const service = makeService({
|
||
liveRates: [],
|
||
snapshots: [
|
||
{
|
||
rateCode: 'CUSTOMS_CLEARANCE',
|
||
unitPrice: 500,
|
||
currency: 'USD',
|
||
unitOfMeasure: 'flat',
|
||
isClearance: true,
|
||
},
|
||
],
|
||
});
|
||
const result = await service.computePriceForBooking(
|
||
containerBooking({ contractId: 'c-legacy' }),
|
||
);
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||
expect(line!.unit).toBe('FLAT');
|
||
expect(line!.amount).toBe(500);
|
||
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false);
|
||
});
|
||
|
||
it('adds no fee line when customs clearing is disabled', async () => {
|
||
const service = makeService({ liveRates: [containerFee20] });
|
||
const result = await service.computePriceForBooking(
|
||
containerBooking({ customsClearingEnabled: false }),
|
||
);
|
||
|
||
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||
});
|
||
});
|