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, { 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) } 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 = {}) => ({ 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('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 => ({ 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) } 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 = {}) => ({ 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 commodity’s 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) } 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 line’s 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); }); });