diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts index ffbef03e3..96a8a26ac 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts @@ -71,6 +71,36 @@ describe('contract base freight is priced on the contract lane only', () => { ).rejects.toThrow(UnprocessableEntityException); }); + it('freezes the OVERWEIGHT_PER_TON surcharge on export contracts only', async () => { + const overweight = rate({ + rateType: 'OVERWEIGHT_PER_TON', + trigger: 'OVERWEIGHT', + rateUnit: 'PER_TON', + rateValue: 25, + originYardId: null, + destinationYardId: null, + } as Partial); + + const exported = await service([ + rate({ rateType: 'CONTAINER_EXPORT', containerTypeId: CT20, rateValue: 900 }), + overweight, + ]).buildBreakdown(contract({ tradeDirection: 'EXPORT' })); + expect(exported.lineItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'OVERWEIGHT_PER_TON', unitPrice: 25 }), + ]), + ); + + // Import derives overweight from the route's base freight — never frozen. + const imported = await service([ + rate({ containerTypeId: CT20, rateValue: 750 }), + overweight, + ]).buildBreakdown(contract({})); + expect( + imported.lineItems.some((li) => li.code === 'OVERWEIGHT_PER_TON'), + ).toBe(false); + }); + it('blocks bulk contracts too instead of borrowing an arbitrary rate', async () => { const bulk = contract({ freightType: 'BULK', cargoScope: [] }); await expect( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index fdf4fa3d1..d54b0b544 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -219,6 +219,26 @@ export class ContractPricingService { }); } } + // Overweight surcharge — EXPORT contracts freeze the OVERWEIGHT_PER_TON + // rate so booking pricing bills the contract's price on excess tons + // (frozenRateByCode wins over the live rate). Always included, no toggle: + // overweight is system-detected at booking, never customer-opted. IMPORT + // never reads this snapshot — its overweight price derives from the + // route's base container freight (see RuleEngineService). + if (contract.tradeDirection === 'EXPORT') { + const overweight = liveRates.find( + (r) => r.trigger === 'OVERWEIGHT' && r.currency === 'USD', + ); + if (overweight && Number(overweight.rateValue) > 0) { + lineItems.push({ + code: 'OVERWEIGHT_PER_TON', + label: 'Overweight surcharge (per excess ton)', + unit: toContractUnit(overweight.rateUnit), + unitPrice: convert(Number(overweight.rateValue)), + conditionalOn: 'is_overweight', + }); + } + } // Lashing / cargo securing — BULK only, shown when the contract's commodity // needs lashing (cargoType.hasLashing). The commodity-scoped rate for the // contract's direction wins over the commodity-wide catch-all; billed at