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 = {}) => ({ 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 = {}) => ({ 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); }); });