mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 03:03:39 +00:00
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export class FilterInvoiceDto {
|
||||
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||
@IsIn(["USD", "ETB"])
|
||||
@IsIn(["ETB", "USD", "DJF"])
|
||||
currency?: "USD" | "ETB";
|
||||
|
||||
@ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
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 { 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`
|
||||
* 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
|
||||
* amount, so a failure here must never break the charge list.
|
||||
*/
|
||||
private async convertAmount(
|
||||
charge: AdditionalCharge,
|
||||
): Promise<{ amount: number; currency: string } | null> {
|
||||
if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null;
|
||||
const target = charge.currency === 'ETB' ? 'USD' : 'ETB';
|
||||
const from = charge.currency?.toUpperCase();
|
||||
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 {
|
||||
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 };
|
||||
} catch (err) {
|
||||
this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`);
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
let service: BookingPricingService;
|
||||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||||
let ratesService: { findLiveRates: jest.Mock };
|
||||
let exchangeService: { getRate: jest.Mock };
|
||||
let exchangeService: { getRate: jest.Mock; getRateTable: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||||
@@ -47,6 +47,13 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
};
|
||||
exchangeService = {
|
||||
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(
|
||||
@@ -324,7 +331,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
||||
})),
|
||||
} 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,
|
||||
{
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
@@ -572,7 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => {
|
||||
} as never,
|
||||
{ findById: jest.fn() } 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,
|
||||
{
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
@@ -707,7 +714,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
|
||||
})),
|
||||
} 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,
|
||||
{ findById: jest.fn() } as never,
|
||||
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { round2 } from '../billing/invoice-settlement.util';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { CurrencyCode, ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
BookingEvaluationInput,
|
||||
@@ -143,8 +143,9 @@ export class BookingPricingService {
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const isEtbBooking = paymentCurrency !== 'USD';
|
||||
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
|
||||
const usdToEtb = fx['USD'];
|
||||
|
||||
// H15: a booking created under a contract prices from that contract's FROZEN
|
||||
// rate snapshots (the agreed rates), not the live rate of the day. Loaded
|
||||
@@ -213,7 +214,7 @@ export class BookingPricingService {
|
||||
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||
const frozen = isDerived
|
||||
? null
|
||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb);
|
||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, fx);
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
@@ -570,8 +571,9 @@ export class BookingPricingService {
|
||||
}> {
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const isEtbBooking = paymentCurrency !== 'USD';
|
||||
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
|
||||
const usdToEtb = fx['USD'];
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
|
||||
const rateType =
|
||||
@@ -608,7 +610,7 @@ export class BookingPricingService {
|
||||
frozenRates,
|
||||
container.containerTypeId,
|
||||
paymentCurrency,
|
||||
usdToEtb,
|
||||
fx,
|
||||
);
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
if (!rate && !frozen) {
|
||||
@@ -698,7 +700,7 @@ export class BookingPricingService {
|
||||
const unitUsd = Number(fallback.rateValue);
|
||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||
const frozen = isBulk
|
||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb)
|
||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, fx)
|
||||
: null;
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
@@ -771,8 +773,9 @@ export class BookingPricingService {
|
||||
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const isEtbBooking = paymentCurrency !== 'USD';
|
||||
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
|
||||
const usdToEtb = fx['USD'];
|
||||
|
||||
const containerCount = evalInput.containers.reduce(
|
||||
(sum, c) => sum + Number(c.quantity || 0),
|
||||
@@ -824,7 +827,7 @@ export class BookingPricingService {
|
||||
frozenRates,
|
||||
leg.rateType,
|
||||
paymentCurrency,
|
||||
usdToEtb,
|
||||
fx,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
@@ -1021,13 +1024,18 @@ export class BookingPricingService {
|
||||
* drifted to.) Grandfathered ETB contracts convert the other way for the same
|
||||
* reason.
|
||||
*
|
||||
* `fx` is a rate table converting FROM each source currency INTO the
|
||||
* booking's currency (see `ExchangeService.getRateTable`) — a snapshot can
|
||||
* be frozen in USD or (grandfathered) ETB, and the booking can be paid in
|
||||
* any supported currency, so a scalar USD→ETB rate is no longer enough.
|
||||
*
|
||||
* Returns null only when there is no snapshot or its price is unusable.
|
||||
*/
|
||||
private frozenRateByCode(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
fx: Record<string, number>,
|
||||
): ContractRateSnapshot | null {
|
||||
const snap = frozenRates?.get(code);
|
||||
if (!snap) return null;
|
||||
@@ -1035,15 +1043,11 @@ export class BookingPricingService {
|
||||
if (!(unitPrice >= 0)) return null;
|
||||
if (snap.currency === bookingCurrency) return snap;
|
||||
|
||||
// Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price.
|
||||
if (!(usdToEtb > 0)) return null;
|
||||
const converted =
|
||||
snap.currency === 'USD' && bookingCurrency === 'ETB'
|
||||
? round2(unitPrice * usdToEtb)
|
||||
: snap.currency === 'ETB' && bookingCurrency === 'USD'
|
||||
? unitPrice / usdToEtb
|
||||
: null;
|
||||
if (converted == null) return null;
|
||||
// A rate of 0/NaN (an unpriced or unsupported source currency) would
|
||||
// silently zero the price.
|
||||
const rate = fx[snap.currency];
|
||||
if (!(rate > 0)) return null;
|
||||
const converted = round2(unitPrice * rate);
|
||||
|
||||
// A copy — the snapshot rows are shared across the pricing pass.
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, {
|
||||
@@ -1061,7 +1065,7 @@ export class BookingPricingService {
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
containerTypeId: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
fx: Record<string, number>,
|
||||
): Promise<ContractRateSnapshot | null> {
|
||||
if (!frozenRates) return null;
|
||||
let sizeFt: number | null = null;
|
||||
@@ -1071,7 +1075,7 @@ export class BookingPricingService {
|
||||
return null;
|
||||
}
|
||||
if (!sizeFt) return null;
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb);
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, fx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1093,9 +1097,9 @@ export class BookingPricingService {
|
||||
const usedRates: Rate[] = [];
|
||||
const blocked: string[] = [];
|
||||
const currency = booking.paymentCurrency;
|
||||
const isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||||
const fx = await this.exchangeService.getRateTable(currency as CurrencyCode);
|
||||
const usdToEtb = fx['USD'];
|
||||
const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToEtb));
|
||||
|
||||
// An Ethiopian-side-only customs service prices off its own rate; the
|
||||
// contract froze its snapshots under the matching code prefix. Resolved by
|
||||
@@ -1132,7 +1136,7 @@ export class BookingPricingService {
|
||||
const hasPerSizeSnapshot =
|
||||
frozenRates?.has(`${customsType}_20FT`) ||
|
||||
frozenRates?.has(`${customsType}_40FT`);
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, fx);
|
||||
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||
const amount = Number(legacyFlat.unitPrice);
|
||||
if (amount > 0) {
|
||||
@@ -1161,7 +1165,7 @@ export class BookingPricingService {
|
||||
// unknown type — falls through to the live per-type lookup below
|
||||
}
|
||||
const frozen = sizeFt
|
||||
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
|
||||
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, fx)
|
||||
: null;
|
||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||
if (!frozen && !live) {
|
||||
@@ -1196,7 +1200,7 @@ export class BookingPricingService {
|
||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||
// commodity-less rate (legacy) is the catch-all fallback.
|
||||
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
||||
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, fx);
|
||||
const live =
|
||||
(booking.cargoTypeId
|
||||
? onLeg.find(
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
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 { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||
|
||||
@@ -114,6 +114,15 @@ interface PricedFee {
|
||||
* The cycle is repeatable by construction: the rebooked booking is a normal
|
||||
* 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()
|
||||
export class BookingWagonCancellationService {
|
||||
private readonly logger = new Logger(BookingWagonCancellationService.name);
|
||||
@@ -1697,10 +1706,10 @@ export class BookingWagonCancellationService {
|
||||
*/
|
||||
private async priceFee(booking: Booking, cut: RequestedCut): Promise<PricedFee> {
|
||||
const raw = await this.priceFeeInRateCurrency(booking, cut);
|
||||
// Bill in the booking's own currency (rates are configured in USD; ETB
|
||||
// bookings pay ETB) — same USD→ETB conversion booking pricing applies.
|
||||
const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
|
||||
const from = raw.currency === 'ETB' ? 'ETB' : 'USD';
|
||||
// Bill in the booking's own currency (rates are configured in USD; a
|
||||
// non-USD booking converts) — same conversion booking pricing applies.
|
||||
const target = toCurrencyCode(booking.paymentCurrency);
|
||||
const from = toCurrencyCode(raw.currency);
|
||||
if (from === target) return raw;
|
||||
const fx = await this.exchangeService.getRate(from, target);
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
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 { Contract } from './entities/contract.entity';
|
||||
|
||||
@@ -95,9 +95,9 @@ export class ContractPricingService {
|
||||
(r) => !r.shippingLineCompanyId,
|
||||
);
|
||||
const currency = contract.paymentCurrency;
|
||||
const isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||||
const usdToTarget =
|
||||
currency === 'USD' ? 1 : await this.exchangeService.getRate('USD', currency as CurrencyCode);
|
||||
const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToTarget));
|
||||
|
||||
const lineItems: ContractUnitRateLineItem[] = [];
|
||||
const baseType = this.baseRateType(contract);
|
||||
|
||||
@@ -98,7 +98,7 @@ export class CreateBookingRequestDto {
|
||||
'Billing currency for the shipment GL will book. Intercity is always ETB.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['ETB', 'USD'])
|
||||
@IsIn(['ETB', 'USD', 'DJF'])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -20,7 +20,7 @@ const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot =>
|
||||
const frozenByCode = (
|
||||
snap: ContractRateSnapshot | null,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
fx: Record<string, number>,
|
||||
): ContractRateSnapshot | null =>
|
||||
(
|
||||
BookingPricingService.prototype as unknown as {
|
||||
@@ -28,14 +28,14 @@ const frozenByCode = (
|
||||
m: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
fx: Record<string, number>,
|
||||
) => ContractRateSnapshot | null;
|
||||
}
|
||||
).frozenRateByCode(
|
||||
snap ? new Map([['CONTAINER_20FT', snap]]) : null,
|
||||
'CONTAINER_20FT',
|
||||
bookingCurrency,
|
||||
usdToEtb,
|
||||
fx,
|
||||
);
|
||||
|
||||
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', () => {
|
||||
// The old behaviour returned null here, which silently re-priced the
|
||||
// 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', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
// Converting with 0 would zero the whole line.
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull();
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull();
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 0 })).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', () => {
|
||||
expect(frozenByCode(null, 'ETB', 150)).toBeNull();
|
||||
expect(frozenByCode(null, 'ETB', { USD: 150 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
* rate but far short of a fat-fingered magnitude error — this value multiplies
|
||||
* real invoice amounts whenever CBE is unreachable.
|
||||
* Operator-set X→ETB fallback for one currency. The upper bound is enforced
|
||||
* per currency in the controller (see `RATE_BOUNDS`) rather than here, since
|
||||
* 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 {
|
||||
@IsNumber({ maxDecimalPlaces: 6 })
|
||||
@Min(1)
|
||||
@Max(10_000)
|
||||
@Min(0.000001)
|
||||
fallbackRate!: number;
|
||||
}
|
||||
|
||||
@@ -8,14 +8,18 @@ import { Column, Entity } from "typeorm";
|
||||
export type ExchangeFallbackSource = "AUTO" | "MANUAL";
|
||||
|
||||
/**
|
||||
* Single-row table holding the USD→ETB fallback used when the CBE endpoint is
|
||||
* unreachable. The live CBE rate always wins; this is only consulted on
|
||||
* failure, and is overwritten by every successful fetch so it tracks the last
|
||||
* known good rate.
|
||||
* One row per foreign currency, holding the X→ETB fallback used when the CBE
|
||||
* endpoint is unreachable for that currency. The live CBE rate always wins;
|
||||
* this is only consulted on failure, and is overwritten by every successful
|
||||
* fetch so it tracks the last known good rate.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "exchange_settings" })
|
||||
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({
|
||||
name: "fallback_rate",
|
||||
type: "numeric",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* warehouses), so this keeps the three call sites identical rather than
|
||||
@@ -20,8 +20,8 @@ export function registerExchangeModule(): DynamicModule {
|
||||
settings: ExchangeSettingsService,
|
||||
): ExchangeOptions => ({
|
||||
...(config.get<ExchangeOptions>("app.cbeExchange") ?? {}),
|
||||
loadFallbackRate: () => settings.loadFallbackRate(),
|
||||
saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate),
|
||||
loadFallbackRate: (code) => settings.loadFallbackRate(code),
|
||||
saveFallbackRate: (code, rate) => settings.saveFallbackRate(code, rate),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 provider’s 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);
|
||||
});
|
||||
});
|
||||
@@ -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 { 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 { 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 { 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")
|
||||
@ApiBearerAuth()
|
||||
@Controller("exchange-settings")
|
||||
@@ -17,37 +42,51 @@ export class ExchangeSettingsController {
|
||||
@Get()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin])
|
||||
@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() {
|
||||
const setting = await this.service.get();
|
||||
const status = this.service.getFeedStatus();
|
||||
async list() {
|
||||
const settings = await this.service.list();
|
||||
const byCurrency = new Map(settings.map((s) => [s.currency, s]));
|
||||
|
||||
return {
|
||||
fallbackRate: setting.fallbackRate,
|
||||
fallbackSource: setting.fallbackSource,
|
||||
lastSyncedAt: setting.lastSyncedAt,
|
||||
updatedById: setting.updatedById,
|
||||
feed: status,
|
||||
};
|
||||
return FOREIGN_CURRENCIES.map((code) => {
|
||||
const setting = byCurrency.get(code);
|
||||
return {
|
||||
currency: code,
|
||||
fallbackRate: setting?.fallbackRate ?? null,
|
||||
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])
|
||||
@ApiOperation({
|
||||
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(
|
||||
@Param("currency") currency: string,
|
||||
@Body() dto: UpdateExchangeSettingDto,
|
||||
@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(
|
||||
code,
|
||||
dto.fallbackRate,
|
||||
user?.id ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
currency: updated.currency,
|
||||
fallbackRate: updated.fallbackRate,
|
||||
fallbackSource: updated.fallbackSource,
|
||||
lastSyncedAt: updated.lastSyncedAt,
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { CurrencyCode } from "@edr/api-common";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ExchangeSetting } from "./entities/exchange-setting.entity";
|
||||
|
||||
/**
|
||||
* Rate used before the row exists and before the first successful CBE fetch —
|
||||
* the CBE USD transactional selling rate on 2026-08-04.
|
||||
* Rate used before a currency's row exists and before its first successful
|
||||
* 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 {
|
||||
/** Rate most recently observed, whatever its source. */
|
||||
rate: number | null;
|
||||
@@ -22,9 +28,17 @@ export interface ExchangeFeedStatus {
|
||||
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
|
||||
* CBE endpoint is unreachable.
|
||||
* Owns the `exchange_settings` rows — one per foreign currency (USD, DJF) —
|
||||
* 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,
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* Feed health, recorded from the exchange provider's callbacks rather than
|
||||
* read off an injected `ExchangeService`. The provider is registered several
|
||||
* times (bookings, contracts, warehouses), so no single instance sees every
|
||||
* fetch — and injecting one here would be circular, since those
|
||||
* registrations inject *this* service.
|
||||
* Feed health per currency, recorded from the exchange provider's
|
||||
* callbacks rather than read off an injected `ExchangeService`. The
|
||||
* provider is registered several times (bookings, contracts, warehouses),
|
||||
* so no single instance sees every fetch — and injecting one here would be
|
||||
* circular, since those registrations inject *this* service.
|
||||
*/
|
||||
private feed: ExchangeFeedStatus = {
|
||||
rate: null,
|
||||
source: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
private feed = new Map<string, ExchangeFeedStatus>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ExchangeSetting)
|
||||
private readonly repository: Repository<ExchangeSetting>,
|
||||
) {}
|
||||
|
||||
/** Health of the CBE feed as last observed by any provider instance. */
|
||||
getFeedStatus(): ExchangeFeedStatus {
|
||||
return { ...this.feed };
|
||||
/** Health of the CBE feed for `code` as last observed by any provider instance. */
|
||||
getFeedStatus(code: CurrencyCode): ExchangeFeedStatus {
|
||||
return { ...(this.feed.get(code) ?? EMPTY_FEED_STATUS) };
|
||||
}
|
||||
|
||||
/** The settings row, created at the seed rate on first access. */
|
||||
async get(): Promise<ExchangeSetting> {
|
||||
const existing = await this.repository.findOne({ where: {} });
|
||||
/** The settings row for `code`, created at the seed rate on first access. */
|
||||
async get(code: CurrencyCode): Promise<ExchangeSetting> {
|
||||
const existing = await this.repository.findOne({ where: { currency: code } });
|
||||
if (existing) return existing;
|
||||
|
||||
return this.repository.save(
|
||||
this.repository.create({
|
||||
fallbackRate: SEED_FALLBACK_RATE,
|
||||
currency: code,
|
||||
fallbackRate: SEED_FALLBACK_RATES[code] ?? 1,
|
||||
fallbackSource: "AUTO",
|
||||
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
|
||||
* failure so the provider falls through to its own static default rather
|
||||
* than propagating a database error into a pricing call.
|
||||
* Reads the stored fallback for `code`, for the exchange provider. Returns
|
||||
* `null` on any failure so the provider falls through to its own static
|
||||
* 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
|
||||
// signal that the feed is down.
|
||||
// signal that the feed is down for this currency.
|
||||
try {
|
||||
const { fallbackRate } = await this.get();
|
||||
const { fallbackRate } = await this.get(code);
|
||||
const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
|
||||
this.feed = {
|
||||
...this.feed,
|
||||
rate: usable ? fallbackRate : this.feed.rate,
|
||||
const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS;
|
||||
this.feed.set(code, {
|
||||
...previous,
|
||||
rate: usable ? fallbackRate : previous.rate,
|
||||
source: "stored",
|
||||
lastError: this.feed.lastError ?? "CBE endpoint unreachable",
|
||||
};
|
||||
lastError: previous.lastError ?? "CBE endpoint unreachable",
|
||||
});
|
||||
return usable ? fallbackRate : null;
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
this.feed = { ...this.feed, source: "stored", lastError: message };
|
||||
this.logger.warn(`Could not read stored exchange fallback: ${message}`);
|
||||
const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS;
|
||||
this.feed.set(code, { ...previous, source: "stored", lastError: message });
|
||||
this.logger.warn(
|
||||
`Could not read stored exchange fallback for ${code}: ${message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a freshly fetched live rate as the new fallback. Marked `AUTO`,
|
||||
* overwriting a manual entry — a manual rate is a stopgap for while CBE is
|
||||
* down, so a working CBE feed takes precedence again.
|
||||
* Records a freshly fetched live rate as the new fallback for `code`.
|
||||
* Marked `AUTO`, overwriting a manual entry — a manual rate is a stopgap
|
||||
* 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.
|
||||
this.feed = {
|
||||
this.feed.set(code, {
|
||||
rate,
|
||||
source: "live",
|
||||
lastSuccessAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
};
|
||||
});
|
||||
|
||||
const current = await this.get();
|
||||
const current = await this.get(code);
|
||||
await this.repository.update(current.id, {
|
||||
fallbackRate: rate,
|
||||
fallbackSource: "AUTO",
|
||||
lastSyncedAt: new Date(),
|
||||
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. */
|
||||
async setManualRate(
|
||||
code: CurrencyCode,
|
||||
rate: number,
|
||||
updatedById?: string | null,
|
||||
): Promise<ExchangeSetting> {
|
||||
const current = await this.get();
|
||||
const current = await this.get(code);
|
||||
await this.repository.update(current.id, {
|
||||
fallbackRate: rate,
|
||||
fallbackSource: "MANUAL",
|
||||
updatedById: updatedById ?? null,
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export const contractsDataset: ExportDataset = {
|
||||
{ key: 'paymentCurrency', label: 'Currency', type: 'select', options: [
|
||||
{ value: 'ETB', label: 'ETB' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'DJF', label: 'DJF' },
|
||||
] },
|
||||
{ key: 'serviceTypeId', label: 'Service type', type: 'text' },
|
||||
// Routes are one-to-many on contract_routes, so these filter via EXISTS
|
||||
|
||||
@@ -129,6 +129,7 @@ export const invoicesDataset: ExportDataset = {
|
||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||
{ value: 'ETB', label: 'ETB' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'DJF', label: 'DJF' },
|
||||
] },
|
||||
{ key: 'minAmount', label: 'Min total', type: 'text' },
|
||||
{ key: 'maxAmount', label: 'Max total', type: 'text' },
|
||||
|
||||
@@ -89,6 +89,7 @@ export const paymentsDataset: ExportDataset = {
|
||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||
{ value: 'ETB', label: 'ETB' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'DJF', label: 'DJF' },
|
||||
] },
|
||||
{ key: 'search', label: 'Search order or transaction ID', type: 'text' },
|
||||
],
|
||||
|
||||
@@ -36,6 +36,7 @@ export class OverviewCustomerKpisDto {
|
||||
export class OverviewBillingKpisDto {
|
||||
@ApiProperty() revenueMtdEtb!: number;
|
||||
@ApiProperty() revenueMtdUsd!: number;
|
||||
@ApiProperty() revenueMtdDjf!: number;
|
||||
@ApiProperty() pendingPayments!: number;
|
||||
@ApiProperty() successfulPaymentsMtd!: number;
|
||||
}
|
||||
@@ -84,6 +85,7 @@ export class OverviewPaymentTrendPointDto {
|
||||
@ApiProperty({ example: '2026-06-01' }) date!: string;
|
||||
@ApiProperty() amountEtb!: number;
|
||||
@ApiProperty() amountUsd!: number;
|
||||
@ApiProperty() amountDjf!: number;
|
||||
}
|
||||
|
||||
export class OverviewRecentBookingDto {
|
||||
@@ -113,6 +115,7 @@ export class OverviewPeriodTotalsDto {
|
||||
@ApiProperty() bookingsCreated!: number;
|
||||
@ApiProperty() revenueEtb!: number;
|
||||
@ApiProperty() revenueUsd!: number;
|
||||
@ApiProperty() revenueDjf!: number;
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
@@ -120,6 +123,7 @@ export class OverviewRevenueSliceDto {
|
||||
@ApiProperty() label!: string;
|
||||
@ApiProperty() amountEtb!: number;
|
||||
@ApiProperty() amountUsd!: number;
|
||||
@ApiProperty() amountDjf!: number;
|
||||
}
|
||||
|
||||
export class OverviewTonsTrendPointDto {
|
||||
@@ -132,6 +136,7 @@ export class OverviewRevenueFlowDto {
|
||||
@ApiProperty() freightType!: string;
|
||||
@ApiProperty() amountEtb!: number;
|
||||
@ApiProperty() amountUsd!: number;
|
||||
@ApiProperty() amountDjf!: number;
|
||||
}
|
||||
|
||||
export class OverviewHeatmapCellDto {
|
||||
|
||||
@@ -265,6 +265,7 @@ export class OverviewRepository {
|
||||
async getBillingKpis(dirs?: string[]): Promise<{
|
||||
revenueMtdEtb: number;
|
||||
revenueMtdUsd: number;
|
||||
revenueMtdDjf: number;
|
||||
pendingPayments: number;
|
||||
successfulPaymentsMtd: number;
|
||||
}> {
|
||||
@@ -279,6 +280,10 @@ export class OverviewRepository {
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"revenueMtdUsd",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||
"revenueMtdDjf",
|
||||
)
|
||||
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
@@ -298,6 +303,7 @@ export class OverviewRepository {
|
||||
return {
|
||||
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
|
||||
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
|
||||
revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0),
|
||||
pendingPayments,
|
||||
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
|
||||
};
|
||||
@@ -370,7 +376,7 @@ export class OverviewRepository {
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
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 rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
@@ -386,6 +392,10 @@ export class OverviewRepository {
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||
"amountDjf",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.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`,
|
||||
@@ -394,12 +404,13 @@ export class OverviewRepository {
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||
.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) => ({
|
||||
date: row.date,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
amountDjf: Number(row.amountDjf),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -510,7 +521,7 @@ export class OverviewRepository {
|
||||
async getPaymentsByMethod(
|
||||
dirs?: string[],
|
||||
): 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 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)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`,
|
||||
"amountDjf",
|
||||
)
|
||||
.where(scope.sql, scope.params)
|
||||
.groupBy("payment.method")
|
||||
.orderBy("count", "DESC")
|
||||
@@ -533,6 +548,7 @@ export class OverviewRepository {
|
||||
count: string;
|
||||
amountEtb: string;
|
||||
amountUsd: string;
|
||||
amountDjf: string;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -540,6 +556,7 @@ export class OverviewRepository {
|
||||
count: Number(row.count),
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
amountDjf: Number(row.amountDjf),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -580,6 +597,7 @@ export class OverviewRepository {
|
||||
bookingsCreated: number;
|
||||
revenueEtb: number;
|
||||
revenueUsd: number;
|
||||
revenueDjf: number;
|
||||
tons: number;
|
||||
}> {
|
||||
const bookingScope = directionScopeSql("booking.trade_direction", dirs);
|
||||
@@ -605,13 +623,17 @@ export class OverviewRepository {
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"revenueUsd",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||
"revenueDjf",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
windowSql("COALESCE(payment.paid_at, payment.created_at)"),
|
||||
{ days, offsetDays },
|
||||
)
|
||||
.andWhere(paymentScope.sql, paymentScope.params)
|
||||
.getRawOne<{ revenueEtb: string; revenueUsd: string }>(),
|
||||
.getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(),
|
||||
this.cargoRepository
|
||||
.createQueryBuilder("cargo")
|
||||
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
||||
@@ -626,6 +648,7 @@ export class OverviewRepository {
|
||||
bookingsCreated,
|
||||
revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
|
||||
revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
|
||||
revenueDjf: Number(revenueRow?.revenueDjf ?? 0),
|
||||
tons: Number(tonsRow?.tons ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -634,7 +657,7 @@ export class OverviewRepository {
|
||||
async getRevenueByDirection(
|
||||
days: number,
|
||||
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 rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
@@ -648,6 +671,10 @@ export class OverviewRepository {
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||
"amountDjf",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`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("booking.trade_direction IS NOT NULL")
|
||||
.groupBy("booking.trade_direction")
|
||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
amountDjf: Number(row.amountDjf),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -669,7 +697,7 @@ export class OverviewRepository {
|
||||
async getRevenueByFreightType(
|
||||
days: number,
|
||||
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 rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
@@ -683,6 +711,10 @@ export class OverviewRepository {
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||
"amountDjf",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`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("booking.freight_type IS NOT NULL")
|
||||
.groupBy("booking.freight_type")
|
||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
amountDjf: Number(row.amountDjf),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -734,6 +767,7 @@ export class OverviewRepository {
|
||||
freightType: string;
|
||||
amountEtb: number;
|
||||
amountUsd: number;
|
||||
amountDjf: number;
|
||||
}[]
|
||||
> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
@@ -750,6 +784,10 @@ export class OverviewRepository {
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||
"amountDjf",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
@@ -765,6 +803,7 @@ export class OverviewRepository {
|
||||
freightType: string;
|
||||
amountEtb: string;
|
||||
amountUsd: string;
|
||||
amountDjf: string;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -772,6 +811,7 @@ export class OverviewRepository {
|
||||
freightType: row.freightType,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
amountDjf: Number(row.amountDjf),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
usdEnabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
djfEnabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ export class ManualPaymentSetting extends BaseEntity {
|
||||
@Column({ name: "usd_enabled", type: "boolean", default: true })
|
||||
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. */
|
||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||
updatedById?: string | null;
|
||||
|
||||
@@ -4,16 +4,23 @@ import { Repository } from "typeorm";
|
||||
|
||||
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
|
||||
|
||||
/** The two currencies an invoice can be settled by hand in. */
|
||||
export type ManualPaymentCurrency = "ETB" | "USD";
|
||||
/** The currencies an invoice can be settled by hand in. */
|
||||
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
|
||||
* invoices by hand, per currency.
|
||||
*
|
||||
* 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
|
||||
* the new capability and starts OFF, so enabling it is a deliberate act.
|
||||
* and DJF have always been bank-transfer-capable so they start ON; ETB manual
|
||||
* settlement is the new capability and starts OFF, so enabling it is a
|
||||
* deliberate act.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ManualPaymentSettingsService {
|
||||
@@ -30,7 +37,7 @@ export class ManualPaymentSettingsService {
|
||||
if (existing) return existing;
|
||||
|
||||
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[] = [];
|
||||
if (setting.etbEnabled) enabled.push("ETB");
|
||||
if (setting.usdEnabled) enabled.push("USD");
|
||||
if (setting.djfEnabled) enabled.push("DJF");
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Whether one currency may be settled by hand right now. */
|
||||
async isEnabled(currency: string | null | undefined): Promise<boolean> {
|
||||
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();
|
||||
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(
|
||||
patch: { etbEnabled?: boolean; usdEnabled?: boolean },
|
||||
patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean },
|
||||
updatedById?: string | null,
|
||||
): Promise<ManualPaymentSetting> {
|
||||
const current = await this.get();
|
||||
await this.repository.update(current.id, {
|
||||
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
|
||||
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
|
||||
...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }),
|
||||
updatedById: updatedById ?? null,
|
||||
});
|
||||
const updated = await this.get();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||
type PaymentType = string
|
||||
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"
|
||||
|
||||
@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"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
||||
@Column({ type: "enum", enum: ["ETB", "USD", "DJF"] })
|
||||
currency!: Currency
|
||||
|
||||
@Column({ type: "numeric" })
|
||||
|
||||
@@ -466,6 +466,7 @@ export const CURRENCY_FILTER: ReportFilterDef = {
|
||||
options: [
|
||||
{ value: 'ETB', label: 'ETB' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'DJF', label: 'DJF' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GenerateInvoiceDto {
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' })
|
||||
@IsOptional()
|
||||
@IsIn(['ETB', 'USD'])
|
||||
@IsIn(['ETB', 'USD', 'DJF'])
|
||||
billingCurrency?: 'ETB' | 'USD';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
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 { DataSource } from 'typeorm';
|
||||
|
||||
@@ -430,8 +430,11 @@ export class WarehouseFeeService {
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
|
||||
return currency === 'ETB' ? 'ETB' : 'USD';
|
||||
private normalizeCurrency(currency?: string | null): CurrencyCode {
|
||||
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> {
|
||||
|
||||
@@ -36,7 +36,7 @@ import { downloadBookingFile, fetchViewableFile } from "@/services/files.service
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const CURRENCIES = ["ETB", "USD"];
|
||||
const CURRENCIES = ["ETB", "USD", "DJF"];
|
||||
|
||||
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
|
||||
DRAFT: { label: "Draft", color: "gray" },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
|
||||
import { Coins, Truck } from "lucide-react";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
|
||||
@@ -12,8 +13,8 @@ import { MetricTile } from "./MetricTile";
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: currencyDecimals(currency),
|
||||
maximumFractionDigits: currencyDecimals(currency),
|
||||
})} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
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 as rpc } from "@/services/api";
|
||||
@@ -223,7 +223,7 @@ export function RebookWagonCancellationModal({
|
||||
<Text size="sm">
|
||||
{cancellation.booking?.reference ?? cancellation.bookingId} ·{" "}
|
||||
{cancellation.wagonsCancelled} wagon(s) · credit{" "}
|
||||
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)}
|
||||
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, currencyDecimals(cancellation.feeCurrency))}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
Shipment day
|
||||
|
||||
@@ -8,6 +8,7 @@ import { api } from "@/auth/http";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal";
|
||||
import {
|
||||
canRebookWagonCancellations,
|
||||
@@ -73,7 +74,7 @@ export function WagonCancellationCreditCard({
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{Number(r.wagonsCancelled)} wagon(s) · credit{" "}
|
||||
{formatMoney(Number(r.creditAmount), r.feeCurrency, 2)}
|
||||
{formatMoney(Number(r.creditAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))}
|
||||
</Text>
|
||||
<Badge color={chip.color} variant="light" size="sm" radius="md">
|
||||
{chip.label}
|
||||
@@ -83,7 +84,7 @@ export function WagonCancellationCreditCard({
|
||||
Cancelled {formatDate(r.createdAt)}
|
||||
{r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""}
|
||||
{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"
|
||||
}`
|
||||
: ""}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const CURRENCIES = ["ETB", "USD"];
|
||||
const CURRENCIES = ["ETB", "USD", "DJF"];
|
||||
|
||||
const STATUS_META: Record<
|
||||
Freight.ClearanceChargeStatus,
|
||||
|
||||
@@ -345,7 +345,7 @@ export default function GlCreateBookingForm() {
|
||||
const [notes, setNotes] = useState("");
|
||||
// IMPORT bookings pick ETB or USD — starts empty so the choice is
|
||||
// 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).
|
||||
const [cargoDescription, setCargoDescription] = useState("");
|
||||
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.
|
||||
const effectiveCurrency: "USD" | "ETB" =
|
||||
const effectiveCurrency: "USD" | "ETB" | "DJF" =
|
||||
isImport && paymentCurrency ? paymentCurrency : "ETB";
|
||||
const currencyError =
|
||||
isImport && !paymentCurrency
|
||||
@@ -2351,7 +2351,7 @@ export default function GlCreateBookingForm() {
|
||||
{requestCurrencyLocked
|
||||
? "The customer chose the billing currency on the shipment request — it cannot be changed."
|
||||
: 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."}
|
||||
</Text>
|
||||
<CurrencySelector
|
||||
@@ -2359,6 +2359,7 @@ export default function GlCreateBookingForm() {
|
||||
onChange={setPaymentCurrency}
|
||||
disabled={!isImport || requestCurrencyLocked}
|
||||
allowUsd={isImport}
|
||||
allowDjf={isImport}
|
||||
error={currencyError}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1554,7 +1554,7 @@ function SecondDutyStep({
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
data={["ETB", "USD", "DJF"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
@@ -1944,7 +1944,7 @@ function DraftDeclarationStep({
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
data={["ETB", "USD", "DJF"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
@@ -2063,7 +2063,7 @@ function DutyStep({
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
data={["ETB", "USD", "DJF"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
|
||||
@@ -54,7 +54,7 @@ export function AdviseDutyCard({
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
data={["ETB", "USD", "DJF"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
|
||||
@@ -18,7 +18,7 @@ function formatDateLabel(date: string) {
|
||||
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", {
|
||||
style: "currency",
|
||||
currency,
|
||||
|
||||
@@ -9,10 +9,9 @@ import { SummaryCard } from "./summary/SummaryCard";
|
||||
|
||||
function formatAmount(amount: number | null, currency: string | null) {
|
||||
if (amount == null) return "—";
|
||||
const code = currency === "USD" ? "USD" : "ETB";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
currency: currency || "ETB",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { KpiStrip, type KpiItem } from "@/components/page";
|
||||
import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview";
|
||||
import { CountUp } from "./CountUp";
|
||||
|
||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
function formatCurrency(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "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. */
|
||||
function formatCompactCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
function formatCompactCurrency(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
import { OverviewPaymentChart } from "../OverviewPaymentChart";
|
||||
import { overviewChartColors } from "../overview.styles";
|
||||
|
||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
function formatCurrency(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
|
||||
import { currencyDecimals } from '@edr/ui-common';
|
||||
|
||||
import { useAccrualDashboard } from '@/hooks/useWarehouses';
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -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. */
|
||||
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
|
||||
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 { data, isLoading } = useQuery(
|
||||
api.warehouses.feePreview.queryOptions({
|
||||
@@ -211,10 +211,11 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={billingCurrency}
|
||||
onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD')}
|
||||
onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD' | 'DJF')}
|
||||
data={[
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'ETB', label: 'Birr' },
|
||||
{ value: 'DJF', label: 'DJF' },
|
||||
]}
|
||||
disabled={Boolean(activeInvoice)}
|
||||
/>
|
||||
|
||||
@@ -7,10 +7,11 @@ import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
const QUERY_KEY = ["exchangeSettings"];
|
||||
|
||||
/** One row per foreign currency (USD, DJF, …) — see `exchangeSettingsService.list`. */
|
||||
export const useExchangeSettingsQuery = () =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: () => exchangeSettingsService.get(),
|
||||
queryFn: () => exchangeSettingsService.list(),
|
||||
// Feed health is only interesting while it is being looked at.
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: true,
|
||||
@@ -22,7 +23,8 @@ export const useSetExchangeFallbackRate = () => {
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate),
|
||||
mutationFn: ({ currency, rate }: { currency: string; rate: number }) =>
|
||||
exchangeSettingsService.setFallbackRate(currency, rate),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
toast.success(
|
||||
|
||||
@@ -24,7 +24,9 @@ export const useUpdateManualPaymentSettings = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (
|
||||
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
|
||||
patch: Partial<
|
||||
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
|
||||
>,
|
||||
) => manualPaymentSettingsService.update(patch),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
|
||||
|
||||
@@ -221,7 +221,7 @@ export function useOnTimeDispatch() {
|
||||
}
|
||||
|
||||
/** Live per-item fee accrual (storage/demurrage) with alerts. */
|
||||
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
||||
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD' | 'DJF') {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
||||
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
||||
@@ -598,7 +598,7 @@ export const useUpdateFeeRule = () =>
|
||||
export const useDeleteFeeRule = () =>
|
||||
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({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency],
|
||||
queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data),
|
||||
@@ -649,7 +649,7 @@ export function useGenerateInvoice() {
|
||||
}: {
|
||||
inventoryId: string;
|
||||
confirmZero?: boolean;
|
||||
billingCurrency?: 'ETB' | 'USD';
|
||||
billingCurrency?: 'ETB' | 'USD' | 'DJF';
|
||||
}) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data),
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsT
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import {
|
||||
@@ -254,7 +255,7 @@ export default function BookingRequestDetailPage() {
|
||||
const kpis: KpiItem[] = [
|
||||
{
|
||||
label: "Total value",
|
||||
value: formatMoney(amount, booking.paymentCurrency, 2),
|
||||
value: formatMoney(amount, booking.paymentCurrency, currencyDecimals(booking.paymentCurrency)),
|
||||
hint: booking.paymentStatus,
|
||||
icon: Wallet,
|
||||
color: "edr-green",
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { toDayString } from "@/hooks/useListControls";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -166,7 +167,7 @@ export default function WagonCancellationsPage() {
|
||||
header: () => <span>Fee</span>,
|
||||
cell: ({ row }) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
@@ -175,7 +176,7 @@ export default function WagonCancellationsPage() {
|
||||
header: () => <span>Credit</span>,
|
||||
cell: ({ row }) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
@@ -347,7 +348,7 @@ export default function WagonCancellationsPage() {
|
||||
<Text size="sm">
|
||||
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
|
||||
{voiding.wagonsCancelled} wagon(s) · fee{" "}
|
||||
{formatMoney(voiding.feeAmount, voiding.feeCurrency, 2)}
|
||||
{formatMoney(voiding.feeAmount, voiding.feeCurrency, currencyDecimals(voiding.feeCurrency))}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
The pending fee is dropped and the wagons stay on the booking.
|
||||
|
||||
@@ -82,6 +82,7 @@ const CONTRACT_KIND_OPTIONS = [
|
||||
const CURRENCY_OPTIONS = [
|
||||
{ value: "ETB", label: "ETB" },
|
||||
{ value: "USD", label: "USD" },
|
||||
{ value: "DJF", label: "DJF" },
|
||||
];
|
||||
|
||||
/** value = `${sortBy}:${sortOrder}` for the sort Select. */
|
||||
|
||||
@@ -279,6 +279,7 @@ const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
|
||||
<Group gap="lg" mt={6}>
|
||||
<Radio value="ETB" label="ETB" />
|
||||
<Radio value="USD" label="USD" />
|
||||
<Radio value="DJF" label="DJF" />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -66,6 +66,7 @@ const INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||
options: [
|
||||
{ value: "ETB", label: "ETB" },
|
||||
{ value: "USD", label: "USD" },
|
||||
{ value: "DJF", label: "DJF" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -235,8 +236,23 @@ export default function InvoicesPanel() {
|
||||
const { data: exchangeSettings } = useExchangeSettingsQuery();
|
||||
const etbCollected = summary?.ETB ?? 0;
|
||||
const usdCollected = summary?.USD ?? 0;
|
||||
const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate;
|
||||
const etbFromUsd = rate ? usdCollected * rate : null;
|
||||
const djfCollected = summary?.DJF ?? 0;
|
||||
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(
|
||||
() => [
|
||||
@@ -356,8 +372,8 @@ export default function InvoicesPanel() {
|
||||
items={[
|
||||
{
|
||||
label: "Total collected",
|
||||
hint: etbFromUsd !== null ? "ETB + USD" : "ETB only",
|
||||
value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"),
|
||||
hint: totalHint,
|
||||
value: formatMoney(totalEtb, "ETB"),
|
||||
icon: CircleDollarSign,
|
||||
color: "edr-green",
|
||||
},
|
||||
@@ -373,6 +389,12 @@ export default function InvoicesPanel() {
|
||||
icon: Landmark,
|
||||
color: "violet",
|
||||
},
|
||||
{
|
||||
label: "Collected in DJF",
|
||||
value: formatMoney(djfCollected, "DJF"),
|
||||
icon: Landmark,
|
||||
color: "orange",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ function ConfirmCell({
|
||||
export default function UsdPaymentsPanel({
|
||||
currency,
|
||||
}: {
|
||||
currency: "USD" | "ETB";
|
||||
currency: "USD" | "ETB" | "DJF";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
// Namespaced: the ETB and USD tabs share this panel and live on the same URL
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
|
||||
import {
|
||||
@@ -149,7 +150,7 @@ export default function PaymentsPanel() {
|
||||
header: () => <span className={tableHeader}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -450,6 +450,7 @@ export const rateUnitOptions = (
|
||||
const CURRENCIES = [
|
||||
{ label: "ETB (Birr)", value: "ETB" },
|
||||
{ label: "USD", value: "USD" },
|
||||
{ label: "DJF", value: "DJF" },
|
||||
];
|
||||
|
||||
const PRIORITY_CONFIG_TYPES = [
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
useExchangeSettingsQuery,
|
||||
useSetExchangeFallbackRate,
|
||||
} from "@/hooks/useExchangeSettings";
|
||||
import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
|
||||
import type {
|
||||
ExchangeRateSource,
|
||||
ExchangeSetting,
|
||||
} from "@/services/exchangeSettings.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
/** Feed health, phrased for an operator rather than a developer. */
|
||||
@@ -38,40 +41,121 @@ function feedLabel(source: ExchangeRateSource | null): {
|
||||
const formatTime = (value: string | null) =>
|
||||
value ? formatDateTime(value) : "never";
|
||||
|
||||
/**
|
||||
* USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable.
|
||||
* 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();
|
||||
/** One currency's fallback row — its own draft, its own save. */
|
||||
function ExchangeRateRow({
|
||||
setting,
|
||||
disabled,
|
||||
}: {
|
||||
setting: ExchangeSetting;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const setRate = useSetExchangeFallbackRate();
|
||||
const [draft, setDraft] = useState<string>("");
|
||||
|
||||
const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? "");
|
||||
const value = draft !== "" ? draft : (setting.fallbackRate?.toString() ?? "");
|
||||
const parsed = Number(value);
|
||||
const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000;
|
||||
const dirty = draft !== "" && parsed !== data?.fallbackRate;
|
||||
const invalid = !Number.isFinite(parsed) || parsed <= 0;
|
||||
const dirty = draft !== "" && parsed !== setting.fallbackRate;
|
||||
|
||||
const feed = feedLabel(data?.feed?.source ?? null);
|
||||
const feed = feedLabel(setting.feed?.source ?? null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (invalid) return;
|
||||
await setRate.mutateAsync(parsed);
|
||||
await setRate.mutateAsync({ currency: setting.currency, rate: parsed });
|
||||
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 (
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Exchange rate (USD → ETB)</CardTitle>
|
||||
<CardTitle>Exchange rates (→ ETB)</CardTitle>
|
||||
<CardDescription>
|
||||
Rates come from the Commercial Bank of Ethiopia. The fallback
|
||||
below is used only when CBE cannot be reached, and is refreshed
|
||||
automatically after every successful update.
|
||||
Rates come from the Commercial Bank of Ethiopia. Each fallback
|
||||
below is used only when CBE cannot be reached for that currency,
|
||||
and is refreshed automatically after every successful update.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -88,69 +172,13 @@ export default function ExchangeRateSettingsCard() {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<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">{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>
|
||||
{(data ?? []).map((setting) => (
|
||||
<ExchangeRateRow
|
||||
key={setting.currency}
|
||||
setting={setting}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
useUpdateManualPaymentSettings,
|
||||
} from "@/hooks/useManualPaymentSettings";
|
||||
|
||||
type Currency = "ETB" | "USD";
|
||||
type Currency = "ETB" | "USD" | "DJF";
|
||||
|
||||
const CURRENCIES: {
|
||||
code: Currency;
|
||||
field: "etbEnabled" | "usdEnabled";
|
||||
field: "etbEnabled" | "usdEnabled" | "djfEnabled";
|
||||
icon: typeof Banknote;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -42,6 +42,14 @@ const CURRENCIES: {
|
||||
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.",
|
||||
},
|
||||
{
|
||||
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 update = useUpdateManualPaymentSettings();
|
||||
|
||||
const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled);
|
||||
const noneEnabled = Boolean(
|
||||
data && !data.etbEnabled && !data.usdEnabled && !data.djfEnabled,
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
} 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 { 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) =>
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -78,7 +79,7 @@ const TRUCK_COLUMNS = [
|
||||
] as const;
|
||||
|
||||
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 {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
|
||||
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 { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -50,7 +50,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
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 INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||
@@ -204,7 +204,8 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
|
||||
useEffect(() => {
|
||||
setGatewayMethod(inv?.currency === 'USD' ? 'WAAFI' : 'TELEBIRR');
|
||||
// WAAFI settles USD and DJF; TELEBIRR is ETB-only.
|
||||
setGatewayMethod(inv?.currency !== 'ETB' ? 'WAAFI' : 'TELEBIRR');
|
||||
setPayerAccount('');
|
||||
}, [inv?.id, inv?.currency]);
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ const TRADE = [
|
||||
const CURRENCIES = [
|
||||
{ value: 'USD', label: 'USD - Dollar' },
|
||||
{ value: 'ETB', label: 'ETB - Birr' },
|
||||
{ value: 'DJF', label: 'DJF - Djibouti Franc' },
|
||||
];
|
||||
|
||||
const clean = (s: string) => s.trim() || undefined;
|
||||
|
||||
@@ -1533,7 +1533,7 @@ export const api = {
|
||||
),
|
||||
|
||||
feePreview: endpoint<
|
||||
{ inventoryId: string; billingCurrency?: "ETB" | "USD" },
|
||||
{ inventoryId: string; billingCurrency?: "ETB" | "USD" | "DJF" },
|
||||
FeePreview[]
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
@@ -1885,7 +1885,7 @@ export const api = {
|
||||
{
|
||||
inventoryId: string;
|
||||
confirmZero?: boolean;
|
||||
billingCurrency?: "ETB" | "USD";
|
||||
billingCurrency?: "ETB" | "USD" | "DJF";
|
||||
},
|
||||
WarehouseFeeInvoice
|
||||
>(
|
||||
|
||||
@@ -11,7 +11,7 @@ const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
|
||||
*/
|
||||
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 {
|
||||
rate: number | null;
|
||||
source: ExchangeRateSource | null;
|
||||
@@ -19,25 +19,31 @@ export interface ExchangeFeedStatus {
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface ExchangeSettings {
|
||||
fallbackRate: number;
|
||||
/** `AUTO` when synced from CBE, `MANUAL` when set here. */
|
||||
fallbackSource: "AUTO" | "MANUAL";
|
||||
/** One currency's X→ETB fallback settings — the API returns one per foreign currency. */
|
||||
export interface ExchangeSetting {
|
||||
currency: string;
|
||||
fallbackRate: number | null;
|
||||
/** `AUTO` when synced from CBE, `MANUAL` when set here. `null` before the row exists. */
|
||||
fallbackSource: "AUTO" | "MANUAL" | null;
|
||||
lastSyncedAt: string | null;
|
||||
updatedById: string | null;
|
||||
feed?: ExchangeFeedStatus;
|
||||
}
|
||||
|
||||
export const exchangeSettingsService = {
|
||||
get: async (): Promise<ExchangeSettings> => {
|
||||
const response = await client.get<ApiResponse<ExchangeSettings>>(BASE);
|
||||
list: async (): Promise<ExchangeSetting[]> => {
|
||||
const response = await client.get<ApiResponse<ExchangeSetting[]>>(BASE);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setFallbackRate: async (fallbackRate: number): Promise<ExchangeSettings> => {
|
||||
const response = await client.patch<ApiResponse<ExchangeSettings>>(BASE, {
|
||||
fallbackRate,
|
||||
});
|
||||
setFallbackRate: async (
|
||||
currency: string,
|
||||
fallbackRate: number,
|
||||
): Promise<ExchangeSetting> => {
|
||||
const response = await client.patch<ApiResponse<ExchangeSetting>>(
|
||||
`${BASE}/${currency}`,
|
||||
{ fallbackRate },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE;
|
||||
export interface ManualPaymentSettings {
|
||||
etbEnabled: boolean;
|
||||
usdEnabled: boolean;
|
||||
djfEnabled: boolean;
|
||||
updatedById: string | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -25,7 +26,9 @@ export const manualPaymentSettingsService = {
|
||||
|
||||
/** Partial: an omitted currency keeps its current setting. */
|
||||
update: async (
|
||||
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
|
||||
patch: Partial<
|
||||
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
|
||||
>,
|
||||
): Promise<ManualPaymentSettings> => {
|
||||
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
|
||||
BASE,
|
||||
|
||||
@@ -558,11 +558,11 @@ export const warehouseService = {
|
||||
updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) =>
|
||||
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)),
|
||||
feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD') =>
|
||||
feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD' | 'DJF') =>
|
||||
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
|
||||
params: cleanParams({ billingCurrency }),
|
||||
}),
|
||||
accrualDashboard: (billingCurrency?: 'ETB' | 'USD') =>
|
||||
accrualDashboard: (billingCurrency?: 'ETB' | 'USD' | 'DJF') =>
|
||||
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
|
||||
params: cleanParams({ billingCurrency }),
|
||||
}),
|
||||
@@ -592,7 +592,7 @@ export const warehouseService = {
|
||||
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
|
||||
invoicesForBooking: (bookingId: string) =>
|
||||
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), {
|
||||
confirmZero,
|
||||
billingCurrency,
|
||||
|
||||
@@ -420,7 +420,7 @@ export interface CustomerBooking {
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
totalAmount: number;
|
||||
currency: "ETB" | "USD";
|
||||
currency: "ETB" | "USD" | "DJF";
|
||||
scheduledDate?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -469,7 +469,7 @@ export interface CustomerPayment {
|
||||
/** Booking reference the payment settles. */
|
||||
bookingReference: string;
|
||||
amount: number;
|
||||
currency: "ETB" | "USD";
|
||||
currency: "ETB" | "USD" | "DJF";
|
||||
method: CustomerPaymentMethod;
|
||||
status: CustomerPaymentStatus;
|
||||
paidAt?: string | null;
|
||||
|
||||
@@ -77,7 +77,7 @@ export interface InvoiceListFilter {
|
||||
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
|
||||
paymentMethods?: string;
|
||||
search?: string;
|
||||
currency?: "USD" | "ETB";
|
||||
currency?: "USD" | "ETB" | "DJF";
|
||||
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
|
||||
issuedFrom?: string;
|
||||
issuedTo?: string;
|
||||
|
||||
@@ -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`.
|
||||
* Unknown currency codes fall back to printing the raw code.
|
||||
* Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …).
|
||||
* 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(
|
||||
amount: number,
|
||||
currency: Currency = "ETB",
|
||||
): string {
|
||||
const symbol = SYMBOLS[currency] ?? currency;
|
||||
return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
export type { SupportedCurrency as Currency } from "@edr/ui-common";
|
||||
export { formatCurrency, currencySymbol, currencyDecimals, CURRENCY_CODES } from "@edr/ui-common";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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. */
|
||||
export interface ActionItem {
|
||||
@@ -64,7 +64,10 @@ export function deriveActionItems(
|
||||
? b.status === "FULLY_EXECUTED"
|
||||
: b.status === "SELECTED_FOR_BATCH");
|
||||
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({
|
||||
id: `pay-${b.id}`,
|
||||
kind: "pay",
|
||||
|
||||
@@ -32,7 +32,7 @@ import { invoicesService } from "@/services/invoices.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||
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 { formatCurrency } from "@/lib/currency";
|
||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||
@@ -232,7 +232,7 @@ export default function InvoiceDetailPage() {
|
||||
Receipt
|
||||
</Button>
|
||||
)}
|
||||
{payable && !isUsdCurrency(invoice.currency) && (
|
||||
{payable && canPayOnline(invoice.currency) && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
@@ -247,7 +247,7 @@ export default function InvoiceDetailPage() {
|
||||
Pay {formatCurrency(amountDue, invoice.currency)}
|
||||
</Button>
|
||||
)}
|
||||
{payable && isUsdCurrency(invoice.currency) && (
|
||||
{payable && canPayOffline(invoice.currency) && (
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="md"
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment";
|
||||
import { canPayOnline } from "@/pages/bookings/payments/offline-payment";
|
||||
import {
|
||||
BORDER,
|
||||
GREEN,
|
||||
@@ -277,10 +277,11 @@ export default function InvoicesList() {
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
pageRows.map((inv) => {
|
||||
// USD invoices are paid by bank transfer — the detail page
|
||||
// shows the instructions, so the row action reads "View".
|
||||
// A currency with no online rail (USD) is paid by bank
|
||||
// transfer — the detail page shows the instructions, so the
|
||||
// row action reads "View".
|
||||
const payable =
|
||||
isPayable(inv.status) && !isUsdCurrency(inv.currency);
|
||||
isPayable(inv.status) && canPayOnline(inv.currency);
|
||||
return (
|
||||
<Table.Tr
|
||||
key={inv.id}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { Freight } from "@edr/types";
|
||||
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
||||
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
|
||||
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 { payWindowState } from "@/pages/bookings/payments/payment-drain";
|
||||
import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice";
|
||||
@@ -200,7 +200,8 @@ export function BookingPaymentPanel({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const paid = booking.paymentStatus === "PAID";
|
||||
const offlineUsd = isUsdOfflineBooking(booking);
|
||||
const payOnline = bookingCanPayOnline(booking);
|
||||
const payOffline = bookingCanPayOffline(booking);
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
@@ -293,7 +294,7 @@ export function BookingPaymentPanel({
|
||||
<PaymentProcessingNotice drainEndsAt={payWindow.drainEndsAt} />
|
||||
)}
|
||||
|
||||
{!paid && !draining && offlineUsd && (
|
||||
{!paid && !draining && payOffline && (
|
||||
<Box
|
||||
mt={14}
|
||||
p={14}
|
||||
@@ -307,10 +308,9 @@ export function BookingPaymentPanel({
|
||||
Pay by bank transfer
|
||||
</Text>
|
||||
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
|
||||
Online payment isn't available for USD bookings. Transfer the
|
||||
total amount to EDR's bank account before the payment deadline,
|
||||
then send the payment slip to the EDR Finance department — they
|
||||
will confirm your payment.
|
||||
{payOnline
|
||||
? "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."
|
||||
: "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."}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
@@ -319,7 +319,7 @@ export function BookingPaymentPanel({
|
||||
<Box mt={16}>
|
||||
<Countdown
|
||||
deadline={booking.paymentDeadline}
|
||||
onPay={offlineUsd ? undefined : onPay}
|
||||
onPay={payOnline ? onPay : undefined}
|
||||
paying={paying}
|
||||
/>
|
||||
{!paid && <PayerAccountNote />}
|
||||
|
||||
@@ -44,16 +44,16 @@ const PROVIDERS: ProviderOption[] = [
|
||||
{
|
||||
method: "WAAFI",
|
||||
label: "Waafi",
|
||||
description: "Djibouti mobile money · USD",
|
||||
description: "Djibouti mobile money · USD or DJF",
|
||||
logo: "/assets/waafi.jpeg",
|
||||
currencies: ["USD"],
|
||||
currencies: ["USD", "DJF"],
|
||||
accent: "#2E5B96",
|
||||
},
|
||||
{
|
||||
method: "CAC_BANK",
|
||||
label: "CAC Bank",
|
||||
description: "Djibouti bank debit · confirmed by SMS OTP",
|
||||
currencies: ["USD"],
|
||||
currencies: ["USD", "DJF"],
|
||||
accent: "#8A5A17",
|
||||
},
|
||||
{
|
||||
@@ -74,14 +74,16 @@ const OTP_LENGTH = 4;
|
||||
const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL";
|
||||
|
||||
/**
|
||||
* Pick the provider that settles in the booking's currency. USD → Waafi/CAC,
|
||||
* ETB → CBE bill. Falls back to the full list when unknown.
|
||||
* Pick the provider(s) that settle in the booking's currency: ETB → CBE
|
||||
* 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[] {
|
||||
const cur = currency?.trim().toUpperCase();
|
||||
if (!cur) return PROVIDERS;
|
||||
const matched = PROVIDERS.filter((p) => p.currencies.includes(cur));
|
||||
return matched.length > 0 ? matched : PROVIDERS;
|
||||
return PROVIDERS.filter((p) => p.currencies.includes(cur));
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
@@ -213,14 +215,14 @@ export function PaymentMethodModal({
|
||||
),
|
||||
[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 [code, setCode] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||
useEffect(() => {
|
||||
if (!providers.some((p) => p.method === method)) {
|
||||
if (providers.length > 0 && !providers.some((p) => p.method === method)) {
|
||||
setMethod(providers[0].method);
|
||||
}
|
||||
}, [providers, method]);
|
||||
@@ -462,14 +464,21 @@ export function PaymentMethodModal({
|
||||
Payment method
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{providers.map((option) => (
|
||||
<ProviderRow
|
||||
key={option.method}
|
||||
option={option}
|
||||
selected={method === option.method}
|
||||
onSelect={() => setMethod(option.method)}
|
||||
/>
|
||||
))}
|
||||
{providers.length === 0 ? (
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
No online payment method is available for this currency yet — use bank
|
||||
transfer instead.
|
||||
</Text>
|
||||
) : (
|
||||
providers.map((option) => (
|
||||
<ProviderRow
|
||||
key={option.method}
|
||||
option={option}
|
||||
selected={method === option.method}
|
||||
onSelect={() => setMethod(option.method)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{needsMobile && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
PAYMENT_CURRENCY_OPTIONS,
|
||||
@@ -15,6 +15,7 @@ const CURRENCY_ICONS: Record<
|
||||
> = {
|
||||
USD: { icon: DollarSign, color: "#4F46E5" },
|
||||
ETB: { icon: Banknote, color: "#0A6F4D" },
|
||||
DJF: { icon: Coins, color: "#B45309" },
|
||||
};
|
||||
|
||||
export function PaymentCurrencyField({
|
||||
@@ -28,9 +29,10 @@ export function PaymentCurrencyField({
|
||||
*/
|
||||
allowUsd?: boolean;
|
||||
}) {
|
||||
// DJF is offered wherever USD is — both are import-shipment-only currencies.
|
||||
const options = allowUsd
|
||||
? PAYMENT_CURRENCY_OPTIONS
|
||||
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value !== "USD");
|
||||
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB");
|
||||
return (
|
||||
<Box mt={24}>
|
||||
<StepLabel>Payment currency</StepLabel>
|
||||
|
||||
@@ -73,7 +73,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
|
||||
|
||||
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 const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
@@ -92,6 +92,12 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
label: "USD",
|
||||
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;
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/**
|
||||
* USD bookings are never paid online: the customer pays by bank transfer and
|
||||
* the Finance department confirms the payment from the slip. Phase 1 is
|
||||
* portal-only — Finance's confirm flow lands in the backoffice later.
|
||||
* Which payment rails a currency supports. ETB settles online only (the
|
||||
* payment gateway); USD is bank-transfer-only (never through the online
|
||||
* 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 {
|
||||
return currency?.toUpperCase() === "USD";
|
||||
export function canPayOnline(currency?: string | null): boolean {
|
||||
const c = currency?.toUpperCase();
|
||||
return c === "ETB" || c === "DJF";
|
||||
}
|
||||
|
||||
export function isUsdOfflineBooking(booking: Freight.IBooking): boolean {
|
||||
return isUsdCurrency(
|
||||
booking.pricingBreakdown?.currency ?? booking.paymentCurrency,
|
||||
);
|
||||
export function canPayOffline(currency?: string | null): boolean {
|
||||
const c = currency?.toUpperCase();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
import { bookingsService } from "@/services/bookings.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";
|
||||
|
||||
export type PayableAction =
|
||||
@@ -77,7 +77,11 @@ export function useBookingPayables(booking: Freight.IBooking) {
|
||||
|
||||
const items = useMemo(() => {
|
||||
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 ?? []) {
|
||||
const balance = Number(inv.balanceAmount ?? 0);
|
||||
|
||||
@@ -310,7 +310,9 @@ function mapBookingToShipmentValues(
|
||||
// Resubmit keeps the currency the customer already chose on this booking;
|
||||
// a missing value falls back to empty so the choice is made deliberately.
|
||||
paymentCurrency:
|
||||
booking.paymentCurrency === "USD" || booking.paymentCurrency === "ETB"
|
||||
booking.paymentCurrency === "USD" ||
|
||||
booking.paymentCurrency === "ETB" ||
|
||||
booking.paymentCurrency === "DJF"
|
||||
? booking.paymentCurrency
|
||||
: "",
|
||||
withReturn: booking.equipmentReturn === "WITH_RETURN",
|
||||
@@ -1464,7 +1466,7 @@ function ScheduleStep({
|
||||
<StepLabel>Billing currency *</StepLabel>
|
||||
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
|
||||
{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."}
|
||||
</Text>
|
||||
<CurrencySelector
|
||||
@@ -1472,6 +1474,7 @@ function ScheduleStep({
|
||||
onChange={(v) => field.onChange(v)}
|
||||
error={fieldState.error?.message}
|
||||
allowUsd={isImport}
|
||||
allowDjf={isImport}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function NewShipmentRequestPage() {
|
||||
// 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
|
||||
// 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 [notes, setNotes] = useState("");
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function NewShipmentRequestPage() {
|
||||
contractRouteId: route?.id,
|
||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||
paymentCurrency:
|
||||
isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB"),
|
||||
isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB" | "DJF"),
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
|
||||
@@ -267,6 +267,7 @@ export default function NewShipmentRequestPage() {
|
||||
}}
|
||||
disabled={isIntercity || isExport}
|
||||
allowUsd={!isIntercity && !isExport}
|
||||
allowDjf={!isIntercity && !isExport}
|
||||
error={currencyError}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -75,7 +75,7 @@ export const CONTRACT_KIND_OPTIONS: Array<{
|
||||
|
||||
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 const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
@@ -93,6 +93,11 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
label: "ETB",
|
||||
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.
|
||||
|
||||
@@ -101,7 +101,7 @@ const shipmentFormBase = z.object({
|
||||
// The contract quotes in USD; the customer picks the billing currency for
|
||||
// THIS shipment. Starts empty so the choice is deliberate — validated as
|
||||
// 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
|
||||
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
||||
withReturn: z.boolean().default(false),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ResolvedExchangeOptions,
|
||||
} from "./exchange.options";
|
||||
import {
|
||||
CurrencyCode,
|
||||
CurrencyPair,
|
||||
ExchangeRateProvider,
|
||||
} from "./exchange.types";
|
||||
@@ -41,62 +42,77 @@ export interface CbeProviderStatus {
|
||||
/**
|
||||
* Commercial Bank of Ethiopia (CBE) rate provider.
|
||||
*
|
||||
* Sources a single canonical direction — **USD→ETB** (transactional selling
|
||||
* rate) — from CBE's public `daily-exchange-rates` JSON endpoint, caching the
|
||||
* result and falling back to a configured rate when the fetch fails. The
|
||||
* inverse (ETB→USD) is derived by {@link ExchangeService}, so this provider
|
||||
* only ever reports USD→ETB.
|
||||
* Sources every quoted currency against **ETB** (transactional selling rate)
|
||||
* from CBE's public `daily-exchange-rates` JSON endpoint in a single fetch —
|
||||
* the payload carries every currency CBE quotes that day, not just one — caching
|
||||
* the result and falling back to a configured rate per currency when the fetch
|
||||
* 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 {
|
||||
readonly name = "CBE";
|
||||
readonly baseCurrency: CurrencyCode = "ETB";
|
||||
|
||||
private readonly logger = new Logger(CbeExchangeProvider.name);
|
||||
private readonly options: ResolvedExchangeOptions;
|
||||
private cachedRate: number | null = null;
|
||||
private cachedRates: Map<string, number> | null = null;
|
||||
private cacheExpiresAt = 0;
|
||||
private lastSuccessAt: number | 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) {
|
||||
this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) };
|
||||
}
|
||||
|
||||
async getBaseRate(pair: CurrencyPair): Promise<number | null> {
|
||||
// CBE only sources USD→ETB; everything else is derived upstream.
|
||||
if (pair.from !== "USD" || pair.to !== "ETB") {
|
||||
// CBE only sources X→ETB; everything else is derived upstream.
|
||||
if (pair.to !== "ETB" || pair.from === "ETB") {
|
||||
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 {
|
||||
rate: this.cachedRate,
|
||||
source: this.lastSource,
|
||||
rate: this.lastServed.get(code) ?? null,
|
||||
source: this.lastSource.get(code) ?? null,
|
||||
lastSuccessAt: this.lastSuccessAt,
|
||||
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
|
||||
* fetch stale. On failure the chain is: cached rate → `loadFallbackRate()`
|
||||
* → static `fallbackRate`.
|
||||
* fetch stale. On failure the chain is: cached rate → `loadFallbackRate(code)`
|
||||
* → static `fallbackRates[code]`.
|
||||
*/
|
||||
private async getUsdToEtbRate(): Promise<number> {
|
||||
private async getRateToEtb(code: CurrencyCode): Promise<number> {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
|
||||
this.lastSource = "cache";
|
||||
return this.cachedRate;
|
||||
if (this.cachedRates !== null && now < this.cacheExpiresAt) {
|
||||
const cached = this.cachedRates.get(code);
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -116,54 +132,68 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
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) {
|
||||
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;
|
||||
this.cachedRate = rate;
|
||||
const previous = this.cachedRates?.get(code) ?? null;
|
||||
this.cachedRates = rates;
|
||||
this.cacheExpiresAt = now + cacheTtlMs;
|
||||
this.lastSuccessAt = now;
|
||||
this.lastError = null;
|
||||
this.lastSource = "live";
|
||||
this.lastSource.set(code, "live");
|
||||
this.lastServed.set(code, rate);
|
||||
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
|
||||
// rate. Skipped when unchanged, to avoid pointless writes and audit noise.
|
||||
if (rate !== previous) {
|
||||
await this.persistFallback(rate);
|
||||
await this.persistFallback(code, rate);
|
||||
}
|
||||
|
||||
return rate;
|
||||
} catch (err) {
|
||||
const message = (err as Error).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) {
|
||||
this.lastSource = "cache";
|
||||
this.logger.warn(
|
||||
`Using previously cached CBE rate: ${this.cachedRate}`,
|
||||
);
|
||||
return this.cachedRate;
|
||||
const cached = this.cachedRates?.get(code);
|
||||
if (cached !== undefined) {
|
||||
this.lastSource.set(code, "cache");
|
||||
this.lastServed.set(code, cached);
|
||||
this.logger.warn(`Using previously cached CBE rate for ${code}: ${cached}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const stored = await this.loadStoredFallback();
|
||||
const stored = await this.loadStoredFallback(code);
|
||||
if (stored !== null) {
|
||||
this.lastSource = "stored";
|
||||
this.logger.warn(`Using stored fallback CBE rate: ${stored}`);
|
||||
this.lastSource.set(code, "stored");
|
||||
this.lastServed.set(code, stored);
|
||||
this.logger.warn(`Using stored fallback CBE rate for ${code}: ${stored}`);
|
||||
return stored;
|
||||
}
|
||||
|
||||
this.lastSource = "default";
|
||||
this.logger.warn(`Using default fallback CBE rate: ${fallbackRate}`);
|
||||
return fallbackRate;
|
||||
const fallback = fallbackRates[code];
|
||||
if (fallback === undefined) {
|
||||
// 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
|
||||
* 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;
|
||||
if (!saveFallbackRate) return;
|
||||
|
||||
try {
|
||||
await saveFallbackRate(rate);
|
||||
await saveFallbackRate(code, rate);
|
||||
} catch (err) {
|
||||
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
|
||||
* static default — when unconfigured, unusable, or itself failing.
|
||||
* Reads the persisted fallback for `code`. Returns `null` — falling through
|
||||
* 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;
|
||||
if (!loadFallbackRate) return null;
|
||||
|
||||
try {
|
||||
const stored = await loadFallbackRate();
|
||||
const stored = await loadFallbackRate(code);
|
||||
const rate = Number(stored);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
} catch (err) {
|
||||
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;
|
||||
}
|
||||
@@ -217,18 +247,21 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls USD `transactionalSelling` out of a daily record. Returns `null` when
|
||||
* the entry is missing or the value isn't a usable positive number — CBE
|
||||
* publishes `0`/`null` for currencies it isn't quoting that day.
|
||||
* Pulls every currency's `transactionalSelling` out of a daily record in
|
||||
* one pass. Skips entries missing or unusable — CBE publishes `0`/`null`
|
||||
* for currencies it isn't quoting that day.
|
||||
*/
|
||||
private parseUsdRate(day: CbeDailyRecord): number | null {
|
||||
const usd = day.ExchangeRate?.find(
|
||||
(entry) => entry?.currency?.CurrencyCode === "USD",
|
||||
);
|
||||
if (!usd) return null;
|
||||
|
||||
const rate = Number(usd.transactionalSelling);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
private parseRates(day: CbeDailyRecord): Map<string, number> {
|
||||
const rates = new Map<string, number>();
|
||||
for (const entry of day.ExchangeRate ?? []) {
|
||||
const code = entry?.currency?.CurrencyCode;
|
||||
if (!code) continue;
|
||||
const rate = Number(entry.transactionalSelling);
|
||||
if (Number.isFinite(rate) && rate > 0) {
|
||||
rates.set(code, rate);
|
||||
}
|
||||
}
|
||||
return rates;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CurrencyCode } from "./exchange.types";
|
||||
|
||||
/** Injection token carrying the resolved {@link ExchangeOptions}. */
|
||||
export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS");
|
||||
|
||||
@@ -11,31 +13,34 @@ export interface ExchangeOptions {
|
||||
scrapeUrl?: string;
|
||||
|
||||
/**
|
||||
* Last-resort USD→ETB rate, used only when the fetch fails, no cached rate
|
||||
* exists, and {@link loadFallbackRate} supplies nothing. The ETB→USD
|
||||
* direction is derived as its inverse.
|
||||
* @default 162
|
||||
* Last-resort rate for each foreign currency, quoted against the provider's
|
||||
* base currency (ETB for CBE) — used only when the fetch fails, no cached
|
||||
* rate exists, and {@link loadFallbackRate} supplies nothing for that
|
||||
* 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
|
||||
* set by an operator. Consulted only when the live fetch fails and no cached
|
||||
* rate is available; a `null` result falls through to {@link fallbackRate}.
|
||||
* Reads the persisted fallback rate for `code` — the last known good CBE
|
||||
* rate, or one set by an operator. Consulted only when the live fetch fails
|
||||
* 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
|
||||
* value is never more than one successful fetch stale. Called after every
|
||||
* successful fetch that produced a changed rate.
|
||||
* Persists a freshly fetched live rate for `code` as the new fallback, so
|
||||
* the stored value is never more than one successful fetch stale. Called
|
||||
* after every successful fetch that produced a changed rate.
|
||||
*
|
||||
* Failures here are logged and swallowed — persisting the fallback must
|
||||
* 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.
|
||||
@@ -60,7 +65,7 @@ export type ResolvedExchangeOptions = Required<
|
||||
export const EXCHANGE_DEFAULTS: ResolvedExchangeOptions = {
|
||||
scrapeUrl:
|
||||
"https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
|
||||
fallbackRate: 162,
|
||||
fallbackRates: { USD: 162, DJF: 0.92 },
|
||||
cacheTtlMs: 3_600_000,
|
||||
requestTimeoutMs: 8_000,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Inject, Injectable } from "@nestjs/common";
|
||||
|
||||
import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider";
|
||||
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
|
||||
@@ -12,6 +12,8 @@ import { CurrencyCode } from "./exchange.types";
|
||||
* 1. `from === to` → `1`.
|
||||
* 2. Provider supplies the pair directly (e.g. CBE → USD→ETB).
|
||||
* 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`.
|
||||
*/
|
||||
@@ -42,17 +44,44 @@ export class ExchangeService {
|
||||
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(
|
||||
`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
|
||||
* is currently failing. For operator-facing status displays.
|
||||
*/
|
||||
getProviderStatus(): CbeProviderStatus {
|
||||
return this.provider.getStatus();
|
||||
getProviderStatus(code: CurrencyCode = "USD"): CbeProviderStatus {
|
||||
return this.provider.getStatus(code);
|
||||
}
|
||||
|
||||
/** Converts `amount` from one currency to another using {@link getRate}. */
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* 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' }`. */
|
||||
export interface CurrencyPair {
|
||||
@@ -11,18 +13,21 @@ export interface CurrencyPair {
|
||||
}
|
||||
|
||||
/**
|
||||
* A source of base exchange rates. Implementations fetch (scrape/API) the rate
|
||||
* for a single canonical direction; the {@link ExchangeService} derives the
|
||||
* inverse and same-currency (1:1) cases on top.
|
||||
* A source of base exchange rates. Implementations fetch (scrape/API) rates
|
||||
* quoted against a single canonical base currency; the {@link ExchangeService}
|
||||
* derives every other pair — inverse, pivot, same-currency (1:1) — on top.
|
||||
*
|
||||
* 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
|
||||
* added without touching consumers.
|
||||
* which quotes everything against ETB. New providers (other banks, other base
|
||||
* currencies) can be added without touching consumers.
|
||||
*/
|
||||
export interface ExchangeRateProvider {
|
||||
/** Human-readable provider name, used in logs (e.g. `'CBE'`). */
|
||||
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`),
|
||||
* or `null` if this provider cannot supply that pair directly.
|
||||
|
||||
@@ -8,6 +8,7 @@ export type {
|
||||
ExchangeAsyncOptions,
|
||||
ResolvedExchangeOptions,
|
||||
} from "./exchange.options";
|
||||
export { CURRENCY_CODES } from "./exchange.types";
|
||||
export type {
|
||||
CurrencyCode,
|
||||
CurrencyPair,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import type { SupportedCurrency } from "../../lib/currency";
|
||||
|
||||
export interface CurrencySelectorProps {
|
||||
/** Selected currency code, or "" when none picked yet. */
|
||||
value: string;
|
||||
onChange: (currency: "USD" | "ETB") => void;
|
||||
onChange: (currency: SupportedCurrency) => void;
|
||||
disabled?: boolean;
|
||||
/** Validation error shown under the cards. */
|
||||
error?: string;
|
||||
@@ -14,6 +16,12 @@ export interface CurrencySelectorProps {
|
||||
* USD is settled by bank transfer, never through the online gateway.
|
||||
*/
|
||||
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 = {
|
||||
@@ -30,6 +38,13 @@ const USD_OPTION = {
|
||||
hint: "Paid by bank transfer — send the slip to Finance",
|
||||
} 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`
|
||||
* is "" so a required choice never looks pre-made.
|
||||
@@ -40,8 +55,13 @@ export function CurrencySelector({
|
||||
disabled = false,
|
||||
error,
|
||||
allowUsd = false,
|
||||
allowDjf = false,
|
||||
}: CurrencySelectorProps) {
|
||||
const options = allowUsd ? [ETB_OPTION, USD_OPTION] : [ETB_OPTION];
|
||||
const options = [
|
||||
ETB_OPTION,
|
||||
...(allowUsd ? [USD_OPTION] : []),
|
||||
...(allowDjf ? [DJF_OPTION] : []),
|
||||
];
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
|
||||
@@ -80,3 +80,12 @@ export type {
|
||||
BookingWindowStateInput,
|
||||
BookingWindowUiState,
|
||||
} from "./lib/booking-window-display";
|
||||
|
||||
export {
|
||||
CURRENCY_META,
|
||||
CURRENCY_CODES,
|
||||
currencyDecimals,
|
||||
currencySymbol,
|
||||
formatCurrency,
|
||||
} from "./lib/currency";
|
||||
export type { SupportedCurrency } from "./lib/currency";
|
||||
|
||||
43
packages/ui-common/src/lib/currency.ts
Normal file
43
packages/ui-common/src/lib/currency.ts
Normal 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,
|
||||
})}`;
|
||||
}
|
||||
Reference in New Issue
Block a user