import { WarehouseFeeService } from './warehouse-fee.service'; import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; // Bulk storage/demurrage used to bill a flat rate per day regardless of cargo // quantity. It now scales by the cargo type's own unit of measure — tons for // PER_TON cargo, item count for PER_ITEM cargo (Machinery, Truck, Automobile, // Livestock…) — read from THIS inventory row, not the whole booking's total. describe('WarehouseFeeService bulk quantity billing', () => { const makeService = () => // compute() only touches its own arguments plus this.convertAmount, which // short-circuits when rule.currency === billingCurrency — none of the // constructor deps are exercised. new WarehouseFeeService({} as any, {} as any, {} as any, {} as any); const rule = (overrides: Partial = {}): WarehouseFeeRule => ({ id: 'rule-1', name: 'Bulk storage', ruleType: 'STORAGE_FEE', freeDays: 0, ratePerDay: 10, currency: 'USD', tiers: [], ...overrides, }) as WarehouseFeeRule; const baseItem = (overrides: Record = {}) => ({ arrivedAt: new Date('2026-01-01T00:00:00Z'), gateClearedAt: null, releaseDate: null, freightType: 'BULK', tradeDirection: 'IMPORT', cargoTypeCode: 'WHEAT', containerTypeCode: null, vehicleType: null, inventoryQuantity: 3, inventoryWeight: 25, bookingContainerCount: 0, cargoUnitOfMeasure: null, // Double handling now bills only when staff answered Yes after unloading; // these quantity-basis cases assume that answer (the gate itself is covered // in double-handling-gate.spec.ts). doubleHandling: true, facilityId: null, warehouseId: null, yardId: null, zoneId: null, ...overrides, }); // 5 elapsed days, 0 free days -> 5 chargeable days throughout. const now = new Date('2026-01-06T00:00:00Z'); it('bills PER_TON bulk cargo by this row\'s weight, not a flat day rate', async () => { const service = makeService(); const preview = await (service as any).compute( 'STORAGE_FEE', rule(), baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 25 }), now, 'USD', ); expect(preview.unitLabel).toBe('ton'); expect(preview.containerCount).toBe(25); expect(preview.billableUnits).toBe(5 * 25); expect(preview.amount).toBe(5 * 25 * 10); }); it('bills PER_ITEM bulk cargo (Machinery/Truck/Automobile/Livestock) by unit count', async () => { const service = makeService(); const preview = await (service as any).compute( 'STORAGE_FEE', rule(), baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 3, cargoTypeCode: 'MACHINERY' }), now, 'USD', ); expect(preview.unitLabel).toBe('item'); expect(preview.containerCount).toBe(3); expect(preview.billableUnits).toBe(5 * 3); expect(preview.amount).toBe(5 * 3 * 10); }); it('defaults to PER_TON when the cargo type has no unit of measure set', async () => { const service = makeService(); const preview = await (service as any).compute( 'STORAGE_FEE', rule(), baseItem({ cargoUnitOfMeasure: null, inventoryWeight: 12 }), now, 'USD', ); expect(preview.unitLabel).toBe('ton'); expect(preview.containerCount).toBe(12); }); it('charges nothing yet when the row has not been weighed/counted (0 is legitimate, not floored to 1)', async () => { const service = makeService(); const preview = await (service as any).compute( 'STORAGE_FEE', rule(), baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 0 }), now, 'USD', ); expect(preview.containerCount).toBe(0); expect(preview.billableUnits).toBe(0); expect(preview.amount).toBe(0); }); it('leaves CONTAINER freight billing untouched by the new bulk fields', async () => { const service = makeService(); const preview = await (service as any).compute( 'DEMURRAGE_FEE', rule({ ruleType: 'DEMURRAGE_FEE' }), baseItem({ freightType: 'CONTAINER', bookingContainerCount: 4, cargoUnitOfMeasure: 'PER_ITEM', // must be ignored for container freight inventoryWeight: 999, }), now, 'USD', ); expect(preview.unitLabel).toBe('container'); expect(preview.containerCount).toBe(4); expect(preview.billableUnits).toBe(5 * 4); }); // Double handling is a flat one-time charge, but previewForInventory() calls // it once per warehouse_inventory ROW. Before this fix it read the whole // booking's total on every row, so a booking split across N rows was billed // N times against its full quantity. Reading each row's own weight/count // fixes that: summing the rows now reproduces the booking total exactly once. describe('double handling (row-level, not booking-wide)', () => { const doubleHandlingRule = (basis: 'PER_CONTAINER' | 'PER_TON' | 'PER_ITEM') => rule({ ruleType: 'DOUBLE_HANDLING_FEE', basis, ratePerDay: 20 }); it('bills PER_TON by this row\'s own weight', async () => { const service = makeService(); const preview = await (service as any).compute( 'DOUBLE_HANDLING_FEE', doubleHandlingRule('PER_TON'), baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }), now, 'USD', ); expect(preview.unitLabel).toBe('ton'); expect(preview.billableUnits).toBe(10); expect(preview.amount).toBe(10 * 20); }); it('bills PER_ITEM by this row\'s own unit count', async () => { const service = makeService(); const preview = await (service as any).compute( 'DOUBLE_HANDLING_FEE', doubleHandlingRule('PER_ITEM'), baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 2, cargoTypeCode: 'TRUCK' }), now, 'USD', ); expect(preview.unitLabel).toBe('item'); expect(preview.billableUnits).toBe(2); expect(preview.amount).toBe(2 * 20); }); it('two rows of one booking sum to the booking total exactly once (no N-times overcount)', async () => { const service = makeService(); const ruleDef = doubleHandlingRule('PER_TON'); const rowA = await (service as any).compute( 'DOUBLE_HANDLING_FEE', ruleDef, baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 6 }), now, 'USD', ); const rowB = await (service as any).compute( 'DOUBLE_HANDLING_FEE', ruleDef, baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 4 }), now, 'USD', ); // Booking total is 10 tons across the two rows — billed once in total, // not 10 tons charged against EACH row (which the old booking-wide read did). expect(rowA.amount + rowB.amount).toBe(10 * 20); }); it('no charge for export/domestic regardless of basis', async () => { const service = makeService(); const preview = await (service as any).compute( 'DOUBLE_HANDLING_FEE', doubleHandlingRule('PER_TON'), baseItem({ tradeDirection: 'EXPORT', cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }), now, 'USD', ); expect(preview.amount).toBe(0); }); }); });