feat(freight-api): add DJF as a supported currency

Adds DJF to freight.payments_currency_enum (migration 3860000000000,
alone in its own migration per Postgres's ADD VALUE-in-a-transaction
restriction) and to manual_payment_settings (djf_enabled column,
migration 3870000000000, default true).

Replaces the binary ETB/USD assumptions that would have silently
mispriced or discarded a DJF booking:
- booking-pricing / contract-pricing: usdToEtb scalar -> a rate table
  keyed by source currency (ExchangeService.getRateTable), so a
  contract-frozen rate converts into whatever currency the booking is
  paid in instead of being dropped when neither leg is ETB or USD.
- warehouse-fee / booking-wagon-cancellation: normalizeCurrency no
  longer coerces anything non-ETB to USD.
- additional-charge: convertAmount no longer bails out for a currency
  that isn't literally ETB or USD.
- manual-payment-settings: isEnabled/enabledCurrencies cover DJF.

Widens the three @IsIn(['ETB','USD']) DTO validators, and adds DJF to
the export/report currency filter option lists.

Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
This commit is contained in:
ghost2023
2026-09-04 11:52:37 +03:00
parent 21dc28a708
commit 3734ca3897
20 changed files with 183 additions and 75 deletions

View File

@@ -8,7 +8,7 @@ import { Rate } from '../rule-engine/entities/rate.entity';
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { round2 } from '../billing/invoice-settlement.util';
import { ExchangeService } from '@edr/api-common';
import { CurrencyCode, ExchangeService } from '@edr/api-common';
import {
AppliedCargoModifier,
BookingEvaluationInput,
@@ -143,8 +143,9 @@ export class BookingPricingService {
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const isEtbBooking = paymentCurrency !== 'USD';
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
const usdToEtb = fx['USD'];
// H15: a booking created under a contract prices from that contract's FROZEN
// rate snapshots (the agreed rates), not the live rate of the day. Loaded
@@ -213,7 +214,7 @@ export class BookingPricingService {
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
const frozen = isDerived
? null
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb);
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, fx);
const unitAmount = frozen
? Number(frozen.unitPrice)
: isEtbBooking
@@ -570,8 +571,9 @@ export class BookingPricingService {
}> {
const liveRates = await this.liveRatesForBooking(booking);
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const isEtbBooking = paymentCurrency !== 'USD';
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
const usdToEtb = fx['USD'];
const isBulk = booking.freightType === 'BULK';
const rateType =
@@ -608,7 +610,7 @@ export class BookingPricingService {
frozenRates,
container.containerTypeId,
paymentCurrency,
usdToEtb,
fx,
);
const label = await this.containerTypeLabel(container.containerTypeId);
if (!rate && !frozen) {
@@ -698,7 +700,7 @@ export class BookingPricingService {
const unitUsd = Number(fallback.rateValue);
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
const frozen = isBulk
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb)
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, fx)
: null;
let amount: number;
let unitAmount: number;
@@ -771,8 +773,9 @@ export class BookingPricingService {
const liveRates = await this.liveRatesForBooking(booking);
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const isEtbBooking = paymentCurrency !== 'USD';
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
const usdToEtb = fx['USD'];
const containerCount = evalInput.containers.reduce(
(sum, c) => sum + Number(c.quantity || 0),
@@ -824,7 +827,7 @@ export class BookingPricingService {
frozenRates,
leg.rateType,
paymentCurrency,
usdToEtb,
fx,
);
let amount: number;
let unitAmount: number;
@@ -1021,13 +1024,18 @@ export class BookingPricingService {
* drifted to.) Grandfathered ETB contracts convert the other way for the same
* reason.
*
* `fx` is a rate table converting FROM each source currency INTO the
* booking's currency (see `ExchangeService.getRateTable`) — a snapshot can
* be frozen in USD or (grandfathered) ETB, and the booking can be paid in
* any supported currency, so a scalar USD→ETB rate is no longer enough.
*
* Returns null only when there is no snapshot or its price is unusable.
*/
private frozenRateByCode(
frozenRates: Map<string, ContractRateSnapshot> | null,
code: string,
bookingCurrency: string,
usdToEtb: number,
fx: Record<string, number>,
): ContractRateSnapshot | null {
const snap = frozenRates?.get(code);
if (!snap) return null;
@@ -1035,15 +1043,11 @@ export class BookingPricingService {
if (!(unitPrice >= 0)) return null;
if (snap.currency === bookingCurrency) return snap;
// Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price.
if (!(usdToEtb > 0)) return null;
const converted =
snap.currency === 'USD' && bookingCurrency === 'ETB'
? round2(unitPrice * usdToEtb)
: snap.currency === 'ETB' && bookingCurrency === 'USD'
? unitPrice / usdToEtb
: null;
if (converted == null) return null;
// A rate of 0/NaN (an unpriced or unsupported source currency) would
// silently zero the price.
const rate = fx[snap.currency];
if (!(rate > 0)) return null;
const converted = round2(unitPrice * rate);
// A copy — the snapshot rows are shared across the pricing pass.
return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, {
@@ -1061,7 +1065,7 @@ export class BookingPricingService {
frozenRates: Map<string, ContractRateSnapshot> | null,
containerTypeId: string,
bookingCurrency: string,
usdToEtb: number,
fx: Record<string, number>,
): Promise<ContractRateSnapshot | null> {
if (!frozenRates) return null;
let sizeFt: number | null = null;
@@ -1071,7 +1075,7 @@ export class BookingPricingService {
return null;
}
if (!sizeFt) return null;
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb);
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, fx);
}
/**
@@ -1093,9 +1097,9 @@ export class BookingPricingService {
const usedRates: Rate[] = [];
const blocked: string[] = [];
const currency = booking.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 fx = await this.exchangeService.getRateTable(currency as CurrencyCode);
const usdToEtb = fx['USD'];
const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToEtb));
// An Ethiopian-side-only customs service prices off its own rate; the
// contract froze its snapshots under the matching code prefix. Resolved by
@@ -1132,7 +1136,7 @@ export class BookingPricingService {
const hasPerSizeSnapshot =
frozenRates?.has(`${customsType}_20FT`) ||
frozenRates?.has(`${customsType}_40FT`);
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, fx);
if (legacyFlat && !hasPerSizeSnapshot) {
const amount = Number(legacyFlat.unitPrice);
if (amount > 0) {
@@ -1161,7 +1165,7 @@ export class BookingPricingService {
// unknown type — falls through to the live per-type lookup below
}
const frozen = sizeFt
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, fx)
: null;
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
if (!frozen && !live) {
@@ -1196,7 +1200,7 @@ export class BookingPricingService {
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
// Live lookup: the rate scoped to the booking's commodity wins; a
// commodity-less rate (legacy) is the catch-all fallback.
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, fx);
const live =
(booking.cargoTypeId
? onLeg.find(