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

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds DJF to `freight.payments_currency_enum` — the only currency column in
* the schema backed by a real Postgres enum (every other currency column is
* a plain varchar and needed no migration).
*
* This statement must be the ONLY thing in its migration: `ALTER TYPE ... ADD
* VALUE` cannot be used within the same transaction that added it (Postgres
* restriction, still true on PG 12+), and migrations here run one-per-
* transaction (`migrationsTransactionMode: 'each'`). Do not add a seed insert
* that writes 'DJF' into `payments.currency` to this file.
*/
export class AddDjfPaymentsCurrency3860000000000 implements MigrationInterface {
name = 'AddDjfPaymentsCurrency3860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE freight.payments_currency_enum ADD VALUE IF NOT EXISTS 'DJF'`);
}
public async down(): Promise<void> {
// Postgres cannot drop a single enum value. Reverting would require
// recreating the type and every dependent column/constraint — out of
// scope for a currency addition; leave it in place.
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds the DJF toggle to `manual_payment_settings`, alongside the existing
* `etb_enabled`/`usd_enabled` columns. Defaults to `true` — like USD, DJF
* invoices are bank-transfer-settleable from day one.
*/
export class AddDjfManualPaymentSetting3870000000000 implements MigrationInterface {
name = 'AddDjfManualPaymentSetting3870000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.manual_payment_settings
ADD COLUMN IF NOT EXISTS djf_enabled boolean NOT NULL DEFAULT true;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_enabled;
`);
}
}

View File

@@ -126,7 +126,7 @@ export class FilterInvoiceDto {
@ApiPropertyOptional({ enum: ["USD", "ETB"] }) @ApiPropertyOptional({ enum: ["USD", "ETB"] })
@IsOptional() @IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["USD", "ETB"]) @IsIn(["ETB", "USD", "DJF"])
currency?: "USD" | "ETB"; currency?: "USD" | "ETB";
@ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." }) @ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." })

View File

@@ -1,7 +1,7 @@
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { ExchangeService } from '@edr/api-common'; import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
@@ -291,20 +291,24 @@ export class AdditionalChargeService {
} }
/** /**
* Amount converted to the other of ETB/USD, via the existing shared * Amount converted to a second reference currency, via the existing shared
* `ExchangeService` (CBE rate, falls back to the stored `exchange_settings` * `ExchangeService` (CBE rate, falls back to the stored `exchange_settings`
* rate) — same mechanism `booking-wagon-cancellation.service.ts` and * rate) — same mechanism `booking-wagon-cancellation.service.ts` and
* warehouse fee pricing already use. Null on anything but ETB/USD, or if * warehouse fee pricing already use. ETB converts to USD and vice versa
* (unchanged behaviour); any other supported currency (DJF) converts to
* USD, the system's pivot currency. Null on an unsupported currency, or if
* the rate feed is down — this is a display convenience, not the payable * the rate feed is down — this is a display convenience, not the payable
* amount, so a failure here must never break the charge list. * amount, so a failure here must never break the charge list.
*/ */
private async convertAmount( private async convertAmount(
charge: AdditionalCharge, charge: AdditionalCharge,
): Promise<{ amount: number; currency: string } | null> { ): Promise<{ amount: number; currency: string } | null> {
if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null; const from = charge.currency?.toUpperCase();
const target = charge.currency === 'ETB' ? 'USD' : 'ETB'; if (!(CURRENCY_CODES as readonly string[]).includes(from ?? '')) return null;
const source = from as CurrencyCode;
const target: CurrencyCode = source === 'ETB' ? 'USD' : source === 'USD' ? 'ETB' : 'USD';
try { try {
const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target); const amount = await this.exchangeService.convert(Number(charge.amount), source, target);
return { amount: Math.round(amount * 100) / 100, currency: target }; return { amount: Math.round(amount * 100) / 100, currency: target };
} catch (err) { } catch (err) {
this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`); this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`);

View File

@@ -38,7 +38,7 @@ describe('BookingPricingService — domestic corridor', () => {
let service: BookingPricingService; let service: BookingPricingService;
let bookingsRepository: { calculateWagonCount: jest.Mock }; let bookingsRepository: { calculateWagonCount: jest.Mock };
let ratesService: { findLiveRates: jest.Mock }; let ratesService: { findLiveRates: jest.Mock };
let exchangeService: { getRate: jest.Mock }; let exchangeService: { getRate: jest.Mock; getRateTable: jest.Mock };
beforeEach(() => { beforeEach(() => {
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
@@ -47,6 +47,13 @@ describe('BookingPricingService — domestic corridor', () => {
}; };
exchangeService = { exchangeService = {
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
// Delegates to `getRate` so a test that reassigns
// `exchangeService.getRate.mockResolvedValue(...)` gets a consistent
// rate table without also having to touch this mock.
getRateTable: jest.fn(async (target: string) => {
const rate = await exchangeService.getRate('USD', target);
return { ETB: rate, USD: rate, DJF: rate };
}),
}; };
service = new BookingPricingService( service = new BookingPricingService(
@@ -324,7 +331,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking
})), })),
} as never, } as never,
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never, { findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{ {
findById: jest.fn().mockResolvedValue({ findById: jest.fn().mockResolvedValue({
@@ -572,7 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => {
} as never, } as never,
{ findById: jest.fn() } as never, { findById: jest.fn() } as never,
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never, { findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{ {
findById: jest.fn().mockResolvedValue({ findById: jest.fn().mockResolvedValue({
@@ -707,7 +714,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
})), })),
} as never, } as never,
{ findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never, { findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{ findById: jest.fn() } as never, { findById: jest.fn() } as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,

View File

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

View File

@@ -8,7 +8,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { ExchangeService } from '@edr/api-common'; import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In, IsNull } from 'typeorm'; import { DataSource, EntityManager, In, IsNull } from 'typeorm';
@@ -114,6 +114,15 @@ interface PricedFee {
* The cycle is repeatable by construction: the rebooked booking is a normal * The cycle is repeatable by construction: the rebooked booking is a normal
* PAID booking, so it can itself be partially cancelled again. * PAID booking, so it can itself be partially cancelled again.
*/ */
/** Validates a stored currency string against the supported set, defaulting to USD. */
function toCurrencyCode(currency?: string | null): CurrencyCode {
const code = currency?.toUpperCase();
return (CURRENCY_CODES as readonly string[]).includes(code ?? '')
? (code as CurrencyCode)
: 'USD';
}
@Injectable() @Injectable()
export class BookingWagonCancellationService { export class BookingWagonCancellationService {
private readonly logger = new Logger(BookingWagonCancellationService.name); private readonly logger = new Logger(BookingWagonCancellationService.name);
@@ -1697,10 +1706,10 @@ export class BookingWagonCancellationService {
*/ */
private async priceFee(booking: Booking, cut: RequestedCut): Promise<PricedFee> { private async priceFee(booking: Booking, cut: RequestedCut): Promise<PricedFee> {
const raw = await this.priceFeeInRateCurrency(booking, cut); const raw = await this.priceFeeInRateCurrency(booking, cut);
// Bill in the booking's own currency (rates are configured in USD; ETB // Bill in the booking's own currency (rates are configured in USD; a
// bookings pay ETB) — same USD→ETB conversion booking pricing applies. // non-USD booking converts) — same conversion booking pricing applies.
const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; const target = toCurrencyCode(booking.paymentCurrency);
const from = raw.currency === 'ETB' ? 'ETB' : 'USD'; const from = toCurrencyCode(raw.currency);
if (from === target) return raw; if (from === target) return raw;
const fx = await this.exchangeService.getRate(from, target); const fx = await this.exchangeService.getRate(from, target);
return { return {

View File

@@ -3,7 +3,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { RatesService } from '../rule-engine/services/rates.service'; import { RatesService } from '../rule-engine/services/rates.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { round2 } from '../billing/invoice-settlement.util'; import { round2 } from '../billing/invoice-settlement.util';
import { ExchangeService } from '@edr/api-common'; import { CurrencyCode, ExchangeService } from '@edr/api-common';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
@@ -95,9 +95,9 @@ export class ContractPricingService {
(r) => !r.shippingLineCompanyId, (r) => !r.shippingLineCompanyId,
); );
const currency = contract.paymentCurrency; const currency = contract.paymentCurrency;
const isEtb = currency === 'ETB'; const usdToTarget =
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; currency === 'USD' ? 1 : await this.exchangeService.getRate('USD', currency as CurrencyCode);
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToTarget));
const lineItems: ContractUnitRateLineItem[] = []; const lineItems: ContractUnitRateLineItem[] = [];
const baseType = this.baseRateType(contract); const baseType = this.baseRateType(contract);

View File

@@ -98,7 +98,7 @@ export class CreateBookingRequestDto {
'Billing currency for the shipment GL will book. Intercity is always ETB.', 'Billing currency for the shipment GL will book. Intercity is always ETB.',
}) })
@IsOptional() @IsOptional()
@IsIn(['ETB', 'USD']) @IsIn(['ETB', 'USD', 'DJF'])
paymentCurrency?: string; paymentCurrency?: string;
@ApiPropertyOptional() @ApiPropertyOptional()

View File

@@ -20,7 +20,7 @@ const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot =>
const frozenByCode = ( const frozenByCode = (
snap: ContractRateSnapshot | null, snap: ContractRateSnapshot | null,
bookingCurrency: string, bookingCurrency: string,
usdToEtb: number, fx: Record<string, number>,
): ContractRateSnapshot | null => ): ContractRateSnapshot | null =>
( (
BookingPricingService.prototype as unknown as { BookingPricingService.prototype as unknown as {
@@ -28,14 +28,14 @@ const frozenByCode = (
m: Map<string, ContractRateSnapshot> | null, m: Map<string, ContractRateSnapshot> | null,
code: string, code: string,
bookingCurrency: string, bookingCurrency: string,
usdToEtb: number, fx: Record<string, number>,
) => ContractRateSnapshot | null; ) => ContractRateSnapshot | null;
} }
).frozenRateByCode( ).frozenRateByCode(
snap ? new Map([['CONTAINER_20FT', snap]]) : null, snap ? new Map([['CONTAINER_20FT', snap]]) : null,
'CONTAINER_20FT', 'CONTAINER_20FT',
bookingCurrency, bookingCurrency,
usdToEtb, fx,
); );
describe('per-shipment billing currency', () => { describe('per-shipment billing currency', () => {
@@ -61,25 +61,34 @@ describe('frozen contract rate in the booking currency', () => {
it('converts a USD snapshot for an ETB booking instead of dropping it', () => { it('converts a USD snapshot for an ETB booking instead of dropping it', () => {
// The old behaviour returned null here, which silently re-priced the // The old behaviour returned null here, which silently re-priced the
// booking at live rates and lost the agreed contract price. // booking at live rates and lost the agreed contract price.
expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000); expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 150 })?.unitPrice).toBe(60_000);
}); });
it('converts a grandfathered ETB snapshot back for a USD booking', () => { it('converts a grandfathered ETB snapshot back for a USD booking', () => {
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400); expect(frozenByCode(snapshot('ETB', 60_000), 'USD', { ETB: 1 / 150 })?.unitPrice).toBe(400);
});
it('converts a USD snapshot for a DJF booking via the USD->DJF rate', () => {
// 177.6 ETB/DJF pivot: USD->DJF = usdToEtb / djfToEtb = 150 / 0.845.
expect(frozenByCode(snapshot('USD', 400), 'DJF', { USD: 177.6 })?.unitPrice).toBe(71_040);
}); });
it('passes a matching-currency snapshot through untouched', () => { it('passes a matching-currency snapshot through untouched', () => {
const snap = snapshot('USD', 400); const snap = snapshot('USD', 400);
expect(frozenByCode(snap, 'USD', 1)).toBe(snap); expect(frozenByCode(snap, 'USD', { USD: 1 })).toBe(snap);
}); });
it('refuses to price off an unusable exchange rate', () => { it('refuses to price off an unusable exchange rate', () => {
// Converting with 0 would zero the whole line. // Converting with 0 would zero the whole line.
expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull(); expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 0 })).toBeNull();
expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull(); expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: Number.NaN })).toBeNull();
});
it('refuses to price off a currency the rate table has no entry for', () => {
expect(frozenByCode(snapshot('USD', 400), 'DJF', {})).toBeNull();
}); });
it('returns null when there is no snapshot', () => { it('returns null when there is no snapshot', () => {
expect(frozenByCode(null, 'ETB', 150)).toBeNull(); expect(frozenByCode(null, 'ETB', { USD: 150 })).toBeNull();
}); });
}); });

View File

@@ -112,6 +112,7 @@ export const contractsDataset: ExportDataset = {
{ key: 'paymentCurrency', label: 'Currency', type: 'select', options: [ { key: 'paymentCurrency', label: 'Currency', type: 'select', options: [
{ value: 'ETB', label: 'ETB' }, { value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' }, { value: 'USD', label: 'USD' },
{ value: 'DJF', label: 'DJF' },
] }, ] },
{ key: 'serviceTypeId', label: 'Service type', type: 'text' }, { key: 'serviceTypeId', label: 'Service type', type: 'text' },
// Routes are one-to-many on contract_routes, so these filter via EXISTS // Routes are one-to-many on contract_routes, so these filter via EXISTS

View File

@@ -129,6 +129,7 @@ export const invoicesDataset: ExportDataset = {
{ key: 'currency', label: 'Currency', type: 'select', options: [ { key: 'currency', label: 'Currency', type: 'select', options: [
{ value: 'ETB', label: 'ETB' }, { value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' }, { value: 'USD', label: 'USD' },
{ value: 'DJF', label: 'DJF' },
] }, ] },
{ key: 'minAmount', label: 'Min total', type: 'text' }, { key: 'minAmount', label: 'Min total', type: 'text' },
{ key: 'maxAmount', label: 'Max total', type: 'text' }, { key: 'maxAmount', label: 'Max total', type: 'text' },

View File

@@ -89,6 +89,7 @@ export const paymentsDataset: ExportDataset = {
{ key: 'currency', label: 'Currency', type: 'select', options: [ { key: 'currency', label: 'Currency', type: 'select', options: [
{ value: 'ETB', label: 'ETB' }, { value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' }, { value: 'USD', label: 'USD' },
{ value: 'DJF', label: 'DJF' },
] }, ] },
{ key: 'search', label: 'Search order or transaction ID', type: 'text' }, { key: 'search', label: 'Search order or transaction ID', type: 'text' },
], ],

View File

@@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
usdEnabled?: boolean; usdEnabled?: boolean;
@ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" })
@IsOptional()
@IsBoolean()
djfEnabled?: boolean;
} }

View File

@@ -20,6 +20,10 @@ export class ManualPaymentSetting extends BaseEntity {
@Column({ name: "usd_enabled", type: "boolean", default: true }) @Column({ name: "usd_enabled", type: "boolean", default: true })
usdEnabled!: boolean; usdEnabled!: boolean;
/** Manual settlement allowed for DJF invoices. */
@Column({ name: "djf_enabled", type: "boolean", default: true })
djfEnabled!: boolean;
/** IAM user id of the last operator to change either toggle. */ /** IAM user id of the last operator to change either toggle. */
@Column({ name: "updated_by_id", type: "uuid", nullable: true }) @Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null; updatedById?: string | null;

View File

@@ -4,16 +4,23 @@ import { Repository } from "typeorm";
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
/** The two currencies an invoice can be settled by hand in. */ /** The currencies an invoice can be settled by hand in. */
export type ManualPaymentCurrency = "ETB" | "USD"; export type ManualPaymentCurrency = "ETB" | "USD" | "DJF";
const FIELD_BY_CURRENCY: Record<ManualPaymentCurrency, "etbEnabled" | "usdEnabled" | "djfEnabled"> = {
ETB: "etbEnabled",
USD: "usdEnabled",
DJF: "djfEnabled",
};
/** /**
* Owns the single `manual_payment_settings` row: whether Finance may settle * Owns the single `manual_payment_settings` row: whether Finance may settle
* invoices by hand, per currency. * invoices by hand, per currency.
* *
* Defaults mirror how the platform behaved before the toggles existed — USD * Defaults mirror how the platform behaved before the toggles existed — USD
* has always been bank-transfer-only so it starts ON; ETB manual settlement is * and DJF have always been bank-transfer-capable so they start ON; ETB manual
* the new capability and starts OFF, so enabling it is a deliberate act. * settlement is the new capability and starts OFF, so enabling it is a
* deliberate act.
*/ */
@Injectable() @Injectable()
export class ManualPaymentSettingsService { export class ManualPaymentSettingsService {
@@ -30,7 +37,7 @@ export class ManualPaymentSettingsService {
if (existing) return existing; if (existing) return existing;
return this.repository.save( return this.repository.save(
this.repository.create({ etbEnabled: false, usdEnabled: true }), this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }),
); );
} }
@@ -40,31 +47,34 @@ export class ManualPaymentSettingsService {
const enabled: ManualPaymentCurrency[] = []; const enabled: ManualPaymentCurrency[] = [];
if (setting.etbEnabled) enabled.push("ETB"); if (setting.etbEnabled) enabled.push("ETB");
if (setting.usdEnabled) enabled.push("USD"); if (setting.usdEnabled) enabled.push("USD");
if (setting.djfEnabled) enabled.push("DJF");
return enabled; return enabled;
} }
/** Whether one currency may be settled by hand right now. */ /** Whether one currency may be settled by hand right now. */
async isEnabled(currency: string | null | undefined): Promise<boolean> { async isEnabled(currency: string | null | undefined): Promise<boolean> {
const upper = currency?.toUpperCase(); const upper = currency?.toUpperCase();
if (upper !== "ETB" && upper !== "USD") return false; const field = FIELD_BY_CURRENCY[upper as ManualPaymentCurrency];
if (!field) return false;
const setting = await this.get(); const setting = await this.get();
return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled; return setting[field];
} }
/** Flip either toggle; an omitted field leaves that currency unchanged. */ /** Flip any toggle; an omitted field leaves that currency unchanged. */
async update( async update(
patch: { etbEnabled?: boolean; usdEnabled?: boolean }, patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean },
updatedById?: string | null, updatedById?: string | null,
): Promise<ManualPaymentSetting> { ): Promise<ManualPaymentSetting> {
const current = await this.get(); const current = await this.get();
await this.repository.update(current.id, { await this.repository.update(current.id, {
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }),
updatedById: updatedById ?? null, updatedById: updatedById ?? null,
}); });
const updated = await this.get(); const updated = await this.get();
this.logger.warn( this.logger.warn(
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`, `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`,
); );
return updated; return updated;
} }

