import { RuleEngineService } from './rule-engine.service'; import type { BookingEvaluationInput } from './rule-engine.service'; import type { Rate } from './entities/rate.entity'; describe('RuleEngineService — requested service without a configured surcharge rate', () => { const hazardRate: Rate = { id: 'rate-hazard', rateType: 'HAZARD_SURCHARGE', trigger: 'HAZARDOUS', rateValue: 50, rateUnit: 'PER_CONTAINER', currency: 'USD', status: 'LIVE', containerTypeId: null, cargoTypeId: null, } as Rate; let ratesRepo: { findLiveRates: jest.Mock }; let service: RuleEngineService; beforeEach(() => { ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) }; service = new RuleEngineService( { findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes { findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits { findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs ratesRepo as never, { findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines {} as never, // dataSource (unused by evaluate) ); }); const input = (overrides: Partial): BookingEvaluationInput => ({ serviceTypeId: 'svc-1', paymentCurrency: 'USD', tradeDirection: 'IMPORT', isHazardous: false, totalWagons: 1, containers: [], ...overrides, }); it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => { const result = await service.evaluate(input({ isHazardous: true })); expect(result.hardBlocked).toHaveLength(1); expect(result.hardBlocked[0]).toContain('hazardous'); }); it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => { ratesRepo.findLiveRates.mockResolvedValue([hazardRate]); const result = await service.evaluate(input({ isHazardous: true })); expect(result.hardBlocked).toHaveLength(0); }); it('does not block a non-hazardous booking when no surcharge rates exist', async () => { const result = await service.evaluate(input({})); expect(result.hardBlocked).toHaveLength(0); }); it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => { const result = await service.evaluate( input({ containers: [ { containerTypeId: 'ct-20', quantity: 2, vgmPerUnitTons: 10, totalVgmTons: 20, reeferQuantity: 1, }, ], }), ); expect(result.hardBlocked).toHaveLength(1); expect(result.hardBlocked[0]).toContain('reefer'); }); }); describe('RuleEngineService — overweight surcharge by trade direction', () => { const baseImportRate: Rate = { id: 'rate-import-20', rateType: 'CONTAINER_IMPORT', trigger: 'ALWAYS', rateValue: 1000, rateUnit: 'PER_CONTAINER', currency: 'USD', status: 'LIVE', containerTypeId: 'ct-20', cargoTypeId: null, originYardId: 'yard-dj', destinationYardId: 'yard-adama', } as Rate; const configuredOverweight: Rate = { id: 'rate-ow', rateType: 'OVERWEIGHT_PER_TON', trigger: 'OVERWEIGHT', rateValue: 10, rateUnit: 'PER_TON', currency: 'USD', status: 'LIVE', containerTypeId: null, cargoTypeId: null, } as Rate; let service: RuleEngineService; beforeEach(() => { service = new RuleEngineService( { findById: jest.fn().mockResolvedValue(null) } as never, { findById: jest.fn().mockResolvedValue(null) } as never, { findActiveByContainerTypeId: jest .fn() .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), } as never, { findAllActive: jest.fn().mockResolvedValue([]) } as never, { findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]), } as never, { findById: jest.fn().mockResolvedValue(null) } as never, {} as never, ); }); // One 20ft at 25 t against a 20 t limit → 5 t excess. const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({ serviceTypeId: 'svc-1', paymentCurrency: 'USD', tradeDirection, isHazardous: false, totalWagons: 1, originYardId: 'yard-dj', destinationYardId: 'yard-adama', containers: [ { containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 }, ], }); it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => { const result = await service.evaluate(overweightInput('IMPORT')); const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); expect(ow).toHaveLength(1); // 1000 / (2 × 20) = 25 USD/t on 5 excess tons. expect(ow[0].unitPriceUsd).toBe(25); expect(ow[0].calculatedAmount).toBe(125); expect(ow[0].triggerValue).toBe(5); expect(ow[0].rateId).toBe(baseImportRate.id); }); it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => { const result = await service.evaluate(overweightInput('EXPORT')); const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); expect(ow).toHaveLength(1); expect(ow[0].rateId).toBe(configuredOverweight.id); // 5 excess tons × the configured 10 USD/t. expect(ow[0].calculatedAmount).toBe(50); expect(ow[0].unitPriceUsd).toBeUndefined(); }); it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => { const result = await service.evaluate({ ...overweightInput('IMPORT'), destinationYardId: 'yard-elsewhere', }); const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); expect(ow).toHaveLength(0); }); }); describe('RuleEngineService — empty-container return per route + container type', () => { const returnRate20: Rate = { id: 'rate-return-20', rateType: 'RETURN_SURCHARGE', trigger: 'WITH_RETURN', rateValue: 20, rateUnit: 'PER_CONTAINER', currency: 'USD', status: 'LIVE', containerTypeId: 'ct-20', cargoTypeId: null, tradeDirection: 'IMPORT', originYardId: 'yard-dj', destinationYardId: 'yard-adama', } as Rate; let service: RuleEngineService; beforeEach(() => { service = new RuleEngineService( { findById: jest.fn().mockResolvedValue(null) } as never, { findById: jest.fn().mockResolvedValue(null) } as never, { findActiveByContainerTypeId: jest .fn() .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), } as never, { findAllActive: jest.fn().mockResolvedValue([]) } as never, { findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never, { findById: jest.fn().mockResolvedValue(null) } as never, {} as never, ); }); const returnInput = (overrides: Partial): BookingEvaluationInput => ({ serviceTypeId: 'svc-1', paymentCurrency: 'USD', tradeDirection: 'IMPORT', isHazardous: false, totalWagons: 1, originYardId: 'yard-dj', destinationYardId: 'yard-adama', containers: [ { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40, returnQuantity: 2, }, ], ...overrides, }); it('bills the route + type matched rate on the opted-in count', async () => { const result = await service.evaluate(returnInput({})); const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); expect(result.hardBlocked).toHaveLength(0); expect(ret).toHaveLength(1); expect(ret[0].rateId).toBe(returnRate20.id); expect(ret[0].triggerValue).toBe(2); expect(ret[0].calculatedAmount).toBe(40); expect(ret[0].billingUnit).toBe('PER_CONTAINER'); }); it('hard-blocks when the booking route has no matching return rate', async () => { const result = await service.evaluate( returnInput({ destinationYardId: 'yard-elsewhere' }), ); expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true); expect( result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'), ).toHaveLength(0); }); it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => { const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' })); expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true); }); it('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => { // Same service, but the return rate is sold per wagon: 4× 20ft return = // 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20. service = new RuleEngineService( { findById: jest.fn().mockResolvedValue(null) } as never, { findById: jest.fn().mockResolvedValue(null) } as never, { findActiveByContainerTypeId: jest .fn() .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), } as never, { findAllActive: jest.fn().mockResolvedValue([]) } as never, { findLiveRates: jest .fn() .mockResolvedValue([{ ...returnRate20, rateUnit: 'PER_WAGON' } as Rate]), } as never, { findById: jest.fn().mockResolvedValue(null) } as never, {} as never, ); const result = await service.evaluate( returnInput({ containers: [ { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40, returnQuantity: 4, wagonsPerUnit: 0.5, }, ], }), ); const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); expect(ret).toHaveLength(1); expect(ret[0].triggerValue).toBe(2); expect(ret[0].calculatedAmount).toBe(40); expect(ret[0].billingUnit).toBe('PER_WAGON'); }); it('legacy booking-level flag bills every container at its type rate', async () => { const result = await service.evaluate( returnInput({ withReturn: true, containers: [ { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 }, ], }), ); const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); expect(ret).toHaveLength(1); expect(ret[0].triggerValue).toBe(4); expect(ret[0].calculatedAmount).toBe(80); }); }); describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => { const lashingBulkImport: Rate = { id: 'rate-lash-bulk', rateType: 'LASHING', trigger: 'LASHING', rateValue: 2, rateUnit: 'PER_TON', currency: 'USD', status: 'LIVE', containerTypeId: null, cargoTypeId: null, tradeDirection: 'IMPORT', originYardId: null, destinationYardId: null, } as Rate; const buildService = (rates: Rate[]): RuleEngineService => new RuleEngineService( { findById: jest .fn() .mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }), } as never, { findById: jest.fn().mockResolvedValue(null) } as never, { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, { findAllActive: jest.fn().mockResolvedValue([]) } as never, { findLiveRates: jest.fn().mockResolvedValue(rates) } as never, { findById: jest.fn().mockResolvedValue(null) } as never, {} as never, ); const bulkInput = (overrides: Partial = {}): BookingEvaluationInput => ({ serviceTypeId: 'svc-1', paymentCurrency: 'USD', tradeDirection: 'IMPORT', isHazardous: false, cargoTypeId: 'cargo-sugar', totalWagons: 0, bulkTons: 100, bulkWagons: 3, containers: [], ...overrides, }); const lashingMods = (result: Awaited>) => result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING'); it('bulk lashing bills per ton on the direction-matched rate', async () => { const result = await buildService([lashingBulkImport]).evaluate(bulkInput()); const mods = lashingMods(result); expect(mods).toHaveLength(1); expect(mods[0].triggerValue).toBe(100); expect(mods[0].calculatedAmount).toBe(200); expect(mods[0].billingUnit).toBe('PER_TON'); }); it('a rate for the other direction never bills', async () => { const result = await buildService([ { ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate, ]).evaluate(bulkInput()); expect(lashingMods(result)).toHaveLength(0); }); it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => { const result = await buildService([ { ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate, ]).evaluate(bulkInput()); const mods = lashingMods(result); expect(mods[0].triggerValue).toBe(3); expect(mods[0].calculatedAmount).toBe(75); }); it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => { const result = await buildService([ lashingBulkImport, { ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate, ]).evaluate(bulkInput()); const mods = lashingMods(result); expect(mods).toHaveLength(1); expect(mods[0].unitPriceUsd).toBe(7); expect(mods[0].calculatedAmount).toBe(700); }); it('container bookings never incur lashing (bulk-only service)', async () => { const result = await buildService([lashingBulkImport]).evaluate( bulkInput({ cargoTypeId: null, hasLashing: true, containers: [ { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 }, ], }), ); expect(lashingMods(result)).toHaveLength(0); }); it('no lashing charge when the cargo does not need lashing', async () => { const service = new RuleEngineService( { findById: jest .fn() .mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }), } as never, { findById: jest.fn().mockResolvedValue(null) } as never, { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, { findAllActive: jest.fn().mockResolvedValue([]) } as never, { findLiveRates: jest.fn().mockResolvedValue([lashingBulkImport]) } as never, { findById: jest.fn().mockResolvedValue(null) } as never, {} as never, ); const result = await service.evaluate(bulkInput()); expect(lashingMods(result)).toHaveLength(0); }); });