mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 04:13:37 +00:00
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:
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user