Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts

508 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { RatesService } from '../rule-engine/services/rates.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { round2 } from '../billing/invoice-settlement.util';
import { ExchangeService } from '@edr/api-common';
import { ContractsRepository } from './contracts.repository';
import { Contract } from './entities/contract.entity';
/** A single unit-rate line at contract phase — NO quantities, NO totals. */
export interface ContractUnitRateLineItem {
code: string;
label: string;
unit:
| 'per_container'
| 'per_wagon'
| 'per_ton'
| 'per_item'
| 'per_km'
| 'per_liter'
| 'flat';
unitPrice: number;
containerSize?: string | null;
conditionalOn?: string | null;
cargoTypeCode?: string | null;
/**
* Customs clearance service fee — billed separately in advance (before the
* clearance document step), never part of shipment booking totals.
*/
isClearance?: boolean;
}
/** The contract `pricing_breakdown` shape (doc §9.1). */
export interface ContractPricingBreakdown {
displayMode: 'UNIT_RATES';
currency: string;
lineItems: ContractUnitRateLineItem[];
generatedAt: string;
}
/** Map a rate's storage unit to the contract-display unit. */
function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
switch (rateUnit) {
case 'PER_TON':
return 'per_ton';
case 'PER_ITEM':
return 'per_item';
case 'PER_KM':
return 'per_km';
case 'PER_WAGON':
return 'per_wagon';
case 'PER_CONTAINER':
return 'per_container';
case 'PER_LITER':
return 'per_liter';
default:
return 'flat';
}
}
@Injectable()
export class ContractPricingService {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly ratesService: RatesService,
private readonly containerTypesService: ContainerTypesService,
private readonly exchangeService: ExchangeService,
) {}
/** Base rail rate type for the contract's direction + freight. */
private baseRateType(contract: Contract): string {
const isBulk = contract.freightType === 'BULK';
if (contract.tradeDirection === 'IMPORT') {
return isBulk ? 'BULK_IMPORT' : 'CONTAINER_IMPORT';
}
if (contract.tradeDirection === 'EXPORT') {
return isBulk ? 'BULK_EXPORT' : 'CONTAINER_EXPORT';
}
return isBulk ? 'INTERCITY_BULK' : 'INTERCITY_CONTAINER';
}
/**
* Build the unit-rate breakdown from live rates. Emits per-unit prices only
* (one per container size, conditional hazard/reefer surcharges, and bulk
* commodity rate) — NO totals or quantities (doc §9.1).
*/
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
const liveRates = await this.ratesService.findLiveRates();
const currency = contract.paymentCurrency;
const isEtb = currency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
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)
.filter((s): s is string => !!s);
const { items: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 100,
});
for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20;
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
const rate =
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.`,
);
}
lineItems.push({
code: `CONTAINER_${size.toUpperCase()}`,
label: `${size} container`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
});
}
} else {
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.
// No arbitrary-rate fallback: another commodity's rate must never price
// this contract.
const bulkRate =
(cargoScope?.cargoTypeId
? onLane.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
: undefined) ??
onLane.find((r) => !r.cargoTypeId) ??
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
// that leg. Per-unit prices only; the actual amount (× km / containers /
// tons / flat) is computed at booking time.
if (contract.firstMilePickupAddress) {
const fm = liveRates.find(
(r) => r.rateType === 'FIRST_MILE' && r.currency === 'USD',
);
if (fm && Number(fm.rateValue) > 0) {
lineItems.push({
code: 'FIRST_MILE',
label: 'First mile (pick-up)',
unit: toContractUnit(fm.rateUnit),
unitPrice: convert(Number(fm.rateValue)),
});
}
}
if (contract.lastMileDeliveryAddress) {
// New-style last-mile rates (PER_TON_KM / distance-banded PER_KM) are
// priced operationally per job, not as a single contract unit price.
const lm = liveRates.find(
(r) =>
r.rateType === 'LAST_MILE' &&
r.currency === 'USD' &&
r.rateUnit !== 'PER_TON_KM' &&
r.minKm == null,
);
if (lm && Number(lm.rateValue) > 0) {
lineItems.push({
code: 'LAST_MILE',
label: 'Last mile (delivery)',
unit: toContractUnit(lm.rateUnit),
unitPrice: convert(Number(lm.rateValue)),
});
}
}
// Conditional surcharges — shown only when the contract toggles them on AND
// the rate has a non-zero value (a 0 rate means "no surcharge").
if (contract.isHazardous) {
const hazard = liveRates.find(
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
);
if (hazard && Number(hazard.rateValue) > 0) {
lineItems.push({
code: 'HAZARD_SURCHARGE',
label: 'Hazardous surcharge',
unit: toContractUnit(hazard.rateUnit),
unitPrice: convert(Number(hazard.rateValue)),
conditionalOn: 'is_hazardous',
});
}
}
if (contract.isReefer) {
const reefer = liveRates.find(
(r) => r.rateType === 'REEFER_SURCHARGE' && r.currency === 'USD',
);
if (reefer && Number(reefer.rateValue) > 0) {
lineItems.push({
code: 'REEFER_SURCHARGE',
label: 'Reefer surcharge',
unit: toContractUnit(reefer.rateUnit),
unitPrice: convert(Number(reefer.rateValue)),
conditionalOn: 'is_reefer',
});
}
}
// 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
// booking on the live rate (per ton / per wagon), this line is display.
if (contract.freightType === 'BULK') {
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
if (scope?.cargoType?.hasLashing) {
const onDirection = liveRates.filter(
(r) =>
r.trigger === 'LASHING' &&
r.currency === 'USD' &&
!r.containerTypeId &&
r.tradeDirection === contract.tradeDirection,
);
const lashing =
onDirection.find((r) => r.cargoTypeId === scope.cargoTypeId) ??
onDirection.find((r) => !r.cargoTypeId);
if (lashing && Number(lashing.rateValue) > 0) {
lineItems.push({
code: 'LASHING',
label: `Lashing / cargo securing (${scope.cargoType.cargoTypeName})`,
unit: toContractUnit(lashing.rateUnit),
unitPrice: convert(Number(lashing.rateValue)),
cargoTypeCode: scope.cargoType.code ?? null,
conditionalOn: 'has_lashing',
});
}
}
}
// Fuel surcharge — shown when the contract's commodity incurs fuel
// (cargoType.hasFuel), sold per lane + commodity. Billed at booking on the
// frozen/live rate (per wagon × wagons, or per liter × base liters, once);
// this line freezes the agreed unit price.
{
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
if (scope?.cargoType?.hasFuel && route) {
const fuel = liveRates.find(
(r) =>
r.trigger === 'FUEL' &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId &&
r.cargoTypeId === scope.cargoTypeId,
);
if (fuel && Number(fuel.rateValue) > 0) {
// Per-liter collapses to one flat total (base liters × rate value) —
// the customer only sees the final price, and booking pricing bills
// the same flat figure once (see RuleEngineService.fuelCharges).
const perLiter = fuel.rateUnit === 'PER_LITER';
const total = perLiter
? Number(fuel.baseLiters ?? 0) * Number(fuel.rateValue)
: Number(fuel.rateValue);
lineItems.push({
code: 'FUEL_SURCHARGE',
label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`,
unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit),
unitPrice: convert(total),
cargoTypeCode: scope.cargoType.code ?? null,
conditionalOn: 'has_fuel',
});
}
}
}
// Empty-container return service — container contracts only, toggled on the
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
if (
contract.freightType === 'CONTAINER' &&
contract.equipmentReturn === 'WITH_RETURN'
) {
// Return is sold per direction + route + container type (import-only) —
// 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 onLeg = route
? liveRates.filter(
(r) =>
r.rateType === 'RETURN_SURCHARGE' &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId,
)
: [];
if (onLeg.length > 0) {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s);
const { items: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 100,
});
for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20;
const matchedIds = new Set(
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
);
const rate =
onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ??
onLeg.find((r) => !r.containerTypeId);
if (!rate || Number(rate.rateValue) <= 0) continue;
lineItems.push({
code: 'RETURN_SURCHARGE',
label: `Empty container return (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
conditionalOn: 'with_return',
});
}
}
}
// Customs clearance service fee (Path B) — billed on the booking invoice
// together with the freight. Sold per direction + route + cargo kind:
// container contracts freeze one fee line per contract size (each size's
// own container-type rate), bulk contracts freeze the route's bulk fee.
// A customs contract may not proceed without the fee(s) configured.
if (contract.customsClearingEnabled) {
// An Ethiopian-side-only customs service prices off its own rate; the
// snapshot codes carry the same prefix so booking pricing finds them.
const customsType = contract.serviceType?.includesEthiopianCustomsOnly
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
: 'CUSTOMS_CLEARANCE';
const customsLabel =
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
? 'Ethiopian customs clearance service'
: 'Customs clearance service';
// Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const onLeg = route
? liveRates.filter(
(r) =>
r.rateType === customsType &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId,
)
: [];
if (contract.freightType === 'CONTAINER') {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s);
const { items: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 100,
});
for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20;
const matchedIds = new Set(
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
);
const rate = onLeg.find(
(r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live ${customsType} rate for this container type and origin → destination.`,
);
}
lineItems.push({
// Distinct code per size so the frozen snapshots don't collide —
// booking pricing looks each size up by <customsType>_<FT>FT.
code: `${customsType}_${sizeFt}FT`,
label: `${customsLabel} (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
isClearance: true,
});
}
} else {
// Bulk fee — the rate scoped to the contract's commodity wins; a
// commodity-less rate (legacy) is the catch-all fallback.
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
const rate =
(scope?.cargoTypeId
? onLeg.find(
(r) => !r.containerTypeId && r.cargoTypeId === scope.cargoTypeId,
)
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
`No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk ${customsType} rate for this commodity and origin → destination.`,
);
}
lineItems.push({
code: customsType,
label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
cargoTypeCode: scope?.cargoType?.code ?? null,
isClearance: true,
});
}
}
return {
displayMode: 'UNIT_RATES',
currency,
lineItems,
generatedAt: new Date().toISOString(),
};
}
/** Generate (and persist) the unit-rate breakdown for a contract. */
async generatePrice(contractId: string): Promise<ContractPricingBreakdown> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) {
throw new Error(`Contract ${contractId} not found`);
}
const breakdown = await this.buildBreakdown(contract);
await this.contractsRepository.update(contractId, {
pricingBreakdown: breakdown as never,
pricingDisplayMode: 'UNIT_RATES',
} as never);
return breakdown;
}
/**
* Freeze the contract's unit rates into contract_rate_snapshots (one row per
* rate line) at submit time. The booking later computes totals from these.
*/
async freezeRateSnapshots(contractId: string): Promise<void> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) return;
const breakdown =
(contract.pricingBreakdown as ContractPricingBreakdown | null) ??
(await this.buildBreakdown(contract));
await this.contractsRepository.clearRateSnapshots(contractId);
for (const line of breakdown.lineItems) {
await this.contractsRepository.createRateSnapshot({
contractId,
rateCode: line.code,
description: line.label,
unitPrice: line.unitPrice,
unitOfMeasure: line.unit,
currency: breakdown.currency,
containerSize: line.containerSize ?? null,
isSurcharge: !!line.conditionalOn,
conditionalOn: line.conditionalOn ?? null,
isClearance: !!line.isClearance,
});
}
}
}