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 { 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 { 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 { 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, }); } } }