Merge pull request #1495 from Tria-plc/dj-franc

Dj franc
This commit is contained in:
Nathnael Wondisha
2026-09-04 16:39:19 +03:00
committed by GitHub
88 changed files with 1104 additions and 475 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

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* `exchange_settings` was a single-row table holding the USD→ETB fallback
* only. Restructures it to one row per currency so DJF (and any future
* currency) gets its own fallback rate, source and sync timestamp instead of
* a parallel column per currency.
*/
export class ExchangeSettingsPerCurrency3880000000000 implements MigrationInterface {
name = 'ExchangeSettingsPerCurrency3880000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.exchange_settings ADD COLUMN IF NOT EXISTS currency varchar(5);
`);
// The single pre-existing row was always the USD→ETB fallback.
await queryRunner.query(`
UPDATE freight.exchange_settings SET currency = 'USD' WHERE currency IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.exchange_settings ALTER COLUMN currency SET NOT NULL;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_exchange_settings_currency
ON freight.exchange_settings (currency) WHERE deleted_at IS NULL;
`);
// Seed the DJF row at the CBE-quoted DJF→ETB rate observed 2026-09-04, so
// pricing has a usable fallback before the first successful CBE fetch.
await queryRunner.query(`
INSERT INTO freight.exchange_settings (id, currency, fallback_rate, fallback_source, created_at, updated_at)
SELECT uuid_generate_v4(), 'DJF', 0.9203, 'AUTO', now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.exchange_settings WHERE currency = 'DJF');
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM freight.exchange_settings WHERE currency = 'DJF'`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_exchange_settings_currency`);
await queryRunner.query(`ALTER TABLE freight.exchange_settings ALTER COLUMN currency DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE freight.exchange_settings DROP COLUMN IF EXISTS currency`);
}
}

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

@@ -1,13 +1,14 @@
import { IsNumber, Max, Min } from "class-validator"; import { IsNumber, Min } from "class-validator";
/** /**
* Operator-set USD→ETB fallback. Bounded well outside any plausible published * Operator-set X→ETB fallback for one currency. The upper bound is enforced
* rate but far short of a fat-fingered magnitude error — this value multiplies * per currency in the controller (see `RATE_BOUNDS`) rather than here, since
* real invoice amounts whenever CBE is unreachable. * USD's plausible range (~100-300) and DJF's (~0.5-2) differ by two orders of
* magnitude — this value multiplies real invoice amounts whenever CBE is
* unreachable.
*/ */
export class UpdateExchangeSettingDto { export class UpdateExchangeSettingDto {
@IsNumber({ maxDecimalPlaces: 6 }) @IsNumber({ maxDecimalPlaces: 6 })
@Min(1) @Min(0.000001)
@Max(10_000)
fallbackRate!: number; fallbackRate!: number;
} }

View File

