diff --git a/apps/edr-freight-api/src/migrations/3850000000000-ExchangeSettingsPerCurrency.ts b/apps/edr-freight-api/src/migrations/3850000000000-ExchangeSettingsPerCurrency.ts new file mode 100644 index 000000000..07444c0a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3850000000000-ExchangeSettingsPerCurrency.ts @@ -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 ExchangeSettingsPerCurrency3850000000000 implements MigrationInterface { + name = 'ExchangeSettingsPerCurrency3850000000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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`); + } +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts index 98e87007c..4343732b7 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts index 1e1f4ad66..e1fc99780 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts @@ -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", diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts index fb126f969..a52db2010 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts @@ -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("app.cbeExchange") ?? {}), - loadFallbackRate: () => settings.loadFallbackRate(), - saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate), + loadFallbackRate: (code) => settings.loadFallbackRate(code), + saveFallbackRate: (code, rate) => settings.saveFallbackRate(code, rate), }), }); } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts new file mode 100644 index 000000000..e18bc830e --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts index 001fc90d2..0e0736561 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -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 = { + 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, diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts index e0b670292..df36821bc 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -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> = { + 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(); constructor( @InjectRepository(ExchangeSetting) private readonly repository: Repository, ) {} - /** 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 { - 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 { + 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 { + 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 { + async loadFallbackRate(code: CurrencyCode): Promise { // 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 { + async saveFallbackRate(code: CurrencyCode, rate: number): Promise { // 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 { - 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); } }