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 new file mode 100644 index 000000000..ffbef03e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts @@ -0,0 +1,92 @@ +import { UnprocessableEntityException } from '@nestjs/common'; + +import { ContractPricingService } from './contract-pricing.service'; +import type { Contract } from './entities/contract.entity'; +import type { Rate } from '../rule-engine/entities/rate.entity'; + +const CT20 = 'ct-20'; +const CT40 = 'ct-40'; +const DCT = 'yard-dct'; +const SEBETA = 'yard-sebeta'; +const GMP = 'yard-gmp'; + +const rate = (over: Partial): Rate => + ({ + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 1000, + rateUnit: 'PER_CONTAINER', + containerTypeId: null, + cargoTypeId: null, + originYardId: DCT, + destinationYardId: SEBETA, + ...over, + }) as Rate; + +const contract = (over: Partial): Contract => + ({ + freightType: 'CONTAINER', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + customsClearingEnabled: false, + isHazardous: false, + isReefer: false, + routes: [{ originYardId: DCT, destinationYardId: SEBETA, sortOrder: 0 }], + cargoScope: [{ containerSize: '20ft' }], + ...over, + }) as Contract; + +const service = (liveRates: Rate[]): ContractPricingService => + new ContractPricingService( + {} as never, + { findLiveRates: async () => liveRates } as never, + { + findAll: async () => ({ + items: [ + { id: CT20, sizeFt: 20 }, + { id: CT40, sizeFt: 40 }, + ], + }), + } as never, + { getRate: async () => 1 } as never, + ); + +describe('contract base freight is priced on the contract lane only', () => { + it('prices from the contract route, never another lane (CTR-2026-00065)', async () => { + const breakdown = await service([ + // Same size, other lane — the leak that priced DCT → Sebeta at GMP rates. + rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }), + rate({ containerTypeId: CT20, rateValue: 750 }), + ]).buildBreakdown(contract({})); + expect(breakdown.lineItems).toEqual([ + expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 750 }), + ]); + }); + + it('blocks the contract when its lane has no container rate', async () => { + await expect( + service([ + rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }), + ]).buildBreakdown(contract({})), + ).rejects.toThrow(UnprocessableEntityException); + }); + + it('blocks bulk contracts too instead of borrowing an arbitrary rate', async () => { + const bulk = contract({ freightType: 'BULK', cargoScope: [] }); + await expect( + service([ + rate({ + rateType: 'BULK_IMPORT', + rateUnit: 'PER_TON', + destinationYardId: GMP, + }), + ]).buildBreakdown(bulk), + ).rejects.toThrow(UnprocessableEntityException); + const priced = await service([ + rate({ rateType: 'BULK_IMPORT', rateUnit: 'PER_TON', rateValue: 32 }), + ]).buildBreakdown(bulk); + expect(priced.lineItems).toEqual([ + expect.objectContaining({ code: 'BULK_FREIGHT', unitPrice: 32 }), + ]); + }); +}); 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 a4e4b43b5..fdf4fa3d1 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 @@ -84,6 +84,26 @@ export class ContractPricingService { const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); + // Base rail freight is quoted per route (CK_rates_yard_scope) — only rates + // on the contract's own lane may price it. Matching without the yard filter + // is how a DCT → Sebeta contract froze DCT → GMP (Indode) prices, and the + // frozen snapshot then bills bookings that the route-scoped booking lookup + // would have hard-blocked (CTR-2026-00065). + // ponytail: multi-route contracts price the first lane (same as customs + // clearance below); per-lane pricing needs per-route breakdowns. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const onLane = route + ? liveRates.filter( + (r) => + r.rateType === baseType && + r.currency === 'USD' && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (contract.freightType === 'CONTAINER') { const sizes = (contract.cargoScope ?? []) .map((c) => c.containerSize) @@ -97,17 +117,14 @@ export class ContractPricingService { const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt); const matchedIds = new Set(matchedTypes.map((ct) => ct.id)); const rate = - liveRates.find( - (r) => - r.rateType === baseType && - r.currency === 'USD' && - r.containerTypeId && - matchedIds.has(r.containerTypeId), - ) ?? - liveRates.find( - (r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId, + onLane.find( + (r) => r.containerTypeId && matchedIds.has(r.containerTypeId), + ) ?? onLane.find((r) => !r.containerTypeId); + if (!rate || Number(rate.rateValue) <= 0) { + throw new UnprocessableEntityException( + `No rail freight rate is configured for ${size} containers on this direction and route — the contract cannot be priced. Ask the rates team to set a live ${baseType} rate for this container type and origin → destination.`, ); - if (!rate) continue; + } lineItems.push({ code: `CONTAINER_${size.toUpperCase()}`, label: `${size} container`, @@ -120,25 +137,26 @@ export class ContractPricingService { const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); // Freeze the rate for the contract's own commodity when one is configured // — a per-item machinery rate and a per-ton wheat rate live side by side. - const bulkRates = liveRates.filter( - (r) => r.rateType === baseType && r.currency === 'USD', - ); + // No arbitrary-rate fallback: another commodity's rate must never price + // this contract. const bulkRate = (cargoScope?.cargoTypeId - ? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId) + ? onLane.find((r) => r.cargoTypeId === cargoScope.cargoTypeId) : undefined) ?? - bulkRates.find((r) => !r.cargoTypeId) ?? - bulkRates[0] ?? + onLane.find((r) => !r.cargoTypeId) ?? null; - if (bulkRate) { - lineItems.push({ - code: 'BULK_FREIGHT', - label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo', - unit: toContractUnit(bulkRate.rateUnit), - unitPrice: convert(Number(bulkRate.rateValue)), - cargoTypeCode: cargoScope?.cargoType?.code ?? null, - }); + if (!bulkRate || Number(bulkRate.rateValue) <= 0) { + throw new UnprocessableEntityException( + 'No bulk rail freight rate is configured for this cargo type on this direction and route — the contract cannot be priced. Ask the rates team to set a live rate for this commodity and origin → destination.', + ); } + lineItems.push({ + code: 'BULK_FREIGHT', + label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo', + unit: toContractUnit(bulkRate.rateUnit), + unitPrice: convert(Number(bulkRate.rateValue)), + cargoTypeCode: cargoScope?.cargoType?.code ?? null, + }); } // First / last mile trucking unit rates — shown when the contract carries @@ -241,9 +259,6 @@ export class ContractPricingService { // one display line per contract size that has a configured rate. A size // with no rate shows nothing here and hard-blocks at booking time. // ponytail: bookings bill the live route rate, not a frozen snapshot. - const route = [...(contract.routes ?? [])].sort( - (a, b) => a.sortOrder - b.sortOrder, - )[0]; const onLeg = route ? liveRates.filter( (r) => @@ -291,9 +306,6 @@ export class ContractPricingService { if (contract.customsClearingEnabled) { // Strict, no route-less fallback. // ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots. - const route = [...(contract.routes ?? [])].sort( - (a, b) => a.sortOrder - b.sortOrder, - )[0]; const onLeg = route ? liveRates.filter( (r) =>