View File

@@ -5,7 +5,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ /** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
type PaymentType = string type PaymentType = string
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill" type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill"
type Currency = "ETB" | "USD" type Currency = "ETB" | "USD" | "DJF"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@Entity({ schema: 'freight', name: 'payments' }) @Entity({ schema: 'freight', name: 'payments' })
@@ -25,7 +25,7 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] }) @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] })
method!: PaymentMethod method!: PaymentMethod
@Column({ type: "enum", enum: ["ETB", "USD"] }) @Column({ type: "enum", enum: ["ETB", "USD", "DJF"] })
currency!: Currency currency!: Currency
@Column({ type: "numeric" }) @Column({ type: "numeric" })

View File

@@ -466,6 +466,7 @@ export const CURRENCY_FILTER: ReportFilterDef = {
options: [ options: [
{ value: 'ETB', label: 'ETB' }, { value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' }, { value: 'USD', label: 'USD' },
{ value: 'DJF', label: 'DJF' },
], ],
}; };

View File

@@ -14,7 +14,7 @@ export class GenerateInvoiceDto {
@ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' }) @ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' })
@IsOptional() @IsOptional()
@IsIn(['ETB', 'USD']) @IsIn(['ETB', 'USD', 'DJF'])
billingCurrency?: 'ETB' | 'USD'; billingCurrency?: 'ETB' | 'USD';
} }

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { ExchangeService } from '@edr/api-common'; import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common';
import { NotificationAudience, NotificationType } from '@edr/types'; import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
@@ -430,8 +430,11 @@ export class WarehouseFeeService {
}; };
} }
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { private normalizeCurrency(currency?: string | null): CurrencyCode {
return currency === 'ETB' ? 'ETB' : 'USD'; const code = currency?.toUpperCase();
return (CURRENCY_CODES as readonly string[]).includes(code ?? '')
? (code as CurrencyCode)
: 'USD';
} }
private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise<number> { private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise<number> {