feat(exchange-settings): one fallback rate per currency

exchange_settings was a single row holding the USD->ETB fallback only.
Restructured to one row per foreign currency (adds a currency column,
migration 3850000000000) so DJF gets its own fallback rate, source and
sync timestamp instead of a parallel column. Service/controller/DTO
follow: get/loadFallbackRate/saveFallbackRate/setManualRate all take a
currency now, GET /exchange-settings returns the list, and
PATCH /exchange-settings/:currency sets one. Per-currency manual-rate
ceiling (USD ~10,000, DJF ~100) replaces the old fixed bound.

Adds a spec exercising the multi-currency CBE parse and the USD<->DJF
pivot against a fixture payload.

Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
This commit is contained in:
ghost2023
2026-09-04 11:52:18 +03:00
parent 17deb434ae
commit 21dc28a708
7 changed files with 285 additions and 77 deletions

View File

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

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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),
}),
});
}

View File

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

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Get, Patch } from "@nestjs/common";
import { BadRequestException, Body, Controller, Get, Param, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { 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,

View File

@@ -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);
}
}