Files
edr-platform/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts
2026-09-04 22:43:20 +00:00

903 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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; getRateTable: jest.Mock };
beforeEach(() => {
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
ratesService = {
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
};
exchangeService = {
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
// Delegates to `getRate` so a test that reassigns
// `exchangeService.getRate.mockResolvedValue(...)` gets a consistent
// rate table without also having to touch this mock.
getRateTable: jest.fn(async (target: string) => {
const rate = await exchangeService.getRate('USD', target);
return { ETB: rate, USD: rate, DJF: 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,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } 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('keeps the exchange rate decimals — ETB amounts round to cents, not whole birr', async () => {
exchangeService.getRate.mockResolvedValue(162.2132);
const booking = {
id: 'b-1-frac',
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 }> }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
// 35 × 120 × 162.2132 = 681,295.44 — the .44 must survive (whole-birr
// rounding here billed with the integer part of the rate, in effect).
expect(result.lineItems[0].amount).toBe(681295.44);
});
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), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
findById: jest.fn().mockResolvedValue({
wagonTypes:
opts.wagonCapacity !== undefined
? [{ capacityTons: opts.wagonCapacity }]
: [],
}),
} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } 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('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => {
const ethiopianFee = {
...containerFee20,
id: 'rate-et-20',
rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
rateValue: 40,
} as Rate;
// No serviceType relation on the booking (like the GL/portal shipment
// preview) — the flag must be resolved from serviceTypeId.
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
(service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = {
findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }),
};
const result = await service.computePriceForBooking(
containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }),
);
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
expect(line).toBeDefined();
expect(line!.amount).toBe(160);
expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false);
});
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);
});
});
/**
* Bulk freight bills in the commodity's own unit: tonnage for a weighed
* commodity (PER_TON), item count for a counted one (PER_ITEM). Both read the
* booking's cargo amount; PER_WAGON bills the wagons the cargo occupies.
*/
describe('BookingPricingService — bulk base freight units', () => {
const DJ = 'yard-dj-bulk';
const DIRE_B = 'yard-dire-bulk';
const bulkRate = (overrides: Partial<Rate> = {}): Rate =>
({
id: 'rate-bulk',
rateType: 'BULK_IMPORT',
appliesTo: 'BULK',
trigger: 'ALWAYS',
currency: 'USD',
rateValue: 200,
rateUnit: 'PER_ITEM',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: DIRE_B,
...overrides,
}) as Rate;
const makeService = (liveRates: Rate[], wagonCapacity?: number) =>
new BookingPricingService(
{
calculateWagonCount: jest.fn().mockResolvedValue(0),
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
} as never,
{
evaluate: jest.fn().mockResolvedValue({
priorityScore: 0,
appliedModifiers: [],
containerWeightResults: [],
warnings: [],
hardBlocked: [],
requiresDirectorApproval: false,
}),
} as never,
{ findById: jest.fn() } as never,
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
findById: jest.fn().mockResolvedValue({
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
}),
} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
const booking = (overrides: Record<string, unknown> = {}) =>
({
id: 'b-bulk',
freightType: 'BULK',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
cargoTypeId: 'cargo-machinery',
cargoTotalWeightVgm: 12,
originYardId: DJ,
destinationYardId: DIRE_B,
bookingContainers: [],
...overrides,
}) as unknown as Booking;
it('bills a PER_ITEM rate on the item count', async () => {
const result = await makeService([bulkRate()]).computePriceForBooking(booking());
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_ITEM');
expect(line!.quantity).toBe(12);
expect(line!.amount).toBe(2400);
});
it('bills a PER_TON rate on the tonnage', async () => {
const result = await makeService([
bulkRate({ rateUnit: 'PER_TON', rateValue: 35 }),
]).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_TON');
expect(line!.amount).toBe(35 * 120);
});
it('bills a PER_WAGON rate on the wagons the cargo occupies, not zero', async () => {
const result = await makeService(
[bulkRate({ rateUnit: 'PER_WAGON', rateValue: 500 })],
60,
).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_WAGON');
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
expect(line!.amount).toBe(1000);
});
it('prices off the rate scoped to the booking commodity, not another one', async () => {
const result = await makeService([
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat', rateUnit: 'PER_TON', rateValue: 35 }),
bulkRate({ id: 'rate-machinery', cargoTypeId: 'cargo-machinery', rateValue: 200 }),
]).computePriceForBooking(booking());
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_ITEM');
expect(line!.amount).toBe(2400);
});
it('hard-blocks when the leg only carries another commoditys rate', async () => {
const result = await makeService([
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat' }),
]).computePriceForBooking(booking());
expect(result.lineItems.some((l) => l.code === 'BULK_IMPORT')).toBe(false);
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
});
});
/**
* A PER_WAGON container rate bills the wagons the LINE occupies — two 20ft share
* one wagon, a 40ft takes a whole one. Regression cases taken from real
* bookings on Doraleh → Gelan, where the 20ft line was being charged for the
* 40ft line's wagons as well.
*/
describe('BookingPricingService — PER_WAGON container freight', () => {
const DJ = 'yard-dj-w';
const ET = 'yard-et-w';
const perWagon20: Rate = {
id: 'rate-20-wagon',
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 1690,
rateUnit: 'PER_WAGON',
status: 'LIVE',
containerTypeId: 'ct-20',
originYardId: DJ,
destinationYardId: ET,
} as Rate;
const perContainer40: Rate = {
...perWagon20,
id: 'rate-40-container',
rateValue: 1676,
rateUnit: 'PER_CONTAINER',
containerTypeId: 'ct-40',
} as Rate;
const makeService = () =>
new BookingPricingService(
{
// Booking-wide aggregate — deliberately larger than any single line, so
// a regression that reads it instead of the line's own wagons shows up.
calculateWagonCount: jest.fn().mockResolvedValue(5),
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
} as never,
{
evaluate: jest.fn().mockResolvedValue({
priorityScore: 0,
appliedModifiers: [],
containerWeightResults: [],
warnings: [],
hardBlocked: [],
requiresDirectorApproval: false,
}),
} as never,
{
findById: jest.fn(async (id: string) => ({
id,
sizeFt: id === 'ct-40' ? 40 : 20,
isReefer: false,
code: id === 'ct-40' ? 'C40' : 'C20',
label: id === 'ct-40' ? 'C40' : 'C20',
})),
} as never,
{ findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{ findById: jest.fn() } as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
const booking = (
lines: Array<{ containerTypeId: string; quantity: number }>,
) =>
({
id: 'b-wagon',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
originYardId: DJ,
destinationYardId: ET,
bookingContainers: lines.map((l) => ({
containerTypeId: l.containerTypeId,
quantity: l.quantity,
vgmPerUnitTons: 10,
})),
}) as unknown as Booking;
const price = async (
lines: Array<{ containerTypeId: string; quantity: number }>,
) => {
const service = makeService();
const result = await service.computePriceForBooking(booking(lines));
return result.lineItems.filter((l) => l.code === 'CONTAINER_IMPORT');
};
it('bills 2× 20ft as one wagon', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 2 }]);
expect(line.unit).toBe('PER_WAGON');
expect(line.quantity).toBe(1);
expect(line.amount).toBe(1690);
});
it('bills 10× 20ft as five wagons', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 10 }]);
expect(line.quantity).toBe(5);
expect(line.amount).toBe(5 * 1690);
});
it('does not charge the 20ft line for the 40ft lines wagons', async () => {
const lines = await price([
{ containerTypeId: 'ct-20', quantity: 4 },
{ containerTypeId: 'ct-40', quantity: 1 },
]);
const twenty = lines.find((l) => l.description.startsWith('C20'))!;
const forty = lines.find((l) => l.description.startsWith('C40'))!;
// 4× 20ft = 2 wagons, NOT the booking-wide 3.
expect(twenty.quantity).toBe(2);
expect(twenty.amount).toBe(2 * 1690);
// The 40ft line keeps billing per container.
expect(forty.quantity).toBe(1);
expect(forty.amount).toBe(1676);
});
it('rounds an odd 20ft count up to a whole wagon', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 5 }]);
expect(line.quantity).toBe(3);
expect(line.amount).toBe(3 * 1690);
});
});
/**
* Empty container import is bare equipment moved as freight in its own right.
* It has to price off EMPTY_CONTAINER_IMPORT, never the laden CONTAINER_IMPORT
* rate for the same lane and box — the two are separate tariffs, and
* UQ_rates_pattern only lets both exist because the rateType differs.
*/
describe('BookingPricingService — empty container import', () => {
const DJIBOUTI = 'yard-djibouti';
const CT40 = 'ct-40ft';
const ladenImport40: Rate = {
id: 'rate-container-import-40',
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 900,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: CT40,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
} as Rate;
const emptyImport40: Rate = {
id: 'rate-empty-container-import-40',
rateType: 'EMPTY_CONTAINER_IMPORT',
currency: 'USD',
rateValue: 250,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: CT40,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
} as Rate;
let service: BookingPricingService;
const priceLines = (booking: Booking) =>
(
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: Array<{ containerTypeId: string; quantity: number; wagonsPerUnit: number }> },
) => Promise<{
lineItems: Array<{ code: string; amount: number; description: string }>;
blocked: string[];
}>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [{ containerTypeId: CT40, quantity: 4, wagonsPerUnit: 1 }],
});
const bookingWith = (cargoCondition: string) =>
({
id: 'b-empty-1',
freightType: 'CONTAINER',
cargoCondition,
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
// Bare equipment declares no VGM — the service zeroes it at create.
cargoTotalWeightVgm: 0,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
bookingContainers: [],
}) as unknown as Booking;
beforeEach(() => {
const exchangeService = {
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: 1, DJF: 1 }),
};
service = new BookingPricingService(
{ calculateWagonCount: jest.fn().mockResolvedValue(4) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ sizeFt: 40, label: '40ft' }) } as never,
{ findLiveRates: jest.fn().mockResolvedValue([ladenImport40, emptyImport40]) } as never,
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
});
it('prices an empty booking off the empty tariff, not the laden one', async () => {
const result = await priceLines(bookingWith('EMPTY'));
expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].code).toBe('EMPTY_CONTAINER_IMPORT');
expect(result.lineItems[0].amount).toBe(250 * 4);
expect(result.lineItems[0].description).toContain('empty');
});
it('leaves laden bookings on the laden tariff', async () => {
const result = await priceLines(bookingWith('LADEN'));
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
expect(result.lineItems[0].amount).toBe(900 * 4);
});
it('treats a booking with no condition set as laden', async () => {
const booking = bookingWith('LADEN');
delete (booking as unknown as Record<string, unknown>).cargoCondition;
const result = await priceLines(booking);
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
});
it('hard-blocks an empty booking on a lane with no empty rate configured', async () => {
(
service as unknown as { ratesService: { findLiveRates: jest.Mock } }
).ratesService.findLiveRates.mockResolvedValue([ladenImport40]);
const result = await priceLines(bookingWith('EMPTY'));
// Never silently fall through to the laden rate — that would bill an empty
// repositioning move at 900/box instead of 250.
expect(result.lineItems).toHaveLength(0);
expect(result.blocked[0]).toContain('EMPTY_CONTAINER_IMPORT');
});
});