fix(contracts): price base freight from contract lane only

Contract pricing matched base container/bulk rates by type+currency
with no origin/destination filter, so a lane with no configured rate
silently froze another lane's price into the contract snapshot
(CTR-2026-00065: DCT-Sebeta contract froze DCT-GMP rates) and bookings
then billed off it. Now scoped to the contract's first route and a
missing lane rate hard-blocks pricing with a 422, matching the
customs-clearance and booking-side behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-07-30 09:24:37 +00:00
parent 63604b0cf4
commit 9241ce01d2
2 changed files with 134 additions and 30 deletions

View File

@@ -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>): 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>): 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 }),
]);
});
});

View File

@@ -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,17 +137,19 @@ 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) {
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',
@@ -139,7 +158,6 @@ export class ContractPricingService {
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
});
}
}
// First / last mile trucking unit rates — shown when the contract carries
// that leg. Per-unit prices only; the actual amount (× km / containers /
@@ -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) =>