@@ -8,14 +8,18 @@ import { Column, Entity } from "typeorm";
export type ExchangeFallbackSource = "AUTO" | "MANUAL"; export type ExchangeFallbackSource = "AUTO" | "MANUAL";
/** /**
* Single-row table holding the USD→ETB fallback used when the CBE endpoint is * One row per foreign currency, holding the X→ETB fallback used when the CBE
* unreachable. The live CBE rate always wins; this is only consulted on * endpoint is unreachable for that currency. The live CBE rate always wins;
* failure, and is overwritten by every successful fetch so it tracks the last * this is only consulted on failure, and is overwritten by every successful
* known good rate. * fetch so it tracks the last known good rate.
*/ */
@Entity({ schema: "freight", name: "exchange_settings" }) @Entity({ schema: "freight", name: "exchange_settings" })
export class ExchangeSetting extends BaseEntity { export class ExchangeSetting extends BaseEntity {
/** USD→ETB rate served while the CBE endpoint is failing. */ /** The foreign currency this row's fallback applies to, e.g. `USD`, `DJF`. */
@Column({ name: "currency", type: "varchar", length: 5 })
currency!: string;
/** currency→ETB rate served while the CBE endpoint is failing for it. */
@Column({ @Column({
name: "fallback_rate", name: "fallback_rate",
type: "numeric", type: "numeric",

View File

@@ -6,7 +6,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service";
/** /**
* The app's single `ExchangeModule` registration shape: CBE endpoint config * The app's single `ExchangeModule` registration shape: CBE endpoint config
* from `app.cbeExchange`, with the DB-backed fallback wired in. * from `app.cbeExchange`, with the DB-backed per-currency fallback wired in.
* *
* `ExchangeModule` is registered per-feature-module (bookings, contracts, * `ExchangeModule` is registered per-feature-module (bookings, contracts,
* warehouses), so this keeps the three call sites identical rather than * warehouses), so this keeps the three call sites identical rather than
@@ -20,8 +20,8 @@ export function registerExchangeModule(): DynamicModule {
settings: ExchangeSettingsService, settings: ExchangeSettingsService,
): ExchangeOptions => ({ ): ExchangeOptions => ({
...(config.get<ExchangeOptions>("app.cbeExchange") ?? {}), ...(config.get<ExchangeOptions>("app.cbeExchange") ?? {}),
loadFallbackRate: () => settings.loadFallbackRate(), loadFallbackRate: (code) => settings.loadFallbackRate(code),
saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate), saveFallbackRate: (code, rate) => settings.saveFallbackRate(code, rate),
}), }),
}); });
} }

View File

@@ -0,0 +1,102 @@
import { CbeExchangeProvider, ExchangeService } from '@edr/api-common';
/**
* The CBE feed quotes every currency it publishes against ETB in one fetch —
* this is a fixture of that shape (trimmed to USD + DJF, the two the app
* actually reads). Verified live against the real feed on 2026-09-04.
*/
const CBE_FIXTURE = [
{
Date: '2026-09-04',
ExchangeRate: [
{
transactionalSelling: 163.4365,
transactionalBuying: 160.2319,
currency: { CurrencyCode: 'USD' },
},
{
transactionalSelling: 0.9203,
transactionalBuying: 0.9022,
currency: { CurrencyCode: 'DJF' },
},
// CBE publishes 0 for a currency it isn't quoting cash-selling that
// day — must not be picked up as a usable rate.
{ transactionalSelling: 0, currency: { CurrencyCode: 'ZZZ' } },
],
},
];
function mockFetchOnce(payload: unknown): jest.Mock {
const fn = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(payload),
});
(global as unknown as { fetch: typeof fetch }).fetch = fn as never;
return fn;
}
describe('CbeExchangeProvider — multi-currency', () => {
it('parses every quoted currency out of one fetch, not just USD', async () => {
const fetchMock = mockFetchOnce(CBE_FIXTURE);
const provider = new CbeExchangeProvider({});
const usdToEtb = await provider.getBaseRate({ from: 'USD', to: 'ETB' });
const djfToEtb = await provider.getBaseRate({ from: 'DJF', to: 'ETB' });
expect(usdToEtb).toBeCloseTo(163.4365);
expect(djfToEtb).toBeCloseTo(0.9203);
// Both rates came from the SAME cached fetch — one HTTP call serves
// every currency, not one per currency.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('skips a currency CBE reports as 0 (unquoted that day) — throws with no fallback configured', async () => {
mockFetchOnce(CBE_FIXTURE);
const provider = new CbeExchangeProvider({});
await expect(provider.getBaseRate({ from: 'ZZZ' as never, to: 'ETB' })).rejects.toThrow(
/No CBE rate available for ZZZ/,
);
});
it('only ever answers for X→ETB — everything else is derived upstream', async () => {
mockFetchOnce(CBE_FIXTURE);
const provider = new CbeExchangeProvider({});
await expect(provider.getBaseRate({ from: 'ETB', to: 'USD' })).resolves.toBeNull();
await expect(provider.getBaseRate({ from: 'USD', to: 'DJF' })).resolves.toBeNull();
});
});
describe('ExchangeService — USD↔DJF pivot', () => {
it('derives USD→DJF by pivoting through ETB, the providers base currency', async () => {
mockFetchOnce(CBE_FIXTURE);
const service = new ExchangeService({});
const rate = await service.getRate('USD', 'DJF');
// 163.4365 / 0.9203 — same arithmetic as converting via ETB by hand.
expect(rate).toBeCloseTo(163.4365 / 0.9203, 4);
expect(rate).toBeCloseTo(177.59, 1);
});
it('derives the inverse, DJF→USD, from the same pivot', async () => {
mockFetchOnce(CBE_FIXTURE);
const service = new ExchangeService({});
const rate = await service.getRate('DJF', 'USD');
expect(rate).toBeCloseTo(0.9203 / 163.4365, 6);
});
it('getRateTable resolves every supported currency into the target in one call', async () => {
mockFetchOnce(CBE_FIXTURE);
const service = new ExchangeService({});
const fx = await service.getRateTable('DJF');
expect(fx.DJF).toBe(1);
expect(fx.USD).toBeCloseTo(163.4365 / 0.9203, 4);
expect(fx.ETB).toBeCloseTo(1 / 0.9203, 4);
});
});

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Get, Patch } from "@nestjs/common"; import { BadRequestException, Body, Controller, Get, Param, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common"; import { CURRENCY_CODES, CurrencyCode, CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards"; import { BookingStaff } from "../../common/booking-guards";
@@ -8,6 +8,31 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto"; import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto";
import { ExchangeSettingsService } from "./exchange-settings.service"; import { ExchangeSettingsService } from "./exchange-settings.service";
/**
* Sane manual-rate ceiling per currency — bounded well outside any plausible
* published rate but far short of a fat-fingered magnitude error. USD trades
* in the hundreds (ETB per USD); DJF trades under 2 (ETB per DJF, since DJF
* itself is worth roughly 1/177th of a USD).
*/
const RATE_BOUNDS: Record<CurrencyCode, number> = {
ETB: 1,
USD: 10_000,
DJF: 100,
};
const FOREIGN_CURRENCIES = CURRENCY_CODES.filter((c) => c !== "ETB");
function assertSupportedCurrency(currency: string): (typeof FOREIGN_CURRENCIES)[number] {
const code = currency?.toUpperCase();
const match = FOREIGN_CURRENCIES.find((c) => c === code);
if (!match) {
throw new BadRequestException(
`Unsupported currency "${currency}" — must be one of ${FOREIGN_CURRENCIES.join(", ")}`,
);
}
return match;
}
@ApiTags("exchange-settings") @ApiTags("exchange-settings")
@ApiBearerAuth() @ApiBearerAuth()
@Controller("exchange-settings") @Controller("exchange-settings")
@@ -17,37 +42,51 @@ export class ExchangeSettingsController {
@Get() @Get()
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin]) @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin])
@ApiOperation({ @ApiOperation({
summary: "Current USD→ETB fallback rate and CBE feed health", summary: "Current X→ETB fallback rates and CBE feed health, one entry per currency",
}) })
async get() { async list() {
const setting = await this.service.get(); const settings = await this.service.list();
const status = this.service.getFeedStatus(); const byCurrency = new Map(settings.map((s) => [s.currency, s]));
return { return FOREIGN_CURRENCIES.map((code) => {
fallbackRate: setting.fallbackRate, const setting = byCurrency.get(code);
fallbackSource: setting.fallbackSource, return {
lastSyncedAt: setting.lastSyncedAt, currency: code,
updatedById: setting.updatedById, fallbackRate: setting?.fallbackRate ?? null,
feed: status, fallbackSource: setting?.fallbackSource ?? null,
}; lastSyncedAt: setting?.lastSyncedAt ?? null,
updatedById: setting?.updatedById ?? null,
feed: this.service.getFeedStatus(code),
};
});
} }
@Patch() @Patch(":currency")
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin]) @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin])
@ApiOperation({ @ApiOperation({
summary: summary:
"Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "Set a currency's X→ETB fallback by hand (used only while CBE is unreachable)",
}) })
async update( async update(
@Param("currency") currency: string,
@Body() dto: UpdateExchangeSettingDto, @Body() dto: UpdateExchangeSettingDto,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
) { ) {
const code = assertSupportedCurrency(currency);
if (dto.fallbackRate > RATE_BOUNDS[code]) {
throw new BadRequestException(
`Fallback rate ${dto.fallbackRate} is outside the accepted range for ${code} (max ${RATE_BOUNDS[code]})`,
);
}
const updated = await this.service.setManualRate( const updated = await this.service.setManualRate(
code,
dto.fallbackRate, dto.fallbackRate,
user?.id ?? null, user?.id ?? null,
); );
return { return {
currency: updated.currency,
fallbackRate: updated.fallbackRate, fallbackRate: updated.fallbackRate,
fallbackSource: updated.fallbackSource, fallbackSource: updated.fallbackSource,
lastSyncedAt: updated.lastSyncedAt, lastSyncedAt: updated.lastSyncedAt,

View File

@@ -1,16 +1,22 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { CurrencyCode } from "@edr/api-common";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
import { ExchangeSetting } from "./entities/exchange-setting.entity"; import { ExchangeSetting } from "./entities/exchange-setting.entity";
/** /**
* Rate used before the row exists and before the first successful CBE fetch — * Rate used before a currency's row exists and before its first successful
* the CBE USD transactional selling rate on 2026-08-04. * CBE fetch. USD is the CBE transactional selling rate on 2026-08-04; DJF is
* the CBE transactional selling rate on 2026-09-04 (CBE started being read
* for DJF then).
*/ */
const SEED_FALLBACK_RATE = 162.4165; const SEED_FALLBACK_RATES: Partial<Record<CurrencyCode, number>> = {
USD: 162.4165,
DJF: 0.9203,
};
/** Health of the CBE feed, as surfaced to the backoffice. */ /** Health of the CBE feed for one currency, as surfaced to the backoffice. */
export interface ExchangeFeedStatus { export interface ExchangeFeedStatus {
/** Rate most recently observed, whatever its source. */ /** Rate most recently observed, whatever its source. */
rate: number | null; rate: number | null;
@@ -22,9 +28,17 @@ export interface ExchangeFeedStatus {
lastError: string | null; lastError: string | null;
} }
const EMPTY_FEED_STATUS: ExchangeFeedStatus = {
rate: null,
source: null,
lastSuccessAt: null,
lastError: null,
};
/** /**
* Owns the single `exchange_settings` row: the USD→ETB fallback used when the * Owns the `exchange_settings` rows — one per foreign currency (USD, DJF) —
* CBE endpoint is unreachable. * each holding the currency→ETB fallback used when the CBE endpoint is
* unreachable for it.
* *
* The live CBE rate is always preferred. This value is only read on failure, * The live CBE rate is always preferred. This value is only read on failure,
* and every successful fetch overwrites it, so it tracks the last known good * and every successful fetch overwrites it, so it tracks the last known good
@@ -35,107 +49,113 @@ export class ExchangeSettingsService {
private readonly logger = new Logger(ExchangeSettingsService.name); private readonly logger = new Logger(ExchangeSettingsService.name);
/** /**
* Feed health, recorded from the exchange provider's callbacks rather than * Feed health per currency, recorded from the exchange provider's
* read off an injected `ExchangeService`. The provider is registered several * callbacks rather than read off an injected `ExchangeService`. The
* times (bookings, contracts, warehouses), so no single instance sees every * provider is registered several times (bookings, contracts, warehouses),
* fetch — and injecting one here would be circular, since those * so no single instance sees every fetch — and injecting one here would be
* registrations inject *this* service. * circular, since those registrations inject *this* service.
*/ */
private feed: ExchangeFeedStatus = { private feed = new Map<string, ExchangeFeedStatus>();
rate: null,
source: null,
lastSuccessAt: null,
lastError: null,
};
constructor( constructor(
@InjectRepository(ExchangeSetting) @InjectRepository(ExchangeSetting)
private readonly repository: Repository<ExchangeSetting>, private readonly repository: Repository<ExchangeSetting>,
) {} ) {}
/** Health of the CBE feed as last observed by any provider instance. */ /** Health of the CBE feed for `code` as last observed by any provider instance. */
getFeedStatus(): ExchangeFeedStatus { getFeedStatus(code: CurrencyCode): ExchangeFeedStatus {
return { ...this.feed }; return { ...(this.feed.get(code) ?? EMPTY_FEED_STATUS) };
} }
/** The settings row, created at the seed rate on first access. */ /** The settings row for `code`, created at the seed rate on first access. */
async get(): Promise<ExchangeSetting> { async get(code: CurrencyCode): Promise<ExchangeSetting> {
const existing = await this.repository.findOne({ where: {} }); const existing = await this.repository.findOne({ where: { currency: code } });
if (existing) return existing; if (existing) return existing;
return this.repository.save( return this.repository.save(
this.repository.create({ this.repository.create({
fallbackRate: SEED_FALLBACK_RATE, currency: code,
fallbackRate: SEED_FALLBACK_RATES[code] ?? 1,
fallbackSource: "AUTO", fallbackSource: "AUTO",
lastSyncedAt: null, lastSyncedAt: null,
}), }),
); );
} }
/** Every currency's settings row, for the backoffice settings list. */
async list(): Promise<ExchangeSetting[]> {
return this.repository.find({ order: { currency: "ASC" } });
}
/** /**
* Reads the stored fallback for the exchange provider. Returns `null` on any * Reads the stored fallback for `code`, for the exchange provider. Returns
* failure so the provider falls through to its own static default rather * `null` on any failure so the provider falls through to its own static
* than propagating a database error into a pricing call. * default rather than propagating a database error into a pricing call.
*/ */
async loadFallbackRate(): Promise<number | null> { async loadFallbackRate(code: CurrencyCode): Promise<number | null> {
// Only reached when the live fetch failed, so this call is itself the // Only reached when the live fetch failed, so this call is itself the
// signal that the feed is down. // signal that the feed is down for this currency.
try { try {
const { fallbackRate } = await this.get(); const { fallbackRate } = await this.get(code);
const usable = Number.isFinite(fallbackRate) && fallbackRate > 0; const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
this.feed = { const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS;
...this.feed, this.feed.set(code, {
rate: usable ? fallbackRate : this.feed.rate, ...previous,
rate: usable ? fallbackRate : previous.rate,
source: "stored", source: "stored",
lastError: this.feed.lastError ?? "CBE endpoint unreachable", lastError: previous.lastError ?? "CBE endpoint unreachable",
}; });
return usable ? fallbackRate : null; return usable ? fallbackRate : null;
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
this.feed = { ...this.feed, source: "stored", lastError: message }; const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS;
this.logger.warn(`Could not read stored exchange fallback: ${message}`); this.feed.set(code, { ...previous, source: "stored", lastError: message });
this.logger.warn(
`Could not read stored exchange fallback for ${code}: ${message}`,
);
return null; return null;
} }
} }
/** /**
* Records a freshly fetched live rate as the new fallback. Marked `AUTO`, * Records a freshly fetched live rate as the new fallback for `code`.
* overwriting a manual entry — a manual rate is a stopgap for while CBE is * Marked `AUTO`, overwriting a manual entry — a manual rate is a stopgap
* down, so a working CBE feed takes precedence again. * for while CBE is down, so a working CBE feed takes precedence again.
*/ */
async saveFallbackRate(rate: number): Promise<void> { async saveFallbackRate(code: CurrencyCode, rate: number): Promise<void> {
// Only called after a successful fetch, so the feed is confirmed healthy. // Only called after a successful fetch, so the feed is confirmed healthy.
this.feed = { this.feed.set(code, {
rate, rate,
source: "live", source: "live",
lastSuccessAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(),
lastError: null, lastError: null,
}; });
const current = await this.get(); const current = await this.get(code);
await this.repository.update(current.id, { await this.repository.update(current.id, {
fallbackRate: rate, fallbackRate: rate,
fallbackSource: "AUTO", fallbackSource: "AUTO",
lastSyncedAt: new Date(), lastSyncedAt: new Date(),
updatedById: null, updatedById: null,
}); });
this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`); this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/${code}`);
} }
/** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */ /** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */
async setManualRate( async setManualRate(
code: CurrencyCode,
rate: number, rate: number,
updatedById?: string | null, updatedById?: string | null,
): Promise<ExchangeSetting> { ): Promise<ExchangeSetting> {
const current = await this.get(); const current = await this.get(code);
await this.repository.update(current.id, { await this.repository.update(current.id, {
fallbackRate: rate, fallbackRate: rate,
fallbackSource: "MANUAL", fallbackSource: "MANUAL",
updatedById: updatedById ?? null, updatedById: updatedById ?? null,
}); });
this.logger.warn( this.logger.warn(
`Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`, `Exchange fallback for ${code} set manually to ${rate} ETB/${code} by ${updatedById ?? "unknown user"}`,
); );
return this.get(); return this.get(code);
} }
} }

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

@@ -36,6 +36,7 @@ export class OverviewCustomerKpisDto {
export class OverviewBillingKpisDto { export class OverviewBillingKpisDto {
@ApiProperty() revenueMtdEtb!: number; @ApiProperty() revenueMtdEtb!: number;
@ApiProperty() revenueMtdUsd!: number; @ApiProperty() revenueMtdUsd!: number;
@ApiProperty() revenueMtdDjf!: number;
@ApiProperty() pendingPayments!: number; @ApiProperty() pendingPayments!: number;
@ApiProperty() successfulPaymentsMtd!: number; @ApiProperty() successfulPaymentsMtd!: number;
} }
@@ -84,6 +85,7 @@ export class OverviewPaymentTrendPointDto {
@ApiProperty({ example: '2026-06-01' }) date!: string; @ApiProperty({ example: '2026-06-01' }) date!: string;
@ApiProperty() amountEtb!: number; @ApiProperty() amountEtb!: number;
@ApiProperty() amountUsd!: number; @ApiProperty() amountUsd!: number;
@ApiProperty() amountDjf!: number;
} }
export class OverviewRecentBookingDto { export class OverviewRecentBookingDto {
@@ -113,6 +115,7 @@ export class OverviewPeriodTotalsDto {
@ApiProperty() bookingsCreated!: number; @ApiProperty() bookingsCreated!: number;
@ApiProperty() revenueEtb!: number; @ApiProperty() revenueEtb!: number;
@ApiProperty() revenueUsd!: number; @ApiProperty() revenueUsd!: number;
@ApiProperty() revenueDjf!: number;
@ApiProperty() tons!: number; @ApiProperty() tons!: number;
} }
@@ -120,6 +123,7 @@ export class OverviewRevenueSliceDto {
@ApiProperty() label!: string; @ApiProperty() label!: string;
@ApiProperty() amountEtb!: number; @ApiProperty() amountEtb!: number;
@ApiProperty() amountUsd!: number; @ApiProperty() amountUsd!: number;
@ApiProperty() amountDjf!: number;
} }
export class OverviewTonsTrendPointDto { export class OverviewTonsTrendPointDto {
@@ -132,6 +136,7 @@ export class OverviewRevenueFlowDto {
@ApiProperty() freightType!: string; @ApiProperty() freightType!: string;
@ApiProperty() amountEtb!: number; @ApiProperty() amountEtb!: number;
@ApiProperty() amountUsd!: number; @ApiProperty() amountUsd!: number;
@ApiProperty() amountDjf!: number;
} }
export class OverviewHeatmapCellDto { export class OverviewHeatmapCellDto {

View File

@@ -265,6 +265,7 @@ export class OverviewRepository {
async getBillingKpis(dirs?: string[]): Promise<{ async getBillingKpis(dirs?: string[]): Promise<{
revenueMtdEtb: number; revenueMtdEtb: number;
revenueMtdUsd: number; revenueMtdUsd: number;
revenueMtdDjf: number;
pendingPayments: number; pendingPayments: number;
successfulPaymentsMtd: number; successfulPaymentsMtd: number;
}> { }> {
@@ -279,6 +280,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"revenueMtdUsd", "revenueMtdUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"revenueMtdDjf",
)
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
@@ -298,6 +303,7 @@ export class OverviewRepository {
return { return {
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0),
pendingPayments, pendingPayments,
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
}; };
@@ -370,7 +376,7 @@ export class OverviewRepository {
days: number, days: number,
dirs?: string[], dirs?: string[],
offsetDays = 0, offsetDays = 0,
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { ): Promise<{ date: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs); const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository const rows = await this.paymentRepository
.createQueryBuilder("payment") .createQueryBuilder("payment")
@@ -386,6 +392,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd", "amountUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`, `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`,
@@ -394,12 +404,13 @@ export class OverviewRepository {
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); .getRawMany<{ date: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
return rows.map((row) => ({ return rows.map((row) => ({
date: row.date, date: row.date,
amountEtb: Number(row.amountEtb), amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd), amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
})); }));
} }
@@ -510,7 +521,7 @@ export class OverviewRepository {
async getPaymentsByMethod( async getPaymentsByMethod(
dirs?: string[], dirs?: string[],
): Promise< ): Promise<
{ method: string; count: number; amountEtb: number; amountUsd: number }[] { method: string; count: number; amountEtb: number; amountUsd: number; amountDjf: number }[]
> { > {
const scope = bookingRefScopeSql("payment.ref_id", dirs); const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository const rows = await this.paymentRepository
@@ -525,6 +536,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
"amountUsd", "amountUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`,
"amountDjf",
)
.where(scope.sql, scope.params) .where(scope.sql, scope.params)
.groupBy("payment.method") .groupBy("payment.method")
.orderBy("count", "DESC") .orderBy("count", "DESC")
@@ -533,6 +548,7 @@ export class OverviewRepository {
count: string; count: string;
amountEtb: string; amountEtb: string;
amountUsd: string; amountUsd: string;
amountDjf: string;
}>(); }>();
return rows.map((row) => ({ return rows.map((row) => ({
@@ -540,6 +556,7 @@ export class OverviewRepository {
count: Number(row.count), count: Number(row.count),
amountEtb: Number(row.amountEtb), amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd), amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
})); }));
} }
@@ -580,6 +597,7 @@ export class OverviewRepository {
bookingsCreated: number; bookingsCreated: number;
revenueEtb: number; revenueEtb: number;
revenueUsd: number; revenueUsd: number;
revenueDjf: number;
tons: number; tons: number;
}> { }> {
const bookingScope = directionScopeSql("booking.trade_direction", dirs); const bookingScope = directionScopeSql("booking.trade_direction", dirs);
@@ -605,13 +623,17 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"revenueUsd", "revenueUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"revenueDjf",
)
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
windowSql("COALESCE(payment.paid_at, payment.created_at)"), windowSql("COALESCE(payment.paid_at, payment.created_at)"),
{ days, offsetDays }, { days, offsetDays },
) )
.andWhere(paymentScope.sql, paymentScope.params) .andWhere(paymentScope.sql, paymentScope.params)
.getRawOne<{ revenueEtb: string; revenueUsd: string }>(), .getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(),
this.cargoRepository this.cargoRepository
.createQueryBuilder("cargo") .createQueryBuilder("cargo")
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id") .leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
@@ -626,6 +648,7 @@ export class OverviewRepository {
bookingsCreated, bookingsCreated,
revenueEtb: Number(revenueRow?.revenueEtb ?? 0), revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
revenueUsd: Number(revenueRow?.revenueUsd ?? 0), revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
revenueDjf: Number(revenueRow?.revenueDjf ?? 0),
tons: Number(tonsRow?.tons ?? 0), tons: Number(tonsRow?.tons ?? 0),
}; };
} }
@@ -634,7 +657,7 @@ export class OverviewRepository {
async getRevenueByDirection( async getRevenueByDirection(
days: number, days: number,
dirs?: string[], dirs?: string[],
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs); const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository const rows = await this.paymentRepository
.createQueryBuilder("payment") .createQueryBuilder("payment")
@@ -648,6 +671,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd", "amountUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
@@ -656,12 +683,13 @@ export class OverviewRepository {
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.andWhere("booking.trade_direction IS NOT NULL") .andWhere("booking.trade_direction IS NOT NULL")
.groupBy("booking.trade_direction") .groupBy("booking.trade_direction")
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
return rows.map((row) => ({ return rows.map((row) => ({
label: row.label, label: row.label,
amountEtb: Number(row.amountEtb), amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd), amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
})); }));
} }
@@ -669,7 +697,7 @@ export class OverviewRepository {
async getRevenueByFreightType( async getRevenueByFreightType(
days: number, days: number,
dirs?: string[], dirs?: string[],
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs); const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository const rows = await this.paymentRepository
.createQueryBuilder("payment") .createQueryBuilder("payment")
@@ -683,6 +711,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd", "amountUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
@@ -691,12 +723,13 @@ export class OverviewRepository {
.andWhere(scope.sql, scope.params) .andWhere(scope.sql, scope.params)
.andWhere("booking.freight_type IS NOT NULL") .andWhere("booking.freight_type IS NOT NULL")
.groupBy("booking.freight_type") .groupBy("booking.freight_type")
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
return rows.map((row) => ({ return rows.map((row) => ({
label: row.label, label: row.label,
amountEtb: Number(row.amountEtb), amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd), amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
})); }));
} }
@@ -734,6 +767,7 @@ export class OverviewRepository {
freightType: string; freightType: string;
amountEtb: number; amountEtb: number;
amountUsd: number; amountUsd: number;
amountDjf: number;
}[] }[]
> { > {
const scope = bookingRefScopeSql("payment.ref_id", dirs); const scope = bookingRefScopeSql("payment.ref_id", dirs);
@@ -750,6 +784,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd", "amountUsd",
) )
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" }) .where("payment.status = :status", { status: "success" })
.andWhere( .andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
@@ -765,6 +803,7 @@ export class OverviewRepository {
freightType: string; freightType: string;
amountEtb: string; amountEtb: string;
amountUsd: string; amountUsd: string;
amountDjf: string;
}>(); }>();
return rows.map((row) => ({ return rows.map((row) => ({
@@ -772,6 +811,7 @@ export class OverviewRepository {
freightType: row.freightType, freightType: row.freightType,
amountEtb: Number(row.amountEtb), amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd), amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
})); }));
} }

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> {

View File

@@ -36,7 +36,7 @@ import { downloadBookingFile, fetchViewableFile } from "@/services/files.service
import { formatDate, formatDateTime } from "@/lib/format"; import { formatDate, formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor"; import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"]; const CURRENCIES = ["ETB", "USD", "DJF"];
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = { const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
DRAFT: { label: "Draft", color: "gray" }, DRAFT: { label: "Draft", color: "gray" },

View File

@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query"; import { useQueries, useQuery } from "@tanstack/react-query";
import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
import { Coins, Truck } from "lucide-react"; import { Coins, Truck } from "lucide-react";
import { currencyDecimals } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
@@ -12,8 +13,8 @@ import { MetricTile } from "./MetricTile";
const money = (amount: number, currency: string) => const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString(undefined, { `${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2, minimumFractionDigits: currencyDecimals(currency),
maximumFractionDigits: 2, maximumFractionDigits: currencyDecimals(currency),
})} ${currency === "ETB" ? "Birr (ETB)" : currency}`; })} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
/** /**

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { OperationDatePicker } from "@edr/ui-common"; import { OperationDatePicker, currencyDecimals } from "@edr/ui-common";
import { api } from "@/auth/http"; import { api } from "@/auth/http";
import { api as rpc } from "@/services/api"; import { api as rpc } from "@/services/api";
@@ -223,7 +223,7 @@ export function RebookWagonCancellationModal({
<Text size="sm"> <Text size="sm">
{cancellation.booking?.reference ?? cancellation.bookingId} ·{" "} {cancellation.booking?.reference ?? cancellation.bookingId} ·{" "}
{cancellation.wagonsCancelled} wagon(s) · credit{" "} {cancellation.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, currencyDecimals(cancellation.feeCurrency))}
</Text> </Text>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
Shipment day Shipment day

View File

@@ -8,6 +8,7 @@ import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { formatDate, formatMoney } from "@/lib/format"; import { formatDate, formatMoney } from "@/lib/format";
import { currencyDecimals } from "@edr/ui-common";
import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal"; import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal";
import { import {
canRebookWagonCancellations, canRebookWagonCancellations,
@@ -73,7 +74,7 @@ export function WagonCancellationCreditCard({
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap">
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{Number(r.wagonsCancelled)} wagon(s) · credit{" "} {Number(r.wagonsCancelled)} wagon(s) · credit{" "}
{formatMoney(Number(r.creditAmount), r.feeCurrency, 2)} {formatMoney(Number(r.creditAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))}
</Text> </Text>
<Badge color={chip.color} variant="light" size="sm" radius="md"> <Badge color={chip.color} variant="light" size="sm" radius="md">
{chip.label} {chip.label}
@@ -83,7 +84,7 @@ export function WagonCancellationCreditCard({
Cancelled {formatDate(r.createdAt)} Cancelled {formatDate(r.createdAt)}
{r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""} {r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""}
{Number(r.feeAmount) > 0 {Number(r.feeAmount) > 0
? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, 2)}${ ? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))}${
r.feePaidAt ? " paid" : " unpaid" r.feePaidAt ? " paid" : " unpaid"
}` }`
: ""} : ""}

View File

@@ -39,7 +39,7 @@ import {
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor"; import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"]; const CURRENCIES = ["ETB", "USD", "DJF"];
const STATUS_META: Record< const STATUS_META: Record<
Freight.ClearanceChargeStatus, Freight.ClearanceChargeStatus,

View File

@@ -345,7 +345,7 @@ export default function GlCreateBookingForm() {
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
// IMPORT bookings pick ETB or USD — starts empty so the choice is // IMPORT bookings pick ETB or USD — starts empty so the choice is
// deliberate (required before pricing). Everything else is forced to ETB. // deliberate (required before pricing). Everything else is forced to ETB.
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">(""); const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
// What the containers carry — captured per booking (moved off the contract). // What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState(""); const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]); const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
@@ -1144,7 +1144,7 @@ export default function GlCreateBookingForm() {
]); ]);
// Only IMPORT actually chooses — the rest bill ETB regardless of the state. // Only IMPORT actually chooses — the rest bill ETB regardless of the state.
const effectiveCurrency: "USD" | "ETB" = const effectiveCurrency: "USD" | "ETB" | "DJF" =
isImport && paymentCurrency ? paymentCurrency : "ETB"; isImport && paymentCurrency ? paymentCurrency : "ETB";
const currencyError = const currencyError =
isImport && !paymentCurrency isImport && !paymentCurrency
@@ -2351,7 +2351,7 @@ export default function GlCreateBookingForm() {
{requestCurrencyLocked {requestCurrencyLocked
? "The customer chose the billing currency on the shipment request — it cannot be changed." ? "The customer chose the billing currency on the shipment request — it cannot be changed."
: isImport : isImport
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." ? "Import shipments may be invoiced in ETB, USD or DJF. USD is paid by bank transfer, not online."
: "Shipments are invoiced in ETB."} : "Shipments are invoiced in ETB."}
</Text> </Text>
<CurrencySelector <CurrencySelector
@@ -2359,6 +2359,7 @@ export default function GlCreateBookingForm() {
onChange={setPaymentCurrency} onChange={setPaymentCurrency}
disabled={!isImport || requestCurrencyLocked} disabled={!isImport || requestCurrencyLocked}
allowUsd={isImport} allowUsd={isImport}
allowDjf={isImport}
error={currencyError} error={currencyError}
/> />
</Box> </Box>

View File

@@ -1554,7 +1554,7 @@ function SecondDutyStep({
/> />
<Select <Select
label="Currency" label="Currency"
data={["ETB", "USD"]} data={["ETB", "USD", "DJF"]}
value={currency} value={currency}
onChange={(v) => setCurrency(v ?? "ETB")} onChange={(v) => setCurrency(v ?? "ETB")}
size="sm" size="sm"
@@ -1944,7 +1944,7 @@ function DraftDeclarationStep({
/> />
<Select <Select
label="Currency" label="Currency"
data={["ETB", "USD"]} data={["ETB", "USD", "DJF"]}
value={currency} value={currency}
onChange={(v) => setCurrency(v ?? "ETB")} onChange={(v) => setCurrency(v ?? "ETB")}
size="sm" size="sm"
@@ -2063,7 +2063,7 @@ function DutyStep({
/> />
<Select <Select
label="Currency" label="Currency"
data={["ETB", "USD"]} data={["ETB", "USD", "DJF"]}
value={currency} value={currency}
onChange={(v) => setCurrency(v ?? "ETB")} onChange={(v) => setCurrency(v ?? "ETB")}
size="sm" size="sm"

View File

@@ -54,7 +54,7 @@ export function AdviseDutyCard({
/> />
<Select <Select
label="Currency" label="Currency"
data={["ETB", "USD"]} data={["ETB", "USD", "DJF"]}
value={currency} value={currency}
onChange={(v) => setCurrency(v ?? "ETB")} onChange={(v) => setCurrency(v ?? "ETB")}
size="sm" size="sm"

View File

@@ -18,7 +18,7 @@ function formatDateLabel(date: string) {
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} }
function formatAmount(value: number, currency: "ETB" | "USD") { function formatAmount(value: number, currency: string) {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency, currency,

View File

@@ -9,10 +9,9 @@ import { SummaryCard } from "./summary/SummaryCard";
function formatAmount(amount: number | null, currency: string | null) { function formatAmount(amount: number | null, currency: string | null) {
if (amount == null) return "—"; if (amount == null) return "—";
const code = currency === "USD" ? "USD" : "ETB";
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: code, currency: currency || "ETB",
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(amount); }).format(amount);
} }

View File

@@ -4,7 +4,7 @@ import { KpiStrip, type KpiItem } from "@/components/page";
import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview"; import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview";
import { CountUp } from "./CountUp"; import { CountUp } from "./CountUp";
function formatCurrency(amount: number, currency: "ETB" | "USD") { function formatCurrency(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency, currency,
@@ -13,7 +13,7 @@ function formatCurrency(amount: number, currency: "ETB" | "USD") {
} }
/** Compact form ("ETB 58.6M") — the hero cell is too narrow for nine digits. */ /** Compact form ("ETB 58.6M") — the hero cell is too narrow for nine digits. */
function formatCompactCurrency(amount: number, currency: "ETB" | "USD") { function formatCompactCurrency(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency, currency,

View File

@@ -18,7 +18,7 @@ import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { OverviewPaymentChart } from "../OverviewPaymentChart"; import { OverviewPaymentChart } from "../OverviewPaymentChart";
import { overviewChartColors } from "../overview.styles"; import { overviewChartColors } from "../overview.styles";
function formatCurrency(amount: number, currency: "ETB" | "USD") { function formatCurrency(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency, currency,

View File

@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core'; import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react'; import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
import { currencyDecimals } from '@edr/ui-common';
import { useAccrualDashboard } from '@/hooks/useWarehouses'; import { useAccrualDashboard } from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service'; import { warehouseService } from '@/services/warehouse.service';
@@ -15,7 +16,8 @@ const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
}; };
function money(amount: number, currency: string): string { function money(amount: number, currency: string): string {
return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`; const decimals = currencyDecimals(currency);
return `${amount.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })} ${currency}`;
} }
function freeDaysLabel(row: AccrualDashboardRow): string { function freeDaysLabel(row: AccrualDashboardRow): string {

View File

@@ -113,7 +113,7 @@ function Row({ label, value }: { label: string; value: string }) {
/** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */ /** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) { export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD'>('USD'); const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD' | 'DJF'>('USD');
const enabledId = opened ? inventoryId ?? undefined : undefined; const enabledId = opened ? inventoryId ?? undefined : undefined;
const { data, isLoading } = useQuery( const { data, isLoading } = useQuery(
api.warehouses.feePreview.queryOptions({ api.warehouses.feePreview.queryOptions({
@@ -211,10 +211,11 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
<SegmentedControl <SegmentedControl
size="xs" size="xs"
value={billingCurrency} value={billingCurrency}
onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD')} onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD' | 'DJF')}
data={[ data={[
{ value: 'USD', label: 'USD' }, { value: 'USD', label: 'USD' },
{ value: 'ETB', label: 'Birr' }, { value: 'ETB', label: 'Birr' },
{ value: 'DJF', label: 'DJF' },
]} ]}
disabled={Boolean(activeInvoice)} disabled={Boolean(activeInvoice)}
/> />

View File

@@ -7,10 +7,11 @@ import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["exchangeSettings"]; const QUERY_KEY = ["exchangeSettings"];
/** One row per foreign currency (USD, DJF, …) — see `exchangeSettingsService.list`. */
export const useExchangeSettingsQuery = () => export const useExchangeSettingsQuery = () =>
useQuery({ useQuery({
queryKey: QUERY_KEY, queryKey: QUERY_KEY,
queryFn: () => exchangeSettingsService.get(), queryFn: () => exchangeSettingsService.list(),
// Feed health is only interesting while it is being looked at. // Feed health is only interesting while it is being looked at.
staleTime: 30_000, staleTime: 30_000,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
@@ -22,7 +23,8 @@ export const useSetExchangeFallbackRate = () => {
const { handleError } = useErrorHandler(t); const { handleError } = useErrorHandler(t);
return useMutation({ return useMutation({
mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate), mutationFn: ({ currency, rate }: { currency: string; rate: number }) =>
exchangeSettingsService.setFallbackRate(currency, rate),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY }); queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success( toast.success(

View File

@@ -24,7 +24,9 @@ export const useUpdateManualPaymentSettings = () => {
return useMutation({ return useMutation({
mutationFn: ( mutationFn: (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>, patch: Partial<
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
>,
) => manualPaymentSettingsService.update(patch), ) => manualPaymentSettingsService.update(patch),
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data); queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);

View File

@@ -221,7 +221,7 @@ export function useOnTimeDispatch() {
} }
/** Live per-item fee accrual (storage/demurrage) with alerts. */ /** Live per-item fee accrual (storage/demurrage) with alerts. */
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') { export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD' | 'DJF') {
return useQuery({ return useQuery({
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'], queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data), queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
@@ -598,7 +598,7 @@ export const useUpdateFeeRule = () =>
export const useDeleteFeeRule = () => export const useDeleteFeeRule = () =>
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']); useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']);
export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' = 'USD') { export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' | 'DJF' = 'USD') {
return useQuery({ return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency], queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency],
queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data), queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data),
@@ -649,7 +649,7 @@ export function useGenerateInvoice() {
}: { }: {
inventoryId: string; inventoryId: string;
confirmZero?: boolean; confirmZero?: boolean;
billingCurrency?: 'ETB' | 'USD'; billingCurrency?: 'ETB' | 'USD' | 'DJF';
}) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data), }) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data),
onSuccess, onSuccess,
}); });

View File

@@ -72,6 +72,7 @@ import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsT
import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { formatDateTime, formatMoney } from "@/lib/format"; import { formatDateTime, formatMoney } from "@/lib/format";
import { currencyDecimals } from "@edr/ui-common";
import { cargoTonsAndItems } from "@/utils/cargoWeight"; import { cargoTonsAndItems } from "@/utils/cargoWeight";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { import {
@@ -254,7 +255,7 @@ export default function BookingRequestDetailPage() {
const kpis: KpiItem[] = [ const kpis: KpiItem[] = [
{ {
label: "Total value", label: "Total value",
value: formatMoney(amount, booking.paymentCurrency, 2), value: formatMoney(amount, booking.paymentCurrency, currencyDecimals(booking.paymentCurrency)),
hint: booking.paymentStatus, hint: booking.paymentStatus,
icon: Wallet, icon: Wallet,
color: "edr-green", color: "edr-green",

View File

@@ -25,6 +25,7 @@ import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page"; import { PageContainer, PageHeader } from "@/components/page";
import { toDayString } from "@/hooks/useListControls"; import { toDayString } from "@/hooks/useListControls";
import { formatDate, formatMoney } from "@/lib/format"; import { formatDate, formatMoney } from "@/lib/format";
import { currencyDecimals } from "@edr/ui-common";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { import {
DataTable, DataTable,
@@ -166,7 +167,7 @@ export default function WagonCancellationsPage() {
header: () => <span>Fee</span>, header: () => <span>Fee</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}> <Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatMoney(row.original.feeAmount, row.original.feeCurrency, 2)} {formatMoney(row.original.feeAmount, row.original.feeCurrency, currencyDecimals(row.original.feeCurrency))}
</Text> </Text>
), ),
}, },
@@ -175,7 +176,7 @@ export default function WagonCancellationsPage() {
header: () => <span>Credit</span>, header: () => <span>Credit</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}> <Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatMoney(row.original.creditAmount, row.original.feeCurrency, 2)} {formatMoney(row.original.creditAmount, row.original.feeCurrency, currencyDecimals(row.original.feeCurrency))}
</Text> </Text>
), ),
}, },
@@ -347,7 +348,7 @@ export default function WagonCancellationsPage() {
<Text size="sm"> <Text size="sm">
{voiding.booking?.reference ?? voiding.bookingId} ·{" "} {voiding.booking?.reference ?? voiding.bookingId} ·{" "}
{voiding.wagonsCancelled} wagon(s) · fee{" "} {voiding.wagonsCancelled} wagon(s) · fee{" "}
{formatMoney(voiding.feeAmount, voiding.feeCurrency, 2)} {formatMoney(voiding.feeAmount, voiding.feeCurrency, currencyDecimals(voiding.feeCurrency))}
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
The pending fee is dropped and the wagons stay on the booking. The pending fee is dropped and the wagons stay on the booking.

View File

@@ -82,6 +82,7 @@ const CONTRACT_KIND_OPTIONS = [
const CURRENCY_OPTIONS = [ const CURRENCY_OPTIONS = [
{ value: "ETB", label: "ETB" }, { value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" }, { value: "USD", label: "USD" },
{ value: "DJF", label: "DJF" },
]; ];
/** value = `${sortBy}:${sortOrder}` for the sort Select. */ /** value = `${sortBy}:${sortOrder}` for the sort Select. */

View File

@@ -279,6 +279,7 @@ const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
<Group gap="lg" mt={6}> <Group gap="lg" mt={6}>
<Radio value="ETB" label="ETB" /> <Radio value="ETB" label="ETB" />
<Radio value="USD" label="USD" /> <Radio value="USD" label="USD" />
<Radio value="DJF" label="DJF" />
</Group> </Group>
</Radio.Group> </Radio.Group>
</SimpleGrid> </SimpleGrid>

View File

@@ -66,6 +66,7 @@ const INVOICE_FILTER_DEFS: FilterDef[] = [
options: [ options: [
{ value: "ETB", label: "ETB" }, { value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" }, { value: "USD", label: "USD" },
{ value: "DJF", label: "DJF" },
], ],
}, },
{ {
@@ -235,8 +236,23 @@ export default function InvoicesPanel() {
const { data: exchangeSettings } = useExchangeSettingsQuery(); const { data: exchangeSettings } = useExchangeSettingsQuery();
const etbCollected = summary?.ETB ?? 0; const etbCollected = summary?.ETB ?? 0;
const usdCollected = summary?.USD ?? 0; const usdCollected = summary?.USD ?? 0;
const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate; const djfCollected = summary?.DJF ?? 0;
const etbFromUsd = rate ? usdCollected * rate : null; const rateFor = (currency: string) => {
const setting = exchangeSettings?.find((s) => s.currency === currency);
return setting?.feed?.rate ?? setting?.fallbackRate ?? null;
};
const usdRate = rateFor("USD");
const djfRate = rateFor("DJF");
const etbFromUsd = usdRate ? usdCollected * usdRate : null;
const etbFromDjf = djfRate ? djfCollected * djfRate : null;
const totalEtb = etbCollected + (etbFromUsd ?? 0) + (etbFromDjf ?? 0);
const totalHint = [
"ETB",
etbFromUsd !== null ? "USD" : null,
etbFromDjf !== null ? "DJF" : null,
]
.filter(Boolean)
.join(" + ");
const columns: ColumnDef<Invoice>[] = useMemo( const columns: ColumnDef<Invoice>[] = useMemo(
() => [ () => [
@@ -356,8 +372,8 @@ export default function InvoicesPanel() {
items={[ items={[
{ {
label: "Total collected", label: "Total collected",
hint: etbFromUsd !== null ? "ETB + USD" : "ETB only", hint: totalHint,
value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"), value: formatMoney(totalEtb, "ETB"),
icon: CircleDollarSign, icon: CircleDollarSign,
color: "edr-green", color: "edr-green",
}, },
@@ -373,6 +389,12 @@ export default function InvoicesPanel() {
icon: Landmark, icon: Landmark,
color: "violet", color: "violet",
}, },
{
label: "Collected in DJF",
value: formatMoney(djfCollected, "DJF"),
icon: Landmark,
color: "orange",
},
]} ]}
/> />

View File

@@ -274,7 +274,7 @@ function ConfirmCell({
export default function UsdPaymentsPanel({ export default function UsdPaymentsPanel({
currency, currency,
}: { }: {
currency: "USD" | "ETB"; currency: "USD" | "ETB" | "DJF";
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
// Namespaced: the ETB and USD tabs share this panel and live on the same URL // Namespaced: the ETB and USD tabs share this panel and live on the same URL

View File

@@ -27,6 +27,7 @@ import { useQuery } from "@tanstack/react-query";
import { KpiStrip } from "@/components/page"; import { KpiStrip } from "@/components/page";
import { ExportButton } from "@/components/export/ExportButton"; import { ExportButton } from "@/components/export/ExportButton";
import { formatDate, formatMoney } from "@/lib/format"; import { formatDate, formatMoney } from "@/lib/format";
import { currencyDecimals } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
import { import {
@@ -149,7 +150,7 @@ export default function PaymentsPanel() {
header: () => <span className={tableHeader}>Amount</span>, header: () => <span className={tableHeader}>Amount</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground"> <span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{formatMoney(row.original.amount, row.original.currency, 2)} {formatMoney(row.original.amount, row.original.currency, currencyDecimals(row.original.currency))}
</span> </span>
), ),
}, },

View File

@@ -450,6 +450,7 @@ export const rateUnitOptions = (
const CURRENCIES = [ const CURRENCIES = [
{ label: "ETB (Birr)", value: "ETB" }, { label: "ETB (Birr)", value: "ETB" },
{ label: "USD", value: "USD" }, { label: "USD", value: "USD" },
{ label: "DJF", value: "DJF" },
]; ];
const PRIORITY_CONFIG_TYPES = [ const PRIORITY_CONFIG_TYPES = [

View File

@@ -14,7 +14,10 @@ import {
useExchangeSettingsQuery, useExchangeSettingsQuery,
useSetExchangeFallbackRate, useSetExchangeFallbackRate,
} from "@/hooks/useExchangeSettings"; } from "@/hooks/useExchangeSettings";
import type { ExchangeRateSource } from "@/services/exchangeSettings.service"; import type {
ExchangeRateSource,
ExchangeSetting,
} from "@/services/exchangeSettings.service";
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
/** Feed health, phrased for an operator rather than a developer. */ /** Feed health, phrased for an operator rather than a developer. */
@@ -38,40 +41,121 @@ function feedLabel(source: ExchangeRateSource | null): {
const formatTime = (value: string | null) => const formatTime = (value: string | null) =>
value ? formatDateTime(value) : "never"; value ? formatDateTime(value) : "never";
/** /** One currency's fallback row — its own draft, its own save. */
* USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable. function ExchangeRateRow({
* The live CBE rate always wins; every successful fetch overwrites the stored setting,
* value, so it tracks the last known good rate on its own. Editing here is for disabled,
* a prolonged outage — the next successful CBE fetch replaces it. }: {
*/ setting: ExchangeSetting;
export default function ExchangeRateSettingsCard() { disabled: boolean;
const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery(); }) {
const setRate = useSetExchangeFallbackRate(); const setRate = useSetExchangeFallbackRate();
const [draft, setDraft] = useState<string>(""); const [draft, setDraft] = useState<string>("");
const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? ""); const value = draft !== "" ? draft : (setting.fallbackRate?.toString() ?? "");
const parsed = Number(value); const parsed = Number(value);
const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000; const invalid = !Number.isFinite(parsed) || parsed <= 0;
const dirty = draft !== "" && parsed !== data?.fallbackRate; const dirty = draft !== "" && parsed !== setting.fallbackRate;
const feed = feedLabel(data?.feed?.source ?? null); const feed = feedLabel(setting.feed?.source ?? null);
const handleSave = async () => { const handleSave = async () => {
if (invalid) return; if (invalid) return;
await setRate.mutateAsync(parsed); await setRate.mutateAsync({ currency: setting.currency, rate: parsed });
setDraft(""); setDraft("");
}; };
return (
<div className="space-y-3 border-t pt-4 first:border-t-0 first:pt-0">
<div
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
feed.live
? "border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100"
: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"
}`}
>
{feed.live ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
)}
<div className="space-y-1">
<p className="font-medium">
{setting.currency} ETB {feed.text}
</p>
{setting.feed?.rate != null && (
<p>
Rate in use: {setting.feed.rate} ETB per {setting.currency}
</p>
)}
<p className="opacity-80">
Last successful update: {formatTime(setting.feed?.lastSuccessAt ?? null)}
</p>
{setting.feed?.lastError && (
<p className="opacity-80">Last error: {setting.feed.lastError}</p>
)}
</div>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor={`fallback-rate-${setting.currency}`}
>
Fallback rate (ETB per {setting.currency})
</label>
<div className="flex items-center gap-2">
<Input
id={`fallback-rate-${setting.currency}`}
type="number"
step="0.0001"
min={0}
className="max-w-[220px]"
disabled={disabled}
value={value}
onChange={(e) => setDraft(e.target.value)}
/>
<Button
onClick={handleSave}
disabled={!dirty || invalid || setRate.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
</div>
{invalid && draft !== "" && (
<p className="text-sm text-red-600">Enter a rate greater than 0.</p>
)}
<p className="text-sm text-muted-foreground">
{setting.fallbackSource === "MANUAL"
? "Set manually. The next successful CBE update will replace it."
: `Synced automatically from CBE (${formatTime(setting.lastSyncedAt)}).`}
</p>
</div>
</div>
);
}
/**
* X→ETB fallback used when the CBE exchange-rate endpoint is unreachable for
* that currency — one row per foreign currency (USD, DJF). The live CBE rate
* always wins; every successful fetch overwrites the stored value, so it
* tracks the last known good rate on its own. Editing here is for a
* prolonged outage — the next successful CBE fetch replaces it.
*/
export default function ExchangeRateSettingsCard() {
const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery();
return ( return (
<Card className="shadow-lg border-gray-200 dark:border-gray-700"> <Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<CardTitle>Exchange rate (USD ETB)</CardTitle> <CardTitle>Exchange rates ( ETB)</CardTitle>
<CardDescription> <CardDescription>
Rates come from the Commercial Bank of Ethiopia. The fallback Rates come from the Commercial Bank of Ethiopia. Each fallback
below is used only when CBE cannot be reached, and is refreshed below is used only when CBE cannot be reached for that currency,
automatically after every successful update. and is refreshed automatically after every successful update.
</CardDescription> </CardDescription>
</div> </div>
<Button <Button
@@ -88,69 +172,13 @@ export default function ExchangeRateSettingsCard() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div {(data ?? []).map((setting) => (
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${ <ExchangeRateRow
feed.live key={setting.currency}
? "border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100" setting={setting}
: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100" disabled={isLoading}
}`} />
> ))}
{feed.live ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
)}
<div className="space-y-1">
<p className="font-medium">{feed.text}</p>
{data?.feed?.rate != null && (
<p>Rate in use: {data.feed.rate} ETB per USD</p>
)}
<p className="opacity-80">
Last successful update: {formatTime(data?.feed?.lastSuccessAt ?? null)}
</p>
{data?.feed?.lastError && (
<p className="opacity-80">Last error: {data.feed.lastError}</p>
)}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="fallback-rate">
Fallback rate (ETB per USD)
</label>
<div className="flex items-center gap-2">
<Input
id="fallback-rate"
type="number"
step="0.0001"
min={1}
max={10000}
className="max-w-[220px]"
disabled={isLoading}
value={value}
onChange={(e) => setDraft(e.target.value)}
/>
<Button
onClick={handleSave}
disabled={!dirty || invalid || setRate.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
</div>
{invalid && draft !== "" && (
<p className="text-sm text-red-600">
Enter a rate between 1 and 10,000.
</p>
)}
<p className="text-sm text-muted-foreground">
{data?.fallbackSource === "MANUAL"
? "Set manually. The next successful CBE update will replace it."
: `Synced automatically from CBE (${formatTime(
data?.lastSyncedAt ?? null,
)}).`}
</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -17,11 +17,11 @@ import {
useUpdateManualPaymentSettings, useUpdateManualPaymentSettings,
} from "@/hooks/useManualPaymentSettings"; } from "@/hooks/useManualPaymentSettings";
type Currency = "ETB" | "USD"; type Currency = "ETB" | "USD" | "DJF";
const CURRENCIES: { const CURRENCIES: {
code: Currency; code: Currency;
field: "etbEnabled" | "usdEnabled"; field: "etbEnabled" | "usdEnabled" | "djfEnabled";
icon: typeof Banknote; icon: typeof Banknote;
title: string; title: string;
description: string; description: string;
@@ -42,6 +42,14 @@ const CURRENCIES: {
description: description:
"USD invoices are paid by bank transfer and have no online channel. Switching this off leaves USD customers with no way to be marked as paid.", "USD invoices are paid by bank transfer and have no online channel. Switching this off leaves USD customers with no way to be marked as paid.",
}, },
{
code: "DJF",
field: "djfEnabled",
icon: Landmark,
title: "Djibouti Franc (DJF) invoices",
description:
"DJF invoices can be paid online (Waafi / CAC Bank) or by bank transfer. Switch this off if Finance should stop accepting DJF payments by hand.",
},
]; ];
/** /**
@@ -60,7 +68,9 @@ export default function ManualPaymentSettingsCard() {
const { data, isLoading } = useManualPaymentSettingsQuery(); const { data, isLoading } = useManualPaymentSettingsQuery();
const update = useUpdateManualPaymentSettings(); const update = useUpdateManualPaymentSettings();
const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled); const noneEnabled = Boolean(
data && !data.etbEnabled && !data.usdEnabled && !data.djfEnabled,
);
return ( return (
<Card className="shadow-lg border-gray-200 dark:border-gray-700"> <Card className="shadow-lg border-gray-200 dark:border-gray-700">
@@ -79,7 +89,7 @@ export default function ManualPaymentSettingsCard() {
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"> <div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p> <p>
Both currencies are off the Manual Payments list is empty and Every currency is off the Manual Payments list is empty and
Finance cannot settle any invoice by hand. Finance cannot settle any invoice by hand.
</p> </p>
</div> </div>

View File

@@ -16,7 +16,7 @@ import {
Text, Text,
Textarea, Textarea,
} from "@mantine/core"; } from "@mantine/core";
import { DataTable, type ColumnDef } from "@edr/ui-common"; import { DataTable, type ColumnDef, currencyDecimals } from "@edr/ui-common";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
@@ -45,7 +45,7 @@ const STATUS_META: Record<EmptyReturnRequestStatus, { label: string; color: stri
const money = (amount: number | null | undefined, currency: string | null | undefined) => const money = (amount: number | null | undefined, currency: string | null | undefined) =>
amount == null amount == null
? "—" ? "—"
: `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? ""}`.trim(); : `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: currencyDecimals(currency) })} ${currency ?? ""}`.trim();
/** /**
* The queue for customer-initiated empty container returns: a booking sold * The queue for customer-initiated empty container returns: a booking sold

View File

@@ -1,4 +1,5 @@
import { Fragment, useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { currencyDecimals } from "@edr/ui-common";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ActionIcon, ActionIcon,
@@ -78,7 +79,7 @@ const TRUCK_COLUMNS = [
] as const; ] as const;
const money = (amount: number, currency: string) => const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`; `${Number(amount).toLocaleString(undefined, { maximumFractionDigits: currencyDecimals(currency) })} ${currency === "ETB" ? "ETB" : currency}`;
export interface BookingGroup { export interface BookingGroup {

View File

@@ -17,7 +17,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react'; import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef } from '@edr/ui-common'; import { DataTable, type ColumnDef, currencyDecimals } from '@edr/ui-common';
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters'; import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
@@ -50,7 +50,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
CANCELLED: 'gray', CANCELLED: 'gray',
}; };
const fmt = (n: number, c: string) => formatMoney(n, c, 2); const fmt = (n: number, c: string) => formatMoney(n, c, currencyDecimals(c));
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—'); const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
const INVOICE_FILTER_DEFS: FilterDef[] = [ const INVOICE_FILTER_DEFS: FilterDef[] = [
@@ -204,7 +204,8 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
useEffect(() => { useEffect(() => {
setGatewayMethod(inv?.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'); // WAAFI settles USD and DJF; TELEBIRR is ETB-only.
setGatewayMethod(inv?.currency !== 'ETB' ? 'WAAFI' : 'TELEBIRR');
setPayerAccount(''); setPayerAccount('');
}, [inv?.id, inv?.currency]); }, [inv?.id, inv?.currency]);

View File

@@ -69,6 +69,7 @@ const TRADE = [
const CURRENCIES = [ const CURRENCIES = [
{ value: 'USD', label: 'USD - Dollar' }, { value: 'USD', label: 'USD - Dollar' },
{ value: 'ETB', label: 'ETB - Birr' }, { value: 'ETB', label: 'ETB - Birr' },
{ value: 'DJF', label: 'DJF - Djibouti Franc' },
]; ];
const clean = (s: string) => s.trim() || undefined; const clean = (s: string) => s.trim() || undefined;

View File

@@ -1533,7 +1533,7 @@ export const api = {
), ),
feePreview: endpoint< feePreview: endpoint<
{ inventoryId: string; billingCurrency?: "ETB" | "USD" }, { inventoryId: string; billingCurrency?: "ETB" | "USD" | "DJF" },
FeePreview[] FeePreview[]
>( >(
"warehouse-inventory", "warehouse-inventory",
@@ -1885,7 +1885,7 @@ export const api = {
{ {
inventoryId: string; inventoryId: string;
confirmZero?: boolean; confirmZero?: boolean;
billingCurrency?: "ETB" | "USD"; billingCurrency?: "ETB" | "USD" | "DJF";
}, },
WarehouseFeeInvoice WarehouseFeeInvoice
>( >(

View File

@@ -11,7 +11,7 @@ const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
*/ */
export type ExchangeRateSource = "live" | "stored"; export type ExchangeRateSource = "live" | "stored";
/** Health of the CBE exchange-rate feed. */ /** Health of the CBE exchange-rate feed for one currency. */
export interface ExchangeFeedStatus { export interface ExchangeFeedStatus {
rate: number | null; rate: number | null;
source: ExchangeRateSource | null; source: ExchangeRateSource | null;
@@ -19,25 +19,31 @@ export interface ExchangeFeedStatus {
lastError: string | null; lastError: string | null;
} }
export interface ExchangeSettings { /** One currency's X→ETB fallback settings — the API returns one per foreign currency. */
fallbackRate: number; export interface ExchangeSetting {
/** `AUTO` when synced from CBE, `MANUAL` when set here. */ currency: string;
fallbackSource: "AUTO" | "MANUAL"; fallbackRate: number | null;
/** `AUTO` when synced from CBE, `MANUAL` when set here. `null` before the row exists. */
fallbackSource: "AUTO" | "MANUAL" | null;
lastSyncedAt: string | null; lastSyncedAt: string | null;
updatedById: string | null; updatedById: string | null;
feed?: ExchangeFeedStatus; feed?: ExchangeFeedStatus;
} }
export const exchangeSettingsService = { export const exchangeSettingsService = {
get: async (): Promise<ExchangeSettings> => { list: async (): Promise<ExchangeSetting[]> => {
const response = await client.get<ApiResponse<ExchangeSettings>>(BASE); const response = await client.get<ApiResponse<ExchangeSetting[]>>(BASE);
return unwrap(response.data); return unwrap(response.data);
}, },
setFallbackRate: async (fallbackRate: number): Promise<ExchangeSettings> => { setFallbackRate: async (
const response = await client.patch<ApiResponse<ExchangeSettings>>(BASE, { currency: string,
fallbackRate, fallbackRate: number,
}); ): Promise<ExchangeSetting> => {
const response = await client.patch<ApiResponse<ExchangeSetting>>(
`${BASE}/${currency}`,
{ fallbackRate },
);
return unwrap(response.data); return unwrap(response.data);
}, },
}; };

View File

@@ -13,6 +13,7 @@ const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE;
export interface ManualPaymentSettings { export interface ManualPaymentSettings {
etbEnabled: boolean; etbEnabled: boolean;
usdEnabled: boolean; usdEnabled: boolean;
djfEnabled: boolean;
updatedById: string | null; updatedById: string | null;
updatedAt?: string; updatedAt?: string;
} }
@@ -25,7 +26,9 @@ export const manualPaymentSettingsService = {
/** Partial: an omitted currency keeps its current setting. */ /** Partial: an omitted currency keeps its current setting. */
update: async ( update: async (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>, patch: Partial<
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
>,
): Promise<ManualPaymentSettings> => { ): Promise<ManualPaymentSettings> => {
const response = await client.patch<ApiResponse<ManualPaymentSettings>>( const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
BASE, BASE,

View File

@@ -558,11 +558,11 @@ export const warehouseService = {
updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) => updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) =>
apiClient.patch<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload), apiClient.patch<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload),
deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)), deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)),
feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD') => feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD' | 'DJF') =>
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), { apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
params: cleanParams({ billingCurrency }), params: cleanParams({ billingCurrency }),
}), }),
accrualDashboard: (billingCurrency?: 'ETB' | 'USD') => accrualDashboard: (billingCurrency?: 'ETB' | 'USD' | 'DJF') =>
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, { apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
params: cleanParams({ billingCurrency }), params: cleanParams({ billingCurrency }),
}), }),
@@ -592,7 +592,7 @@ export const warehouseService = {
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)), apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
invoicesForBooking: (bookingId: string) => invoicesForBooking: (bookingId: string) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)), apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)),
generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD') => generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD' | 'DJF') =>
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), {
confirmZero, confirmZero,
billingCurrency, billingCurrency,

View File

@@ -420,7 +420,7 @@ export interface CustomerBooking {
originLabel: string; originLabel: string;
destinationLabel: string; destinationLabel: string;
totalAmount: number; totalAmount: number;
currency: "ETB" | "USD"; currency: "ETB" | "USD" | "DJF";
scheduledDate?: string | null; scheduledDate?: string | null;
createdAt: string; createdAt: string;
} }
@@ -469,7 +469,7 @@ export interface CustomerPayment {
/** Booking reference the payment settles. */ /** Booking reference the payment settles. */
bookingReference: string; bookingReference: string;
amount: number; amount: number;
currency: "ETB" | "USD"; currency: "ETB" | "USD" | "DJF";
method: CustomerPaymentMethod; method: CustomerPaymentMethod;
status: CustomerPaymentStatus; status: CustomerPaymentStatus;
paidAt?: string | null; paidAt?: string | null;

View File

@@ -77,7 +77,7 @@ export interface InvoiceListFilter {
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */ /** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
paymentMethods?: string; paymentMethods?: string;
search?: string; search?: string;
currency?: "USD" | "ETB"; currency?: "USD" | "ETB" | "DJF";
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */ /** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
issuedFrom?: string; issuedFrom?: string;
issuedTo?: string; issuedTo?: string;

View File

@@ -1,23 +1,8 @@
/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */
export type Currency = string;
const SYMBOLS: Record<string, string> = {
USD: "$",
ETB: "Br",
DJF: "DJF",
};
/** /**
* Format a money amount with its currency symbol, e.g. `Br 12,500.00`. * Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …).
* Unknown currency codes fall back to printing the raw code. * Re-exports the shared `@edr/ui-common` currency module so every currency
* gets the same symbol, decimals rule and formatting across both freight web
* apps — see that module for the source of truth.
*/ */
export function formatCurrency( export type { SupportedCurrency as Currency } from "@edr/ui-common";
amount: number, export { formatCurrency, currencySymbol, currencyDecimals, CURRENCY_CODES } from "@edr/ui-common";
currency: Currency = "ETB",
): string {
const symbol = SYMBOLS[currency] ?? currency;
return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}

View File

@@ -1,6 +1,6 @@
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; import { bookingCanPayOnline } from "@/pages/bookings/payments/offline-payment";
/** A pending customer action surfaced on the home "needs attention" card. */ /** A pending customer action surfaced on the home "needs attention" card. */
export interface ActionItem { export interface ActionItem {
@@ -64,7 +64,10 @@ export function deriveActionItems(
? b.status === "FULLY_EXECUTED" ? b.status === "FULLY_EXECUTED"
: b.status === "SELECTED_FOR_BATCH"); : b.status === "SELECTED_FOR_BATCH");
if (canPay) { if (canPay) {
const offlinePay = isUsdOfflineBooking(b); // Online-only description unless the booking's currency has no online
// rail at all (USD) — DJF supports both, so it reads as a normal
// "payment due" like ETB rather than bank-transfer-only.
const offlinePay = !bookingCanPayOnline(b);
items.push({ items.push({
id: `pay-${b.id}`, id: `pay-${b.id}`,
kind: "pay", kind: "pay",

View File

@@ -32,7 +32,7 @@ import { invoicesService } from "@/services/invoices.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment"; import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service"; import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment"; import { canPayOffline, canPayOnline } from "@/pages/bookings/payments/offline-payment";
import { saveBlob } from "@/utils/download"; import { saveBlob } from "@/utils/download";
import { formatCurrency } from "@/lib/currency"; import { formatCurrency } from "@/lib/currency";
import { BORDER, INK, MUTED } from "../contracts/contract-ui"; import { BORDER, INK, MUTED } from "../contracts/contract-ui";
@@ -232,7 +232,7 @@ export default function InvoiceDetailPage() {
Receipt Receipt
</Button> </Button>
)} )}
{payable && !isUsdCurrency(invoice.currency) && ( {payable && canPayOnline(invoice.currency) && (
<Button <Button
color="edr-green" color="edr-green"
radius="md" radius="md"
@@ -247,7 +247,7 @@ export default function InvoiceDetailPage() {
Pay {formatCurrency(amountDue, invoice.currency)} Pay {formatCurrency(amountDue, invoice.currency)}
</Button> </Button>
)} )}
{payable && isUsdCurrency(invoice.currency) && ( {payable && canPayOffline(invoice.currency) && (
<Badge <Badge
size="lg" size="lg"
radius="md" radius="md"

View File

@@ -32,7 +32,7 @@ import { Freight } from "@edr/types";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { formatCurrency } from "@/lib/currency"; import { formatCurrency } from "@/lib/currency";
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment"; import { canPayOnline } from "@/pages/bookings/payments/offline-payment";
import { import {
BORDER, BORDER,
GREEN, GREEN,
@@ -277,10 +277,11 @@ export default function InvoicesList() {
{!isLoading && {!isLoading &&
!isError && !isError &&
pageRows.map((inv) => { pageRows.map((inv) => {
// USD invoices are paid by bank transfer — the detail page // A currency with no online rail (USD) is paid by bank
// shows the instructions, so the row action reads "View". // transfer — the detail page shows the instructions, so the
// row action reads "View".
const payable = const payable =
isPayable(inv.status) && !isUsdCurrency(inv.currency); isPayable(inv.status) && canPayOnline(inv.currency);
return ( return (
<Table.Tr <Table.Tr
key={inv.id} key={inv.id}

View File

@@ -17,7 +17,7 @@ import type { Freight } from "@edr/types";
import { invoicesService, type PortalInvoice } from "@/services/invoices.service"; import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui"; import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
import { paymentStatusLabel } from "@/pages/bookings/booking-display"; import { paymentStatusLabel } from "@/pages/bookings/booking-display";
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; import { bookingCanPayOffline, bookingCanPayOnline } from "@/pages/bookings/payments/offline-payment";
import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote"; import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote";
import { payWindowState } from "@/pages/bookings/payments/payment-drain"; import { payWindowState } from "@/pages/bookings/payments/payment-drain";
import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice"; import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice";
@@ -200,7 +200,8 @@ export function BookingPaymentPanel({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const paid = booking.paymentStatus === "PAID"; const paid = booking.paymentStatus === "PAID";
const offlineUsd = isUsdOfflineBooking(booking); const payOnline = bookingCanPayOnline(booking);
const payOffline = bookingCanPayOffline(booking);
const isAdjusted = const isAdjusted =
booking.adjustedTotalAmount !== null && booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined; booking.adjustedTotalAmount !== undefined;
@@ -293,7 +294,7 @@ export function BookingPaymentPanel({
<PaymentProcessingNotice drainEndsAt={payWindow.drainEndsAt} /> <PaymentProcessingNotice drainEndsAt={payWindow.drainEndsAt} />
)} )}
{!paid && !draining && offlineUsd && ( {!paid && !draining && payOffline && (
<Box <Box
mt={14} mt={14}
p={14} p={14}
@@ -307,10 +308,9 @@ export function BookingPaymentPanel({
Pay by bank transfer Pay by bank transfer
</Text> </Text>
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}> <Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
Online payment isn&apos;t available for USD bookings. Transfer the {payOnline
total amount to EDR&apos;s bank account before the payment deadline, ? "You can also transfer the total amount to EDR\u2019s bank account before the payment deadline, then send the payment slip to the EDR Finance department \u2014 they will confirm your payment."
then send the payment slip to the EDR Finance department they : "Online payment isn\u2019t available for this booking\u2019s currency. Transfer the total amount to EDR\u2019s bank account before the payment deadline, then send the payment slip to the EDR Finance department \u2014 they will confirm your payment."}
will confirm your payment.
</Text> </Text>
</Box> </Box>
)} )}
@@ -319,7 +319,7 @@ export function BookingPaymentPanel({
<Box mt={16}> <Box mt={16}>
<Countdown <Countdown
deadline={booking.paymentDeadline} deadline={booking.paymentDeadline}
onPay={offlineUsd ? undefined : onPay} onPay={payOnline ? onPay : undefined}
paying={paying} paying={paying}
/> />
{!paid && <PayerAccountNote />} {!paid && <PayerAccountNote />}

View File

@@ -44,16 +44,16 @@ const PROVIDERS: ProviderOption[] = [
{ {
method: "WAAFI", method: "WAAFI",
label: "Waafi", label: "Waafi",
description: "Djibouti mobile money · USD", description: "Djibouti mobile money · USD or DJF",
logo: "/assets/waafi.jpeg", logo: "/assets/waafi.jpeg",
currencies: ["USD"], currencies: ["USD", "DJF"],
accent: "#2E5B96", accent: "#2E5B96",
}, },
{ {
method: "CAC_BANK", method: "CAC_BANK",
label: "CAC Bank", label: "CAC Bank",
description: "Djibouti bank debit · confirmed by SMS OTP", description: "Djibouti bank debit · confirmed by SMS OTP",
currencies: ["USD"], currencies: ["USD", "DJF"],
accent: "#8A5A17", accent: "#8A5A17",
}, },
{ {
@@ -74,14 +74,16 @@ const OTP_LENGTH = 4;
const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL"; const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL";
/** /**
* Pick the provider that settles in the booking's currency. USD → Waafi/CAC, * Pick the provider(s) that settle in the booking's currency: ETB → CBE
* ETB → CBE bill. Falls back to the full list when unknown. * bill, USD/DJF → Waafi/CAC. Falls back to the full list when the currency
* is unknown, but a *known* currency with no matching provider (ETB and USD
* never share one) returns nothing rather than every provider — offering
* CBE bill for a DJF invoice, say, would settle it in the wrong currency.
*/ */
function providersForCurrency(currency?: string | null): ProviderOption[] { function providersForCurrency(currency?: string | null): ProviderOption[] {
const cur = currency?.trim().toUpperCase(); const cur = currency?.trim().toUpperCase();
if (!cur) return PROVIDERS; if (!cur) return PROVIDERS;
const matched = PROVIDERS.filter((p) => p.currencies.includes(cur)); return PROVIDERS.filter((p) => p.currencies.includes(cur));
return matched.length > 0 ? matched : PROVIDERS;
} }
function ProviderRow({ function ProviderRow({
@@ -213,14 +215,14 @@ export function PaymentMethodModal({
), ),
[currency, otp, bill], [currency, otp, bill],
); );
const [method, setMethod] = useState<PaymentMethod>(providers[0].method); const [method, setMethod] = useState<PaymentMethod>(providers[0]?.method ?? PROVIDERS[0].method);
const [mobile, setMobile] = useState(""); const [mobile, setMobile] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// Keep the selection valid when the currency (and therefore provider list) changes. // Keep the selection valid when the currency (and therefore provider list) changes.
useEffect(() => { useEffect(() => {
if (!providers.some((p) => p.method === method)) { if (providers.length > 0 && !providers.some((p) => p.method === method)) {
setMethod(providers[0].method); setMethod(providers[0].method);
} }
}, [providers, method]); }, [providers, method]);
@@ -462,14 +464,21 @@ export function PaymentMethodModal({
Payment method Payment method
</Text> </Text>
<Stack gap={10}> <Stack gap={10}>
{providers.map((option) => ( {providers.length === 0 ? (
<ProviderRow <Text fz="12.5px" c="dimmed">
key={option.method} No online payment method is available for this currency yet — use bank
option={option} transfer instead.
selected={method === option.method} </Text>
onSelect={() => setMethod(option.method)} ) : (
/> providers.map((option) => (
))} <ProviderRow
key={option.method}
option={option}
selected={method === option.method}
onSelect={() => setMethod(option.method)}
/>
))
)}
</Stack> </Stack>
{needsMobile && ( {needsMobile && (

View File

@@ -1,5 +1,5 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { Banknote, Check, DollarSign } from "lucide-react"; import { Banknote, Check, Coins, DollarSign } from "lucide-react";
import { Controller, type Control } from "react-hook-form"; import { Controller, type Control } from "react-hook-form";
import { import {
PAYMENT_CURRENCY_OPTIONS, PAYMENT_CURRENCY_OPTIONS,
@@ -15,6 +15,7 @@ const CURRENCY_ICONS: Record<
> = { > = {
USD: { icon: DollarSign, color: "#4F46E5" }, USD: { icon: DollarSign, color: "#4F46E5" },
ETB: { icon: Banknote, color: "#0A6F4D" }, ETB: { icon: Banknote, color: "#0A6F4D" },
DJF: { icon: Coins, color: "#B45309" },
}; };
export function PaymentCurrencyField({ export function PaymentCurrencyField({
@@ -28,9 +29,10 @@ export function PaymentCurrencyField({
*/ */
allowUsd?: boolean; allowUsd?: boolean;
}) { }) {
// DJF is offered wherever USD is — both are import-shipment-only currencies.
const options = allowUsd const options = allowUsd
? PAYMENT_CURRENCY_OPTIONS ? PAYMENT_CURRENCY_OPTIONS
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value !== "USD"); : PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB");
return ( return (
<Box mt={24}> <Box mt={24}>
<StepLabel>Payment currency</StepLabel> <StepLabel>Payment currency</StepLabel>

View File

@@ -73,7 +73,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
export type BookingDocuments = Record<string, File | File[] | null>; export type BookingDocuments = Record<string, File | File[] | null>;
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const; export const PAYMENT_CURRENCIES = ["USD", "ETB", "DJF"] as const;
export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number]; export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
export const PAYMENT_CURRENCY_OPTIONS: Array<{ export const PAYMENT_CURRENCY_OPTIONS: Array<{
@@ -92,6 +92,12 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
label: "USD", label: "USD",
description: "US Dollar — paid by bank transfer, slip sent to Finance.", description: "US Dollar — paid by bank transfer, slip sent to Finance.",
}, },
// Import shipments only, same as USD.
{
value: "DJF",
label: "DJF",
description: "Djibouti Franc — paid online, or by bank transfer.",
},
]; ];
export const BOOKING_TYPES = ["one_time", "general_contract"] as const; export const BOOKING_TYPES = ["one_time", "general_contract"] as const;

View File

@@ -1,16 +1,33 @@
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
/** /**
* USD bookings are never paid online: the customer pays by bank transfer and * Which payment rails a currency supports. ETB settles online only (the
* the Finance department confirms the payment from the slip. Phase 1 is * payment gateway); USD is bank-transfer-only (never through the online
* portal-only — Finance's confirm flow lands in the backoffice later. * gateway) — the customer pays by bank transfer and the Finance department
* confirms the payment from the slip. DJF supports BOTH: it settles through
* a Djibouti gateway (WAAFI / CAC Bank) as well as by bank transfer.
*
* These two predicates are intentionally independent, not opposites of one
* currency check — DJF is neither purely online nor purely offline.
*/ */
export function isUsdCurrency(currency?: string | null): boolean { export function canPayOnline(currency?: string | null): boolean {
return currency?.toUpperCase() === "USD"; const c = currency?.toUpperCase();
return c === "ETB" || c === "DJF";
} }
export function isUsdOfflineBooking(booking: Freight.IBooking): boolean { export function canPayOffline(currency?: string | null): boolean {
return isUsdCurrency( const c = currency?.toUpperCase();
booking.pricingBreakdown?.currency ?? booking.paymentCurrency, return c === "USD" || c === "DJF";
); }
function bookingCurrency(booking: Freight.IBooking): string | null | undefined {
return booking.pricingBreakdown?.currency ?? booking.paymentCurrency;
}
export function bookingCanPayOnline(booking: Freight.IBooking): boolean {
return canPayOnline(bookingCurrency(booking));
}
export function bookingCanPayOffline(booking: Freight.IBooking): boolean {
return canPayOffline(bookingCurrency(booking));
} }

View File

@@ -6,7 +6,7 @@ import { isPayable } from "@/pages/billing/invoice-ui";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import { invoicesService } from "@/services/invoices.service"; import { invoicesService } from "@/services/invoices.service";
import { isUsdOfflineBooking } from "./offline-payment"; import { bookingCanPayOnline } from "./offline-payment";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "./useBookingPayment"; import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "./useBookingPayment";
export type PayableAction = export type PayableAction =
@@ -77,7 +77,11 @@ export function useBookingPayables(booking: Freight.IBooking) {
const items = useMemo(() => { const items = useMemo(() => {
const out: PayableItem[] = []; const out: PayableItem[] = [];
const offline = isUsdOfflineBooking(booking); // The strip's single action per item picks online when it's available at
// all (DJF supports both rails; the freight-payment card itself offers
// both) and falls back to the bank-transfer instructions only when
// online isn't an option (USD).
const offline = !bookingCanPayOnline(booking);
for (const inv of invoicesQ.data ?? []) { for (const inv of invoicesQ.data ?? []) {
const balance = Number(inv.balanceAmount ?? 0); const balance = Number(inv.balanceAmount ?? 0);

View File

@@ -310,7 +310,9 @@ function mapBookingToShipmentValues(
// Resubmit keeps the currency the customer already chose on this booking; // Resubmit keeps the currency the customer already chose on this booking;
// a missing value falls back to empty so the choice is made deliberately. // a missing value falls back to empty so the choice is made deliberately.
paymentCurrency: paymentCurrency:
booking.paymentCurrency === "USD" || booking.paymentCurrency === "ETB" booking.paymentCurrency === "USD" ||
booking.paymentCurrency === "ETB" ||
booking.paymentCurrency === "DJF"
? booking.paymentCurrency ? booking.paymentCurrency
: "", : "",
withReturn: booking.equipmentReturn === "WITH_RETURN", withReturn: booking.equipmentReturn === "WITH_RETURN",
@@ -1464,7 +1466,7 @@ function ScheduleStep({
<StepLabel>Billing currency *</StepLabel> <StepLabel>Billing currency *</StepLabel>
<Text fz={12.5} c="dimmed" mt={4} mb={10}> <Text fz={12.5} c="dimmed" mt={4} mb={10}>
{isImport {isImport
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." ? "Import shipments may be invoiced in ETB, USD or DJF. USD is paid by bank transfer, not online."
: "Shipments are invoiced in ETB."} : "Shipments are invoiced in ETB."}
</Text> </Text>
<CurrencySelector <CurrencySelector
@@ -1472,6 +1474,7 @@ function ScheduleStep({
onChange={(v) => field.onChange(v)} onChange={(v) => field.onChange(v)}
error={fieldState.error?.message} error={fieldState.error?.message}
allowUsd={isImport} allowUsd={isImport}
allowDjf={isImport}
/> />
</Box> </Box>
)} )}

View File

@@ -38,7 +38,7 @@ export default function NewShipmentRequestPage() {
// to be invoiced in has to be stated here — the contract itself quotes USD. // to be invoiced in has to be stated here — the contract itself quotes USD.
// Starts empty so the billing-currency choice is deliberate — required at // Starts empty so the billing-currency choice is deliberate — required at
// submit. Intercity/export are forced to ETB (server-enforced too). // submit. Intercity/export are forced to ETB (server-enforced too).
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">(""); const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
const [currencyError, setCurrencyError] = useState<string | undefined>(); const [currencyError, setCurrencyError] = useState<string | undefined>();
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
@@ -125,7 +125,7 @@ export default function NewShipmentRequestPage() {
contractRouteId: route?.id, contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
paymentCurrency: paymentCurrency:
isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB"), isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB" | "DJF"),
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
}; };
@@ -267,6 +267,7 @@ export default function NewShipmentRequestPage() {
}} }}
disabled={isIntercity || isExport} disabled={isIntercity || isExport}
allowUsd={!isIntercity && !isExport} allowUsd={!isIntercity && !isExport}
allowDjf={!isIntercity && !isExport}
error={currencyError} error={currencyError}
/> />
</Box> </Box>

View File

@@ -75,7 +75,7 @@ export const CONTRACT_KIND_OPTIONS: Array<{
export type ContractDocuments = Record<string, File | File[] | null>; export type ContractDocuments = Record<string, File | File[] | null>;
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const; export const PAYMENT_CURRENCIES = ["USD", "ETB", "DJF"] as const;
export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number]; export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
export const PAYMENT_CURRENCY_OPTIONS: Array<{ export const PAYMENT_CURRENCY_OPTIONS: Array<{
@@ -93,6 +93,11 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
label: "ETB", label: "ETB",
description: "Ethiopian Birr — local pricing and invoicing.", description: "Ethiopian Birr — local pricing and invoicing.",
}, },
{
value: "DJF",
label: "DJF",
description: "Djibouti Franc — Djibouti-side pricing and invoicing.",
},
]; ];
// one_time → ContractKind.OneTime; general_contract → ContractKind.General. // one_time → ContractKind.OneTime; general_contract → ContractKind.General.

View File

@@ -101,7 +101,7 @@ const shipmentFormBase = z.object({
// The contract quotes in USD; the customer picks the billing currency for // The contract quotes in USD; the customer picks the billing currency for
// THIS shipment. Starts empty so the choice is deliberate — validated as // THIS shipment. Starts empty so the choice is deliberate — validated as
// required below. Intercity is forced to ETB (server-enforced too). // required below. Intercity is forced to ETB (server-enforced too).
paymentCurrency: z.enum(["USD", "ETB", ""]).default(""), paymentCurrency: z.enum(["USD", "ETB", "DJF", ""]).default(""),
// Container contracts only: return the empty container(s) to EDR after // Container contracts only: return the empty container(s) to EDR after
// unloading. Seeded from the contract's equipment return; bulk ignores it. // unloading. Seeded from the contract's equipment return; bulk ignores it.
withReturn: z.boolean().default(false), withReturn: z.boolean().default(false),

View File

@@ -6,6 +6,7 @@ import {
ResolvedExchangeOptions, ResolvedExchangeOptions,
} from "./exchange.options"; } from "./exchange.options";
import { import {
CurrencyCode,
CurrencyPair, CurrencyPair,
ExchangeRateProvider, ExchangeRateProvider,
} from "./exchange.types"; } from "./exchange.types";
@@ -41,62 +42,77 @@ export interface CbeProviderStatus {
/** /**
* Commercial Bank of Ethiopia (CBE) rate provider. * Commercial Bank of Ethiopia (CBE) rate provider.
* *
* Sources a single canonical direction — **USD→ETB** (transactional selling * Sources every quoted currency against **ETB** (transactional selling rate)
* rate) — from CBE's public `daily-exchange-rates` JSON endpoint, caching the * from CBE's public `daily-exchange-rates` JSON endpoint in a single fetch —
* result and falling back to a configured rate when the fetch fails. The * the payload carries every currency CBE quotes that day, not just one — caching
* inverse (ETB→USD) is derived by {@link ExchangeService}, so this provider * the result and falling back to a configured rate per currency when the fetch
* only ever reports USD→ETB. * fails. Every other pair (ETB→X, and cross-pairs like USD→DJF) is derived by
* {@link ExchangeService}, so this provider only ever reports X→ETB.
*/ */
export class CbeExchangeProvider implements ExchangeRateProvider { export class CbeExchangeProvider implements ExchangeRateProvider {
readonly name = "CBE"; readonly name = "CBE";
readonly baseCurrency: CurrencyCode = "ETB";
private readonly logger = new Logger(CbeExchangeProvider.name); private readonly logger = new Logger(CbeExchangeProvider.name);
private readonly options: ResolvedExchangeOptions; private readonly options: ResolvedExchangeOptions;
private cachedRate: number | null = null; private cachedRates: Map<string, number> | null = null;
private cacheExpiresAt = 0; private cacheExpiresAt = 0;
private lastSuccessAt: number | null = null; private lastSuccessAt: number | null = null;
private lastError: string | null = null; private lastError: string | null = null;
private lastSource: CbeRateSource | null = null; /** Source of the rate last served, per currency code. */
private lastSource = new Map<string, CbeRateSource>();
/** Rate last served, per currency code — mirrors {@link lastSource}. */
private lastServed = new Map<string, number>();
constructor(options: ExchangeOptions) { constructor(options: ExchangeOptions) {
this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) };
} }
async getBaseRate(pair: CurrencyPair): Promise<number | null> { async getBaseRate(pair: CurrencyPair): Promise<number | null> {
// CBE only sources USD→ETB; everything else is derived upstream. // CBE only sources X→ETB; everything else is derived upstream.
if (pair.from !== "USD" || pair.to !== "ETB") { if (pair.to !== "ETB" || pair.from === "ETB") {
return null; return null;
} }
return this.getUsdToEtbRate(); return this.getRateToEtb(pair.from);
} }
/** Health of the CBE feed — what was served last, and whether it is failing. */ /**
getStatus(): CbeProviderStatus { * Health of the CBE feed for one currency — what was served last, and
* whether it is currently failing. For operator-facing status displays.
*/
getStatus(code: CurrencyCode = "USD"): CbeProviderStatus {
return { return {
rate: this.cachedRate, rate: this.lastServed.get(code) ?? null,
source: this.lastSource, source: this.lastSource.get(code) ?? null,
lastSuccessAt: this.lastSuccessAt, lastSuccessAt: this.lastSuccessAt,
lastError: this.lastError, lastError: this.lastError,
}; };
} }
/** /**
* Returns the current CBE USD→ETB **transactional selling** rate. * Returns the current CBE `code`→ETB **transactional selling** rate.
* *
* Cached for `cacheTtlMs`. On a successful fetch the rate is written back via * Cached for `cacheTtlMs`, one fetch serving every currency. On a
* successful fetch each currency's rate is written back via
* `saveFallbackRate`, so the stored fallback is never more than one good * `saveFallbackRate`, so the stored fallback is never more than one good
* fetch stale. On failure the chain is: cached rate → `loadFallbackRate()` * fetch stale. On failure the chain is: cached rate → `loadFallbackRate(code)`
* → static `fallbackRate`. * → static `fallbackRates[code]`.
*/ */
private async getUsdToEtbRate(): Promise<number> { private async getRateToEtb(code: CurrencyCode): Promise<number> {
const now = Date.now(); const now = Date.now();
if (this.cachedRate !== null && now < this.cacheExpiresAt) { if (this.cachedRates !== null && now < this.cacheExpiresAt) {
this.lastSource = "cache"; const cached = this.cachedRates.get(code);
return this.cachedRate; if (cached !== undefined) {
this.lastSource.set(code, "cache");
this.lastServed.set(code, cached);
return cached;
}
// Cache is fresh but never saw this currency quoted — fall through to
// stored/default rather than treating it as a live-fetch failure.
} }
const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } = const { scrapeUrl, fallbackRates, cacheTtlMs, requestTimeoutMs } =
this.options; this.options;
try { try {
@@ -116,54 +132,68 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
throw new Error("CBE rates payload contained no daily record"); throw new Error("CBE rates payload contained no daily record");
} }
const rate = this.parseUsdRate(day); const rates = this.parseRates(day);
const rate = rates.get(code) ?? null;
if (rate === null) { if (rate === null) {
throw new Error( throw new Error(
`USD transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`, `${code} transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`,
); );
} }
const previous = this.cachedRate; const previous = this.cachedRates?.get(code) ?? null;
this.cachedRate = rate; this.cachedRates = rates;
this.cacheExpiresAt = now + cacheTtlMs; this.cacheExpiresAt = now + cacheTtlMs;
this.lastSuccessAt = now; this.lastSuccessAt = now;
this.lastError = null; this.lastError = null;
this.lastSource = "live"; this.lastSource.set(code, "live");
this.lastServed.set(code, rate);
this.logger.log( this.logger.log(
`CBE USD→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`, `CBE ${code}→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`,
); );
// Persist as the new fallback so a later outage reuses the last good // Persist as the new fallback so a later outage reuses the last good
// rate. Skipped when unchanged, to avoid pointless writes and audit noise. // rate. Skipped when unchanged, to avoid pointless writes and audit noise.
if (rate !== previous) { if (rate !== previous) {
await this.persistFallback(rate); await this.persistFallback(code, rate);
} }
return rate; return rate;
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
this.lastError = message; this.lastError = message;
this.logger.error(`Failed to fetch CBE exchange rate. Error: ${message}`); this.logger.error(
`Failed to fetch CBE exchange rate for ${code}. Error: ${message}`,
);
if (this.cachedRate !== null) { const cached = this.cachedRates?.get(code);
this.lastSource = "cache"; if (cached !== undefined) {
this.logger.warn( this.lastSource.set(code, "cache");
`Using previously cached CBE rate: ${this.cachedRate}`, this.lastServed.set(code, cached);
); this.logger.warn(`Using previously cached CBE rate for ${code}: ${cached}`);
return this.cachedRate; return cached;
} }
const stored = await this.loadStoredFallback(); const stored = await this.loadStoredFallback(code);
if (stored !== null) { if (stored !== null) {
this.lastSource = "stored"; this.lastSource.set(code, "stored");
this.logger.warn(`Using stored fallback CBE rate: ${stored}`); this.lastServed.set(code, stored);
this.logger.warn(`Using stored fallback CBE rate for ${code}: ${stored}`);
return stored; return stored;
} }
this.lastSource = "default"; const fallback = fallbackRates[code];
this.logger.warn(`Using default fallback CBE rate: ${fallbackRate}`); if (fallback === undefined) {
return fallbackRate; // No static default configured for this currency either — nothing
// left to fall back to.
throw new Error(
`No CBE rate available for ${code}→ETB (fetch failed and no fallback configured)`,
);
}
this.lastSource.set(code, "default");
this.lastServed.set(code, fallback);
this.logger.warn(`Using default fallback CBE rate for ${code}: ${fallback}`);
return fallback;
} }
} }
@@ -172,34 +202,34 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
* logged and swallowed: persisting the fallback is housekeeping, and must * logged and swallowed: persisting the fallback is housekeeping, and must
* never fail the pricing call that triggered it. * never fail the pricing call that triggered it.
*/ */
private async persistFallback(rate: number): Promise<void> { private async persistFallback(code: CurrencyCode, rate: number): Promise<void> {
const { saveFallbackRate } = this.options; const { saveFallbackRate } = this.options;
if (!saveFallbackRate) return; if (!saveFallbackRate) return;
try { try {
await saveFallbackRate(rate); await saveFallbackRate(code, rate);
} catch (err) { } catch (err) {
this.logger.warn( this.logger.warn(
`Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`, `Failed to persist CBE fallback rate ${rate} for ${code}: ${(err as Error).message}`,
); );
} }
} }
/** /**
* Reads the persisted fallback. Returns `null` — falling through to the * Reads the persisted fallback for `code`. Returns `null` — falling through
* static default — when unconfigured, unusable, or itself failing. * to the static default — when unconfigured, unusable, or itself failing.
*/ */
private async loadStoredFallback(): Promise<number | null> { private async loadStoredFallback(code: CurrencyCode): Promise<number | null> {
const { loadFallbackRate } = this.options; const { loadFallbackRate } = this.options;
if (!loadFallbackRate) return null; if (!loadFallbackRate) return null;
try { try {
const stored = await loadFallbackRate(); const stored = await loadFallbackRate(code);
const rate = Number(stored); const rate = Number(stored);
return Number.isFinite(rate) && rate > 0 ? rate : null; return Number.isFinite(rate) && rate > 0 ? rate : null;
} catch (err) { } catch (err) {
this.logger.warn( this.logger.warn(
`Failed to load stored CBE fallback rate: ${(err as Error).message}`, `Failed to load stored CBE fallback rate for ${code}: ${(err as Error).message}`,
); );
return null; return null;
} }
@@ -217,18 +247,21 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
} }
/** /**
* Pulls USD `transactionalSelling` out of a daily record. Returns `null` when * Pulls every currency's `transactionalSelling` out of a daily record in
* the entry is missing or the value isn't a usable positive number — CBE * one pass. Skips entries missing or unusable — CBE publishes `0`/`null`
* publishes `0`/`null` for currencies it isn't quoting that day. * for currencies it isn't quoting that day.
*/ */
private parseUsdRate(day: CbeDailyRecord): number | null { private parseRates(day: CbeDailyRecord): Map<string, number> {
const usd = day.ExchangeRate?.find( const rates = new Map<string, number>();
(entry) => entry?.currency?.CurrencyCode === "USD", for (const entry of day.ExchangeRate ?? []) {
); const code = entry?.currency?.CurrencyCode;
if (!usd) return null; if (!code) continue;
const rate = Number(entry.transactionalSelling);
const rate = Number(usd.transactionalSelling); if (Number.isFinite(rate) && rate > 0) {
return Number.isFinite(rate) && rate > 0 ? rate : null; rates.set(code, rate);
}
}
return rates;
} }
} }

View File

@@ -1,3 +1,5 @@
import { CurrencyCode } from "./exchange.types";
/** Injection token carrying the resolved {@link ExchangeOptions}. */ /** Injection token carrying the resolved {@link ExchangeOptions}. */
export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS"); export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS");
@@ -11,31 +13,34 @@ export interface ExchangeOptions {
scrapeUrl?: string; scrapeUrl?: string;
/** /**
* Last-resort USD→ETB rate, used only when the fetch fails, no cached rate * Last-resort rate for each foreign currency, quoted against the provider's
* exists, and {@link loadFallbackRate} supplies nothing. The ETB→USD * base currency (ETB for CBE) — used only when the fetch fails, no cached
* direction is derived as its inverse. * rate exists, and {@link loadFallbackRate} supplies nothing for that
* @default 162 * currency. Every other pair (including ETB→X and cross-pairs like
* USD→DJF) is derived from these.
* @default { USD: 162, DJF: 0.92 }
*/ */
fallbackRate?: number; fallbackRates?: Partial<Record<CurrencyCode, number>>;
/** /**
* Reads the persisted fallback rate — the last known good CBE rate, or one * Reads the persisted fallback rate for `code` — the last known good CBE
* set by an operator. Consulted only when the live fetch fails and no cached * rate, or one set by an operator. Consulted only when the live fetch fails
* rate is available; a `null` result falls through to {@link fallbackRate}. * and no cached rate is available; a `null` result falls through to
* {@link fallbackRates}.
* *
* Optional: omit it and the provider uses the static `fallbackRate` alone. * Optional: omit it and the provider uses the static `fallbackRates` alone.
*/ */
loadFallbackRate?: () => Promise<number | null>; loadFallbackRate?: (code: CurrencyCode) => Promise<number | null>;
/** /**
* Persists a freshly fetched live rate as the new fallback, so the stored * Persists a freshly fetched live rate for `code` as the new fallback, so
* value is never more than one successful fetch stale. Called after every * the stored value is never more than one successful fetch stale. Called
* successful fetch that produced a changed rate. * after every successful fetch that produced a changed rate.
* *
* Failures here are logged and swallowed — persisting the fallback must * Failures here are logged and swallowed — persisting the fallback must
* never break the pricing call that triggered it. * never break the pricing call that triggered it.
*/ */
saveFallbackRate?: (rate: number) => Promise<void>; saveFallbackRate?: (code: CurrencyCode, rate: number) => Promise<void>;
/** /**
* How long a successfully fetched rate is cached, in milliseconds. * How long a successfully fetched rate is cached, in milliseconds.
@@ -60,7 +65,7 @@ export type ResolvedExchangeOptions = Required<
export const EXCHANGE_DEFAULTS: ResolvedExchangeOptions = { export const EXCHANGE_DEFAULTS: ResolvedExchangeOptions = {
scrapeUrl: scrapeUrl:
"https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC", "https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
fallbackRate: 162, fallbackRates: { USD: 162, DJF: 0.92 },
cacheTtlMs: 3_600_000, cacheTtlMs: 3_600_000,
requestTimeoutMs: 8_000, requestTimeoutMs: 8_000,
}; };

View File

@@ -2,7 +2,7 @@ import { Inject, Injectable } from "@nestjs/common";
import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider"; import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider";
import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options"; import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options";
import { CurrencyCode } from "./exchange.types"; import { CURRENCY_CODES, CurrencyCode } from "./exchange.types";
/** /**
* Currency exchange service. Resolves the rate between any supported currency * Currency exchange service. Resolves the rate between any supported currency
@@ -12,6 +12,8 @@ import { CurrencyCode } from "./exchange.types";
* 1. `from === to` → `1`. * 1. `from === to` → `1`.
* 2. Provider supplies the pair directly (e.g. CBE → USD→ETB). * 2. Provider supplies the pair directly (e.g. CBE → USD→ETB).
* 3. Provider supplies the inverse → return `1 / inverse` (e.g. ETB→USD). * 3. Provider supplies the inverse → return `1 / inverse` (e.g. ETB→USD).
* 4. Neither leg is quoted directly (e.g. USD→DJF) → pivot through the
* provider's base currency, which quotes both.
* *
* Configure via {@link ExchangeModule.forRoot} / `forRootAsync`. * Configure via {@link ExchangeModule.forRoot} / `forRootAsync`.
*/ */
@@ -42,17 +44,44 @@ export class ExchangeService {
return 1 / inverse; return 1 / inverse;
} }
// Neither leg is quoted directly (e.g. USD↔DJF): pivot through the
// provider's base currency, which quotes both. Mathematically identical
// to converting via that base currency by hand.
const base = this.provider.baseCurrency;
if (from !== base && to !== base) {
const fromToBase = await this.provider.getBaseRate({ from, to: base });
const toToBase = await this.provider.getBaseRate({ from: to, to: base });
if (fromToBase !== null && toToBase !== null && toToBase > 0) {
return fromToBase / toToBase;
}
}
throw new Error( throw new Error(
`No exchange rate available for ${from}${to} from provider ${this.provider.name}`, `No exchange rate available for ${from}${to} from provider ${this.provider.name}`,
); );
} }
/**
* A conversion function for every source currency into `target`, resolved
* up front so a pricing loop never awaits per row. `fx(code)` is `1` for
* `code === target`, throws for a currency the provider cannot rate.
*/
async getRateTable(
target: CurrencyCode,
sources: readonly CurrencyCode[] = CURRENCY_CODES,
): Promise<Record<string, number>> {
const entries = await Promise.all(
sources.map(async (code) => [code, await this.getRate(code, target)] as const),
);
return Object.fromEntries(entries);
}
/** /**
* Health of the underlying rate feed — what was served last and whether it * Health of the underlying rate feed — what was served last and whether it
* is currently failing. For operator-facing status displays. * is currently failing. For operator-facing status displays.
*/ */
getProviderStatus(): CbeProviderStatus { getProviderStatus(code: CurrencyCode = "USD"): CbeProviderStatus {
return this.provider.getStatus(); return this.provider.getStatus(code);
} }
/** Converts `amount` from one currency to another using {@link getRate}. */ /** Converts `amount` from one currency to another using {@link getRate}. */

View File

@@ -1,8 +1,10 @@
/** /**
* ISO-4217 currency codes the exchange service can handle. * ISO-4217 currency codes the exchange service can handle.
* Extend this union as new currencies are supported. * Extend this list as new currencies are supported.
*/ */
export type CurrencyCode = "USD" | "ETB"; export const CURRENCY_CODES = ["ETB", "USD", "DJF"] as const;
export type CurrencyCode = (typeof CURRENCY_CODES)[number];
/** A directional currency pair, e.g. `{ from: 'USD', to: 'ETB' }`. */ /** A directional currency pair, e.g. `{ from: 'USD', to: 'ETB' }`. */
export interface CurrencyPair { export interface CurrencyPair {
@@ -11,18 +13,21 @@ export interface CurrencyPair {
} }
/** /**
* A source of base exchange rates. Implementations fetch (scrape/API) the rate * A source of base exchange rates. Implementations fetch (scrape/API) rates
* for a single canonical direction; the {@link ExchangeService} derives the * quoted against a single canonical base currency; the {@link ExchangeService}
* inverse and same-currency (1:1) cases on top. * derives every other pair — inverse, pivot, same-currency (1:1) on top.
* *
* Today the only implementation is the CBE (Central Bank of Ethiopia) provider, * Today the only implementation is the CBE (Central Bank of Ethiopia) provider,
* which sources USD→ETB. New providers (other banks, other base pairs) can be * which quotes everything against ETB. New providers (other banks, other base
* added without touching consumers. * currencies) can be added without touching consumers.
*/ */
export interface ExchangeRateProvider { export interface ExchangeRateProvider {
/** Human-readable provider name, used in logs (e.g. `'CBE'`). */ /** Human-readable provider name, used in logs (e.g. `'CBE'`). */
readonly name: string; readonly name: string;
/** The currency this provider quotes every other currency against. */
readonly baseCurrency: CurrencyCode;
/** /**
* Returns the rate for `pair` (units of `pair.to` per 1 unit of `pair.from`), * Returns the rate for `pair` (units of `pair.to` per 1 unit of `pair.from`),
* or `null` if this provider cannot supply that pair directly. * or `null` if this provider cannot supply that pair directly.

View File

@@ -8,6 +8,7 @@ export type {
ExchangeAsyncOptions, ExchangeAsyncOptions,
ResolvedExchangeOptions, ResolvedExchangeOptions,
} from "./exchange.options"; } from "./exchange.options";
export { CURRENCY_CODES } from "./exchange.types";
export type { export type {
CurrencyCode, CurrencyCode,
CurrencyPair, CurrencyPair,

View File

@@ -1,10 +1,12 @@
import { Box, Text } from "@mantine/core"; import { Box, Text } from "@mantine/core";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import type { SupportedCurrency } from "../../lib/currency";
export interface CurrencySelectorProps { export interface CurrencySelectorProps {
/** Selected currency code, or "" when none picked yet. */ /** Selected currency code, or "" when none picked yet. */
value: string; value: string;
onChange: (currency: "USD" | "ETB") => void; onChange: (currency: SupportedCurrency) => void;
disabled?: boolean; disabled?: boolean;
/** Validation error shown under the cards. */ /** Validation error shown under the cards. */
error?: string; error?: string;
@@ -14,6 +16,12 @@ export interface CurrencySelectorProps {
* USD is settled by bank transfer, never through the online gateway. * USD is settled by bank transfer, never through the online gateway.
*/ */
allowUsd?: boolean; allowUsd?: boolean;
/**
* Offer DJF alongside ETB (and USD, if also allowed). Same import-only
* gating as `allowUsd` — DJF is settled both online (WAAFI / CAC Bank) and
* by bank transfer.
*/
allowDjf?: boolean;
} }
const ETB_OPTION = { const ETB_OPTION = {
@@ -30,6 +38,13 @@ const USD_OPTION = {
hint: "Paid by bank transfer — send the slip to Finance", hint: "Paid by bank transfer — send the slip to Finance",
} as const; } as const;
const DJF_OPTION = {
code: "DJF",
symbol: "Fdj",
name: "Djibouti Franc",
hint: "Pay online, or by bank transfer — send the slip to Finance",
} as const;
/** /**
* Card-style USD/ETB billing-currency picker. Renders unselected when `value` * Card-style USD/ETB billing-currency picker. Renders unselected when `value`
* is "" so a required choice never looks pre-made. * is "" so a required choice never looks pre-made.
@@ -40,8 +55,13 @@ export function CurrencySelector({
disabled = false, disabled = false,
error, error,
allowUsd = false, allowUsd = false,
allowDjf = false,
}: CurrencySelectorProps) { }: CurrencySelectorProps) {
const options = allowUsd ? [ETB_OPTION, USD_OPTION] : [ETB_OPTION]; const options = [
ETB_OPTION,
...(allowUsd ? [USD_OPTION] : []),
...(allowDjf ? [DJF_OPTION] : []),
];
return ( return (
<Box> <Box>
<Box <Box

View File

@@ -80,3 +80,12 @@ export type {
BookingWindowStateInput, BookingWindowStateInput,
BookingWindowUiState, BookingWindowUiState,
} from "./lib/booking-window-display"; } from "./lib/booking-window-display";
export {
CURRENCY_META,
CURRENCY_CODES,
currencyDecimals,
currencySymbol,
formatCurrency,
} from "./lib/currency";
export type { SupportedCurrency } from "./lib/currency";

View File

@@ -0,0 +1,43 @@
/**
* The currency codes the freight apps support, and how each one displays —
* symbol, full name, decimal places. USD/ETB are conventional 2-decimal
* currencies; DJF (Djibouti Franc) is a zero-decimal currency by convention.
*
* Single source of truth for currency display across both freight web apps —
* import this instead of re-declaring a symbol map or a decimals rule.
*/
export const CURRENCY_META = {
ETB: { symbol: "Br", name: "Ethiopian Birr", decimals: 2 },
USD: { symbol: "$", name: "US Dollar", decimals: 2 },
DJF: { symbol: "Fdj", name: "Djibouti Franc", decimals: 0 },
} as const;
export type SupportedCurrency = keyof typeof CURRENCY_META;
export const CURRENCY_CODES = Object.keys(CURRENCY_META) as SupportedCurrency[];
/** Decimal places to render an amount in `code` with. Defaults to 2 for an unknown code. */
export function currencyDecimals(code?: string | null): number {
const meta = code ? CURRENCY_META[code as SupportedCurrency] : undefined;
return meta?.decimals ?? 2;
}
/** Display symbol for `code`. Falls back to printing the raw code. */
export function currencySymbol(code?: string | null): string {
const meta = code ? CURRENCY_META[code as SupportedCurrency] : undefined;
return meta?.symbol ?? code ?? "";
}
/**
* Format a money amount with its currency symbol, e.g. `Br 12,500.00` or
* `Fdj 2,194,318`. Unknown currency codes fall back to printing the raw code
* with 2 decimals.
*/
export function formatCurrency(amount: number, currency?: string | null): string {
const symbol = currencySymbol(currency);
const decimals = currencyDecimals(currency);
return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}`;
}