mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 05:23:38 +00:00
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
103 lines
3.5 KiB
TypeScript
103 lines
3.5 KiB
TypeScript
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);
|
||
});
|
||
});
|