mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user