Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
2026-07-23 11:06:10 +00:00

330 lines
12 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 { 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_ton' | 'per_item' | 'per_km' | '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_KM':
return 'per_km';
case 'PER_CONTAINER':
case 'PER_WAGON':
return 'per_container';
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 ? Math.round(usd * usdToEtb) : usd);
const lineItems: ContractUnitRateLineItem[] = [];
const baseType = this.baseRateType(contract);
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 =
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,
);
if (!rate) continue;
lineItems.push({
code: `CONTAINER_${size.toUpperCase()}`,
label: `${size} container`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
});
}
} else {
const bulkRate =
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
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,
});
}
}
// 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) {
const lm = liveRates.find(
(r) => r.rateType === 'LAST_MILE' && r.currency === 'USD',
);
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',
});
}
}
// 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 route = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
)[0];
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) — a FLAT prepaid fee, shown on the
// contract and billed via its own clearance invoice: after counter-sign for
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
// A customs contract may not proceed without a configured live rate.
if (contract.customsClearingEnabled) {
// The fee is sold per direction + route — 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 clearance = route
? liveRates.find(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId,
)
: undefined;
if (!clearance || Number(clearance.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.',
);
}
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label:
contract.contractKind === 'GENERAL'
? 'Customs clearance service fee (per shipment request, prepaid)'
: 'Customs clearance service fee (prepaid)',
unit: toContractUnit(clearance.rateUnit),
unitPrice: convert(Number(clearance.rateValue)),
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,
});
}
}
}