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

@@ -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) =>