From ae1e13b07e8faff7c10d21c92893c9d6f218c6e7 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 31 Aug 2026 10:54:00 +0000 Subject: [PATCH] feat(exchange): give every currency the rate support USD had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DJF was priced off a rate nobody could see or set. The rate itself worked — CBE quotes DJF and the provider read it — but `exchange_settings` held a single USD→ETB row, `getStatus()` hardcoded `rates.get("USD")`, and the settings card was titled "Exchange rate (USD → ETB)". So an operator could enable DJF billing, have invoices priced in francs, and have no way to see which rate produced them and no way to override it during an outage. Those are exactly the two levers they have for USD. The original reasoning — DJF is pegged to USD, so a second stored number is a second thing that goes stale — justified not storing a second FALLBACK. It never justified not displaying the rate. Those got conflated. `exchange_settings` becomes one row per currency. `fallback_rate` means the same thing in every row: ETB per one unit of `currency`. The ETB row is 1 and exists so the table describes the whole set rather than "the others". `enabled` generalises `djf_enabled` from 3790000000000 — availability is a property of a currency, not a column named after one — and the migration carries the old flag onto the DJF row so an operator who had already switched it on does not find it off after deploying. loadFallbackRate/saveFallbackRate take a currency. The provider persists every quoted rate from the one payload it already fetched, so DJF has its own last-known-good value to fall back on, and reads that currency's own stored rate first — a rate an operator typed by hand during an outage must not be silently re-derived from USD. The peg survives one rung lower: when a currency has nothing stored, USD's stored rate through the peg still beats the compiled-in default, which is a year-old number by definition. The DTO's `@Min(1)` is now `@Min(0.0001)`. It would have rejected every legitimate DJF rate — birr per franc is about 0.92. Verified against the dev database: one live fetch stamped last_synced_at on both the USD and DJF rows, which is the behaviour that was missing. --- ...00000000000-PerCurrencyExchangeSettings.ts | 98 ++++++++ .../dto/update-exchange-setting.dto.ts | 43 +++- .../entities/exchange-setting.entity.ts | 38 ++- .../exchange-module-options.ts | 7 +- .../exchange-settings/exchange-rate.spec.ts | 61 ++++- .../exchange-settings.controller.ts | 48 ++-- .../exchange-settings.service.ts | 237 +++++++++++------- .../src/hooks/useExchangeSettings.ts | 56 +++-- .../settings/ExchangeRateSettingsCard.tsx | 219 +++++++++------- .../src/services/exchangeSettings.service.ts | 52 ++-- .../src/services/exchange/cbe.provider.ts | 77 ++++-- .../src/services/exchange/exchange.options.ts | 27 +- 12 files changed, 649 insertions(+), 314 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3800000000000-PerCurrencyExchangeSettings.ts diff --git a/apps/edr-freight-api/src/migrations/3800000000000-PerCurrencyExchangeSettings.ts b/apps/edr-freight-api/src/migrations/3800000000000-PerCurrencyExchangeSettings.ts new file mode 100644 index 000000000..986a0627f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3800000000000-PerCurrencyExchangeSettings.ts @@ -0,0 +1,98 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * `exchange_settings` becomes one row per currency instead of a single row holding the + * USD→ETB fallback. + * + * DJF was originally derived from the USD row through the currency's peg, on the grounds + * that a second stored number is a second thing that can go stale. That reasoning holds for + * storage but it left operators with no DJF rate on screen and no way to override one during + * an outage — the exact levers they have for USD. Parity won. + * + * `fallback_rate` means the same thing in every row: ETB per one unit of `currency`. The ETB + * row is therefore 1 and exists so the table describes the whole set rather than "the others". + * + * `enabled` generalises the `djf_enabled` flag added in 3790000000000: availability is a + * property of a currency, not a column named after one. + */ +export class PerCurrencyExchangeSettings3800000000000 + implements MigrationInterface +{ + name = 'PerCurrencyExchangeSettings3800000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."exchange_settings" + ADD COLUMN IF NOT EXISTS "currency" character varying(3) NOT NULL DEFAULT 'USD' + `); + await queryRunner.query(` + ALTER TABLE "freight"."exchange_settings" + ADD COLUMN IF NOT EXISTS "enabled" boolean NOT NULL DEFAULT true + `); + + // Carry the old global flag onto the DJF row seeded below, so an operator who had + // already switched DJF on does not find it off again after deploying. + const [existing] = (await queryRunner.query(` + SELECT COALESCE(bool_or("djf_enabled"), false) AS djf + FROM "freight"."exchange_settings" + WHERE "deleted_at" IS NULL + `)) as [{ djf: boolean }]; + + // One row per currency. Partial: soft-deleted rows must not block a re-insert. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "uq_exchange_settings_currency" + ON "freight"."exchange_settings" ("currency") + WHERE "deleted_at" IS NULL + `); + + // ETB is the base every rate is quoted against, so its rate to itself is 1. + await queryRunner.query(` + INSERT INTO "freight"."exchange_settings" + ("currency", "fallback_rate", "fallback_source", "enabled") + VALUES ('ETB', 1, 'AUTO', true) + ON CONFLICT DO NOTHING + `); + + // Seeded at the CBE DJF transactional selling rate on 2026-08-28. Overwritten by the + // first successful fetch, exactly like the USD row. + await queryRunner.query( + ` + INSERT INTO "freight"."exchange_settings" + ("currency", "fallback_rate", "fallback_source", "enabled") + VALUES ('DJF', 0.9203, 'AUTO', $1) + ON CONFLICT DO NOTHING + `, + [existing?.djf ?? false], + ); + + await queryRunner.query(` + ALTER TABLE "freight"."exchange_settings" DROP COLUMN IF EXISTS "djf_enabled" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."exchange_settings" + ADD COLUMN IF NOT EXISTS "djf_enabled" boolean NOT NULL DEFAULT false + `); + await queryRunner.query(` + UPDATE "freight"."exchange_settings" SET "djf_enabled" = COALESCE(( + SELECT "enabled" FROM "freight"."exchange_settings" + WHERE "currency" = 'DJF' AND "deleted_at" IS NULL LIMIT 1 + ), false) + `); + // Everything but the USD row goes; the single-row shape cannot hold the rest. + await queryRunner.query( + `DELETE FROM "freight"."exchange_settings" WHERE "currency" <> 'USD'`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "freight"."uq_exchange_settings_currency"`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."exchange_settings" DROP COLUMN IF EXISTS "enabled"`, + ); + 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 fda50ce50..b4064642f 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,23 +1,46 @@ -import { IsBoolean, IsNumber, IsOptional, Max, Min } from "class-validator"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { PAYMENT_CURRENCIES } from "@edr/types"; +import { + IsBoolean, + IsIn, + IsNumber, + IsOptional, + Max, + 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. + * Updates one currency's exchange settings. * - * Both fields are optional so the two controls on the page move independently: flipping - * the DJF toggle must not require re-submitting (and so re-stamping as MANUAL) a fallback - * rate the operator did not touch. + * Both value fields are optional so the two controls on the page move independently: + * flipping the availability toggle must not re-submit — and so re-stamp as MANUAL — a + * fallback rate the operator did not touch. */ export class UpdateExchangeSettingDto { + /** Which currency this update is about. Defaults to USD for callers that predate the rest. */ + @ApiPropertyOptional({ enum: PAYMENT_CURRENCIES, default: "USD" }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + currency?: (typeof PAYMENT_CURRENCIES)[number]; + + /** + * ETB per one unit of the currency, used only while CBE is unreachable. + * + * Bounded well outside any plausible published rate but far short of a fat-fingered + * magnitude error — this value multiplies real invoice amounts. The floor is deliberately + * fractional: a DJF rate is around 0.92 ETB, so the old `Min(1)` would have rejected every + * legitimate value for it. + */ + @ApiPropertyOptional({ minimum: 0.0001, maximum: 10_000 }) @IsOptional() @IsNumber({ maxDecimalPlaces: 6 }) - @Min(1) + @Min(0.0001) @Max(10_000) fallbackRate?: number; - /** Whether Djiboutian Franc may be chosen as a billing currency. */ + /** Whether this currency may be chosen for billing. ETB cannot be switched off. */ + @ApiPropertyOptional() @IsOptional() @IsBoolean() - djfEnabled?: boolean; + enabled?: boolean; } 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 bb7958b83..cc82b117b 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 @@ -1,5 +1,6 @@ import { BaseEntity } from "@edr/api-common"; -import { Column, Entity } from "typeorm"; +import { Column, Entity, Index } from "typeorm"; +import type { PaymentCurrency } from "@edr/types"; /** * Whether the stored fallback rate was written by the automatic sync (after a @@ -8,14 +9,27 @@ 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 billing currency, holding the 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. */ +@Index("uq_exchange_settings_currency", ["currency"], { + unique: true, + where: '"deleted_at" IS NULL', +}) @Entity({ schema: "freight", name: "exchange_settings" }) export class ExchangeSetting extends BaseEntity { - /** USD→ETB rate served while the CBE endpoint is failing. */ + /** + * ISO-4217 code this row is about. Unique among live rows — the partial unique index + * ignores soft-deleted ones so a currency can be removed and re-added. + */ + @Column({ name: "currency", type: "varchar", length: 3, default: "USD" }) + currency!: PaymentCurrency; + + /** + * ETB per one unit of {@link currency}, served while the CBE endpoint is failing. + * The ETB row is 1 by definition. + */ @Column({ name: "fallback_rate", type: "numeric", @@ -42,15 +56,13 @@ export class ExchangeSetting extends BaseEntity { lastSyncedAt?: Date | null; /** - * Whether Djiboutian Franc may be chosen as a billing currency. + * Whether this currency may be chosen for billing. * - * Off by default: DJF pricing goes live only once Finance says so, so enabling it is a - * deliberate act rather than something a deploy switches on. The rate itself is always - * available (CBE quotes DJF in the same payload as USD) — this gates the *offer*, not - * the conversion. + * Replaces the `djf_enabled` flag: availability is a property of a currency, not a + * column named after one. ETB cannot be switched off — it is the base. */ - @Column({ name: "djf_enabled", type: "boolean", default: false }) - djfEnabled!: boolean; + @Column({ name: "enabled", type: "boolean", default: true }) + enabled!: boolean; /** IAM user id of the last operator to set the rate manually. */ @Column({ name: "updated_by_id", type: "uuid", nullable: true }) 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..e5bf6698f 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 fallbacks wired in. * * `ExchangeModule` is registered per-feature-module (bookings, contracts, * warehouses), so this keeps the three call sites identical rather than @@ -20,8 +20,9 @@ export function registerExchangeModule(): DynamicModule { settings: ExchangeSettingsService, ): ExchangeOptions => ({ ...(config.get("app.cbeExchange") ?? {}), - loadFallbackRate: () => settings.loadFallbackRate(), - saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate), + loadFallbackRate: (currency) => settings.loadFallbackRate(currency), + saveFallbackRate: (currency, rate) => + settings.saveFallbackRate(currency, rate), }), }); } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-rate.spec.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-rate.spec.ts index 325275d6e..084902a2f 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-rate.spec.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-rate.spec.ts @@ -1,5 +1,9 @@ -import { CbeExchangeProvider, ExchangeService } from '@edr/api-common'; -import { EXCHANGE_OPTIONS } from '@edr/api-common'; +import { + CbeExchangeProvider, + EXCHANGE_OPTIONS, + ExchangeService, + type ExchangeOptions, +} from '@edr/api-common'; /** * A CBE `daily-exchange-rates` payload, trimmed to the entries that matter. The real one @@ -19,8 +23,9 @@ const cbePayload = (over: Record = {}) => [ const okFetch = (payload: unknown) => jest.fn().mockResolvedValue({ ok: true, json: async () => payload }); -const service = (options = {}) => - new ExchangeService({ cacheTtlMs: 0, ...options } as never); +/** Typed, so the fallback callbacks' currency argument is inferred rather than `any`. */ +const service = (options: ExchangeOptions = {}) => + new ExchangeService({ cacheTtlMs: 0, ...options }); describe('CBE exchange rates', () => { const realFetch = global.fetch; @@ -55,12 +60,56 @@ describe('CBE exchange rates', () => { ); }); - it('prefers the stored fallback over the static one, still via the peg', async () => { + it("prefers the currency's OWN stored fallback over anything derived", async () => { global.fetch = jest.fn().mockRejectedValue(new Error('timeout')) as never; - const svc = service({ fallbackRate: 162, loadFallbackRate: async () => 163.4365 }); + // An operator set DJF by hand during the outage. That number wins outright — it is not + // re-derived from USD through the peg, which would silently discard what they typed. + const svc = service({ + fallbackRate: 162, + loadFallbackRate: async (code) => + ({ USD: 163.4365, DJF: 0.95, ETB: 1 })[code] ?? null, + }); + await expect(svc.getRate('DJF', 'ETB')).resolves.toBe(0.95); + }); + + it('falls back to the stored USD rate via the peg when the currency has none', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('timeout')) as never; + const svc = service({ + fallbackRate: 162, + loadFallbackRate: async (code) => (code === 'USD' ? 163.4365 : null), + }); + // Still better than the compiled-in default, which is a year-old number by definition. await expect(svc.getRate('DJF', 'ETB')).resolves.toBeCloseTo(163.4365 / 177.721, 6); }); + it('stores every quoted rate, not just USD', async () => { + const saved: Array<[string, number]> = []; + global.fetch = okFetch(cbePayload()) as never; + const svc = service({ + saveFallbackRate: async (code, rate) => { + saved.push([code, rate]); + }, + }); + await svc.getRate('USD', 'ETB'); + // Both, from the one payload — otherwise DJF has nothing to fall back to next outage. + expect(saved).toEqual( + expect.arrayContaining([ + ['USD', 163.4365], + ['DJF', 0.9203], + ]), + ); + }); + + it('reports every rate in the feed status, so an operator can see them', async () => { + const provider = new CbeExchangeProvider({ cacheTtlMs: 0 }); + global.fetch = okFetch(cbePayload()) as never; + await provider.getBaseRate({ from: 'USD', to: 'ETB' }); + expect(provider.getStatus().rates).toMatchObject({ + USD: 163.4365, + DJF: 0.9203, + }); + }); + it('skips a currency CBE publishes as zero rather than pricing off it', async () => { // CBE publishes 0/null for currencies it is not quoting that day. A zero rate would // zero every DJF line on the invoice. 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 0d7ca565b..5c830b85f 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 @@ -17,18 +17,28 @@ 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: "Per-currency fallback rates and CBE feed health", }) async get() { - const setting = await this.service.get(); - const status = this.service.getFeedStatus(); + const [settings, status] = await Promise.all([ + this.service.list(), + Promise.resolve(this.service.getFeedStatus()), + ]); return { - fallbackRate: setting.fallbackRate, - fallbackSource: setting.fallbackSource, - lastSyncedAt: setting.lastSyncedAt, - updatedById: setting.updatedById, - djfEnabled: setting.djfEnabled, + // Every currency, each with the rate actually being applied to it right now — the + // live figure when CBE is answering, the stored fallback when it is not. + currencies: settings.map((setting) => ({ + currency: setting.currency, + fallbackRate: setting.fallbackRate, + fallbackSource: setting.fallbackSource, + lastSyncedAt: setting.lastSyncedAt, + updatedById: setting.updatedById, + enabled: setting.enabled || setting.currency === "ETB", + /** ETB is the base; it has no rate to fetch and cannot be turned off. */ + isBase: setting.currency === "ETB", + liveRate: status.rates[setting.currency] ?? null, + })), feed: status, }; } @@ -48,37 +58,43 @@ export class ExchangeSettingsController { @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: - "Set the USD→ETB fallback by hand (used only while CBE is unreachable) " + - "and/or turn DJF billing on and off", + "Set one currency's fallback rate by hand (used only while CBE is unreachable) " + + "and/or turn its billing on and off", }) async update( @Body() dto: UpdateExchangeSettingDto, @CurrentUser() user: TCurrentUser, ) { + // Defaults to USD so callers written against the single-rate shape keep working. + const currency = dto.currency ?? "USD"; + // An omitted field leaves that setting alone — setting the rate stamps it MANUAL, and - // toggling DJF must not do that as a side effect. - let updated = await this.service.get(); + // toggling availability must not do that as a side effect. + let updated = await this.service.get(currency); if (dto.fallbackRate !== undefined) { updated = await this.service.setManualRate( + currency, dto.fallbackRate, user?.id ?? null, ); } - if (dto.djfEnabled !== undefined) { - updated = await this.service.setDjfEnabled( - dto.djfEnabled, + if (dto.enabled !== undefined) { + updated = await this.service.setEnabled( + currency, + dto.enabled, user?.id ?? null, ); } return { + currency: updated.currency, fallbackRate: updated.fallbackRate, fallbackSource: updated.fallbackSource, lastSyncedAt: updated.lastSyncedAt, updatedById: updated.updatedById, - djfEnabled: updated.djfEnabled, + enabled: updated.enabled || updated.currency === "ETB", }; } } 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 ffd2348a4..3bb800fe6 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 @@ -10,15 +10,31 @@ import { 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 the first successful CBE fetch. + * + * USD is the CBE transactional selling rate on 2026-08-04; DJF the same feed's quote on + * 2026-08-28. ETB is the base every rate is quoted against, so its rate to itself is 1. */ -const SEED_FALLBACK_RATE = 162.4165; +const SEED_FALLBACK_RATES: Record = { + ETB: 1, + USD: 162.4165, + DJF: 0.9203, +}; + +/** Currencies offered before an operator has touched anything. */ +const SEED_ENABLED: Record = { + ETB: true, + USD: true, + // Off until Finance says so — see the 3790000000000 migration. + DJF: false, +}; /** Health of the CBE feed, as surfaced to the backoffice. */ export interface ExchangeFeedStatus { - /** Rate most recently observed, whatever its source. */ + /** USD→ETB most recently observed. Kept for callers that predate multi-currency. */ rate: number | null; + /** Every `code`→ETB rate the feed last served, so each is visible to an operator. */ + rates: Record; /** `live` means CBE answered; `stored`/`default` mean it is failing. */ source: "live" | "stored" | null; /** ISO timestamp of the last successful fetch. */ @@ -28,12 +44,12 @@ export interface ExchangeFeedStatus { } /** - * Owns the single `exchange_settings` row: the USD→ETB fallback used when the - * CBE endpoint is unreachable. + * Owns `exchange_settings`: one row per billing currency, each holding the ETB fallback + * used when the CBE endpoint is unreachable, and whether that currency may be billed in. * - * 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 - * rate rather than drifting into a stale constant. + * The live CBE rate is always preferred. A stored value is only read on failure, and every + * successful fetch overwrites it, so it tracks the last known good rate rather than + * drifting into a stale constant. */ @Injectable() export class ExchangeSettingsService { @@ -48,6 +64,7 @@ export class ExchangeSettingsService { */ private feed: ExchangeFeedStatus = { rate: null, + rates: {}, source: null, lastSuccessAt: null, lastError: null, @@ -60,100 +77,41 @@ export class ExchangeSettingsService { /** Health of the CBE feed as last observed by any provider instance. */ getFeedStatus(): ExchangeFeedStatus { - return { ...this.feed }; + return { ...this.feed, rates: { ...this.feed.rates } }; } - /** The settings row, created at the seed rate on first access. */ - async get(): Promise { - const existing = await this.repository.findOne({ where: {} }); + /** One currency's settings row, created at its seed rate on first access. */ + async get(currency: PaymentCurrency = "USD"): Promise { + const existing = await this.repository.findOne({ where: { currency } }); if (existing) return existing; return this.repository.save( this.repository.create({ - fallbackRate: SEED_FALLBACK_RATE, + currency, + fallbackRate: SEED_FALLBACK_RATES[currency], fallbackSource: "AUTO", lastSyncedAt: null, - djfEnabled: false, + enabled: SEED_ENABLED[currency], }), ); } - /** - * 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. - */ - async loadFallbackRate(): Promise { - // Only reached when the live fetch failed, so this call is itself the - // signal that the feed is down. - try { - const { fallbackRate } = await this.get(); - const usable = Number.isFinite(fallbackRate) && fallbackRate > 0; - this.feed = { - ...this.feed, - rate: usable ? fallbackRate : this.feed.rate, - source: "stored", - lastError: this.feed.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}`); - 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. - */ - async saveFallbackRate(rate: number): Promise { - // Only called after a successful fetch, so the feed is confirmed healthy. - this.feed = { - rate, - source: "live", - lastSuccessAt: new Date().toISOString(), - lastError: null, - }; - - const current = await this.get(); - 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`); - } - - /** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */ - async setManualRate( - rate: number, - updatedById?: string | null, - ): Promise { - const current = await this.get(); - 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"}`, - ); - return this.get(); + /** Every currency's row, in the platform's canonical order. */ + async list(): Promise { + return Promise.all(PAYMENT_CURRENCIES.map((code) => this.get(code))); } /** * Currencies a booking or contract may currently be billed in. * - * ETB and USD are permanent — the platform has always offered both. DJF is behind the - * operator toggle, so this is the one place that decides, rather than each form guessing. + * ETB is always offered — it is the base, and disabling it would leave intercity traffic + * with no currency at all. */ async enabledCurrencies(): Promise { - const { djfEnabled } = await this.get(); - return PAYMENT_CURRENCIES.filter((code) => code !== "DJF" || djfEnabled); + const rows = await this.list(); + return rows + .filter((row) => row.enabled || row.currency === "ETB") + .map((row) => row.currency); } /** @@ -161,7 +119,8 @@ export class ExchangeSettingsService { * * Called wherever a currency is *chosen* (booking create, contract create, shipment * request) rather than wherever one is read: class-validator's `@IsIn` cannot see a - * database flag, so the static whitelist admits DJF and this decides whether it is live. + * database flag, so the static whitelist admits every currency and this decides which + * are live. */ async assertCurrencyAllowed(currency: string | null | undefined): Promise { const code = currency?.trim().toUpperCase(); @@ -181,19 +140,113 @@ export class ExchangeSettingsService { } } - /** Operator turns DJF billing on or off. */ - async setDjfEnabled( - enabled: boolean, + /** + * Reads a stored fallback for the exchange provider. Returns `null` on any failure so the + * provider falls through to its own peg/default rather than propagating a database error + * into a pricing call. + */ + async loadFallbackRate( + currency: PaymentCurrency = "USD", + ): Promise { + // Only reached when the live fetch failed, so this call is itself the + // signal that the feed is down. + try { + const { fallbackRate } = await this.get(currency); + const usable = Number.isFinite(fallbackRate) && fallbackRate > 0; + this.feed = { + ...this.feed, + rates: usable + ? { ...this.feed.rates, [currency]: fallbackRate } + : this.feed.rates, + rate: currency === "USD" && usable ? fallbackRate : this.feed.rate, + source: "stored", + lastError: this.feed.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 for ${currency}: ${message}`, + ); + return null; + } + } + + /** + * Records a freshly fetched live rate as that currency's 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. + */ + async saveFallbackRate( + currency: PaymentCurrency, + rate: number, + ): Promise { + // Only called after a successful fetch, so the feed is confirmed healthy. + this.feed = { + rate: currency === "USD" ? rate : this.feed.rate, + rates: { ...this.feed.rates, [currency]: rate }, + source: "live", + lastSuccessAt: new Date().toISOString(), + lastError: null, + }; + + const current = await this.get(currency); + 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 per ${currency}`, + ); + } + + /** Operator sets one currency's fallback by hand, e.g. during a prolonged CBE outage. */ + async setManualRate( + currency: PaymentCurrency, + rate: number, updatedById?: string | null, ): Promise { - const current = await this.get(); + if (currency === "ETB") { + throw new BadRequestException( + "ETB is the base currency — its rate to itself is always 1", + ); + } + + const current = await this.get(currency); await this.repository.update(current.id, { - djfEnabled: enabled, + fallbackRate: rate, + fallbackSource: "MANUAL", updatedById: updatedById ?? null, }); this.logger.warn( - `DJF billing ${enabled ? "enabled" : "disabled"} by ${updatedById ?? "unknown user"}`, + `Exchange fallback for ${currency} set manually to ${rate} by ${updatedById ?? "unknown user"}`, ); - return this.get(); + return this.get(currency); + } + + /** Operator turns a currency's billing on or off. */ + async setEnabled( + currency: PaymentCurrency, + enabled: boolean, + updatedById?: string | null, + ): Promise { + if (currency === "ETB" && !enabled) { + throw new BadRequestException( + "ETB cannot be switched off — it is the platform's base currency", + ); + } + + const current = await this.get(currency); + await this.repository.update(current.id, { + enabled, + updatedById: updatedById ?? null, + }); + this.logger.warn( + `${currency} billing ${enabled ? "enabled" : "disabled"} by ${updatedById ?? "unknown user"}`, + ); + return this.get(currency); } } diff --git a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts index b254b1162..1b27eff4d 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts @@ -2,10 +2,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { exchangeSettingsService } from "@/services/exchangeSettings.service"; +import { + exchangeSettingsService, + type UpdateExchangeSettingInput, +} from "@/services/exchangeSettings.service"; import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; const QUERY_KEY = ["exchangeSettings"]; +const ENABLED_CURRENCIES_KEY = ["enabledCurrencies"]; export const useExchangeSettingsQuery = () => useQuery({ @@ -16,45 +20,43 @@ export const useExchangeSettingsQuery = () => refetchOnWindowFocus: true, }); -export const useSetExchangeFallbackRate = () => { +/** + * Sets one currency's fallback rate, or turns its billing on and off. + * + * One mutation for both because the API takes one PATCH and leaves omitted fields alone — + * submitting the rate alongside a toggle would re-stamp it MANUAL as a side effect. + */ +export const useUpdateExchangeSetting = () => { const queryClient = useQueryClient(); const { t } = useTranslation(); const { handleError } = useErrorHandler(t); return useMutation({ - mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate), - onSuccess: () => { + mutationFn: (input: UpdateExchangeSettingInput) => + exchangeSettingsService.update(input), + onSuccess: (setting, input) => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + // The pickers read the enabled list, so an availability change has to reach them. + if (input.enabled !== undefined) { + queryClient.invalidateQueries({ queryKey: ENABLED_CURRENCIES_KEY }); + toast.success( + setting.enabled + ? t("exchangeSettings.enabled", `${setting.currency} billing enabled`) + : t("exchangeSettings.disabled", `${setting.currency} billing disabled`), + ); + return; + } toast.success( - t("exchangeSettings.updated", "Fallback exchange rate updated"), + t( + "exchangeSettings.updated", + `${setting.currency} fallback exchange rate updated`, + ), ); }, onError: handleError, }); }; -export const useSetDjfEnabled = () => { - const queryClient = useQueryClient(); - const { t } = useTranslation(); - const { handleError } = useErrorHandler(t); - - return useMutation({ - mutationFn: (enabled: boolean) => exchangeSettingsService.setDjfEnabled(enabled), - onSuccess: (settings) => { - queryClient.invalidateQueries({ queryKey: QUERY_KEY }); - queryClient.invalidateQueries({ queryKey: ENABLED_CURRENCIES_KEY }); - toast.success( - settings.djfEnabled - ? t("exchangeSettings.djfEnabled", "Djiboutian Franc billing enabled") - : t("exchangeSettings.djfDisabled", "Djiboutian Franc billing disabled"), - ); - }, - onError: handleError, - }); -}; - -const ENABLED_CURRENCIES_KEY = ["enabledCurrencies"]; - /** * Currencies a booking may be billed in right now. Every currency picker reads this * instead of hardcoding a list, so switching one off in Exchange Settings removes it from diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx index 304fd8c8b..c1692febf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx @@ -12,10 +12,12 @@ import { AlertTriangle, CheckCircle2, RefreshCw, Save } from "lucide-react"; import { useExchangeSettingsQuery, - useSetDjfEnabled, - useSetExchangeFallbackRate, + useUpdateExchangeSetting, } from "@/hooks/useExchangeSettings"; -import type { ExchangeRateSource } from "@/services/exchangeSettings.service"; +import type { + CurrencyExchangeSetting, + ExchangeRateSource, +} from "@/services/exchangeSettings.service"; import { formatDateTime } from "@/lib/format"; /** Feed health, phrased for an operator rather than a developer. */ @@ -25,11 +27,11 @@ function feedLabel(source: ExchangeRateSource | null): { } { switch (source) { case "live": - return { live: true, text: "CBE reachable — using the live rate" }; + return { live: true, text: "CBE reachable — using the live rates" }; case "stored": return { live: false, - text: "CBE unreachable — using the fallback rate below", + text: "CBE unreachable — using the fallback rates below", }; default: return { live: true, text: "No rate requested yet since the last restart" }; @@ -39,41 +41,132 @@ function feedLabel(source: ExchangeRateSource | null): { const formatTime = (value: string | null) => value ? formatDateTime(value) : "never"; +/** Rates below 1 (DJF is ~0.92 ETB) need more places than birr-per-dollar does. */ +const formatRate = (rate: number) => + rate >= 1 ? rate.toFixed(4) : rate.toFixed(6); + /** - * 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. + * One currency's fallback rate. Editable only for the non-base currencies — ETB is what + * every rate is quoted against, so its rate to itself is 1 and there is nothing to set. + */ +function CurrencyRow({ setting }: { setting: CurrencyExchangeSetting }) { + const update = useUpdateExchangeSetting(); + const [draft, setDraft] = useState(""); + + const value = draft !== "" ? draft : (setting.fallbackRate?.toString() ?? ""); + const parsed = Number(value); + const invalid = !Number.isFinite(parsed) || parsed < 0.0001 || parsed > 10_000; + const dirty = draft !== "" && parsed !== setting.fallbackRate; + + if (setting.isBase) { + return ( +
+

{setting.currency} — base currency

+

+ Every rate on this page is quoted as ETB per one unit of the currency, so ETB is + always 1 and is always available. +

+
+ ); + } + + return ( +
+
+
+

+ {setting.currency} → ETB + {setting.liveRate != null && ( + + live: {formatRate(setting.liveRate)} + + )} +

+

+ {setting.enabled + ? "Offered on new bookings and shipment requests." + : "Not offered — existing bookings priced in it keep their currency."} +

+
+ +
+ +
+ setDraft(e.target.value)} + /> + +
+ + {invalid && draft !== "" && ( +

+ Enter a rate between 0.0001 and 10,000. +

+ )} +

+ {setting.fallbackSource === "MANUAL" + ? "Set manually. The next successful CBE update will replace it." + : `Synced automatically from CBE (${formatTime(setting.lastSyncedAt)}).`} +

+
+ ); +} + +/** + * Fallback rates used when the CBE exchange-rate endpoint is unreachable, one per billing + * currency, plus whether each currency may be billed in at all. + * + * 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. */ export default function ExchangeRateSettingsCard() { const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery(); - const setRate = useSetExchangeFallbackRate(); - const setDjf = useSetDjfEnabled(); - const [draft, setDraft] = useState(""); - - const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? ""); - const parsed = Number(value); - const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000; - const dirty = draft !== "" && parsed !== data?.fallbackRate; - const feed = feedLabel(data?.feed?.source ?? null); - const handleSave = async () => { - if (invalid) return; - await setRate.mutateAsync(parsed); - setDraft(""); - }; - return (
- Exchange rate (USD → ETB) + Exchange rates - 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, which quotes every currency + against birr. The fallbacks below are used only when CBE cannot be reached, + and are refreshed automatically after every successful update.
-
- -
- setDraft(e.target.value)} - /> - -
- {invalid && draft !== "" && ( -

- Enter a rate between 1 and 10,000. -

- )} -

- {data?.fallbackSource === "MANUAL" - ? "Set manually. The next successful CBE update will replace it." - : `Synced automatically from CBE (${formatTime( - data?.lastSyncedAt ?? null, - )}).`} -

-
- -
- -
+ {isLoading ? ( +

Loading rates…

+ ) : ( + (data?.currencies ?? []).map((setting) => ( + + )) + )}
); diff --git a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts index d19ddc47d..e98181d63 100644 --- a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts @@ -14,42 +14,56 @@ export type ExchangeRateSource = "live" | "stored"; /** Health of the CBE exchange-rate feed. */ export interface ExchangeFeedStatus { + /** USD→ETB. Kept for callers written before the other currencies existed. */ rate: number | null; + /** Every code→ETB rate the feed last served. */ + rates: Record; source: ExchangeRateSource | null; lastSuccessAt: string | null; lastError: string | null; } -export interface ExchangeSettings { +/** One currency's row: what it falls back to, and whether it may be billed in. */ +export interface CurrencyExchangeSetting { + currency: PaymentCurrency; + /** ETB per one unit of `currency`. */ fallbackRate: number; /** `AUTO` when synced from CBE, `MANUAL` when set here. */ fallbackSource: "AUTO" | "MANUAL"; lastSyncedAt: string | null; updatedById: string | null; - /** Whether Djiboutian Franc may be chosen as a billing currency. */ - djfEnabled: boolean; + enabled: boolean; + /** ETB: the base every rate is quoted against. No rate to fetch, cannot be disabled. */ + isBase: boolean; + /** What the live feed is currently serving for it, or null while the feed is down. */ + liveRate: number | null; +} + +export interface ExchangeSettings { + currencies: CurrencyExchangeSetting[]; feed?: ExchangeFeedStatus; } +/** An omitted field is left untouched by the API, so each control submits only its own. */ +export interface UpdateExchangeSettingInput { + currency: PaymentCurrency; + fallbackRate?: number; + enabled?: boolean; +} + export const exchangeSettingsService = { get: async (): Promise => { const response = await client.get>(BASE); return unwrap(response.data); }, - setFallbackRate: async (fallbackRate: number): Promise => { - const response = await client.patch>(BASE, { - fallbackRate, - }); - return unwrap(response.data); - }, - - setDjfEnabled: async (djfEnabled: boolean): Promise => { - // Sent alone: the PATCH leaves an omitted field untouched, so this must not - // re-submit the fallback rate and re-stamp it MANUAL. - const response = await client.patch>(BASE, { - djfEnabled, - }); + update: async ( + input: UpdateExchangeSettingInput, + ): Promise => { + const response = await client.patch>( + BASE, + input, + ); return unwrap(response.data); }, @@ -58,9 +72,9 @@ export const exchangeSettingsService = { * too, so the portal's picker offers exactly what the API will accept. */ enabledCurrencies: async (): Promise => { - const response = await client.get>( - `${BASE}/currencies`, - ); + const response = await client.get< + ApiResponse<{ currencies: PaymentCurrency[] }> + >(`${BASE}/currencies`); return unwrap(response.data).currencies; }, }; diff --git a/packages/api-common/src/services/exchange/cbe.provider.ts b/packages/api-common/src/services/exchange/cbe.provider.ts index 190f12143..ea475afe2 100644 --- a/packages/api-common/src/services/exchange/cbe.provider.ts +++ b/packages/api-common/src/services/exchange/cbe.provider.ts @@ -29,8 +29,13 @@ export type CbeRateSource = "live" | "cache" | "stored" | "default"; /** Health of the CBE feed, for operator-facing status displays. */ export interface CbeProviderStatus { - /** The USD→ETB rate most recently served, whatever its source. */ + /** + * The USD→ETB rate most recently served, whatever its source. Kept for callers that + * predate multi-currency support; {@link rates} is the complete picture. + */ rate: number | null; + /** Every `code`→ETB rate currently held, so an operator can see the one being applied. */ + rates: Record; /** Where that rate came from. `live` means the API answered. */ source: CbeRateSource | null; /** Epoch ms of the last successful live fetch, or `null` if never. */ @@ -49,6 +54,12 @@ export interface CbeProviderStatus { */ const DJF_PER_USD = 177.721; +/** + * Currencies whose rate is worth storing. ETB is excluded: it is the base every rate is + * quoted against, so its rate to itself is 1 and CBE never quotes it. + */ +const SUPPORTED: readonly CurrencyCode[] = ["USD", "DJF"]; + /** * Commercial Bank of Ethiopia (CBE) rate provider. * @@ -94,6 +105,7 @@ export class CbeExchangeProvider implements ExchangeRateProvider { getStatus(): CbeProviderStatus { return { rate: this.cachedRates?.get("USD") ?? null, + rates: Object.fromEntries(this.cachedRates ?? []), source: this.lastSource, lastSuccessAt: this.lastSuccessAt, lastError: this.lastError, @@ -135,14 +147,25 @@ export class CbeExchangeProvider implements ExchangeRateProvider { return cached; } - const stored = await this.loadStoredFallback(); + // This currency's own stored rate first — an operator may have set it by hand + // precisely because the feed is down. + const stored = await this.loadStoredFallback(code); if (stored !== null) { this.lastSource = "stored"; - const derived = this.deriveFromUsd(code, stored); - this.logger.warn( - `Using stored fallback CBE rate for ${code}: ${derived ?? "unavailable"}`, - ); - return derived; + this.logger.warn(`Using stored fallback CBE rate for ${code}: ${stored}`); + return stored; + } + + // Nothing stored for it: fall back to USD's stored rate through the peg rather than + // straight to the compiled-in default, which is a year-old number by definition. + const storedUsd = code === "USD" ? null : await this.loadStoredFallback("USD"); + if (storedUsd !== null) { + this.lastSource = "stored"; + const derived = this.deriveFromUsd(code, storedUsd); + if (derived !== null) { + this.logger.warn(`Using stored USD fallback via peg for ${code}: ${derived}`); + return derived; + } } this.lastSource = "default"; @@ -189,7 +212,7 @@ export class CbeExchangeProvider implements ExchangeRateProvider { ); } - const previous = this.cachedRates?.get("USD") ?? null; + const previous = this.cachedRates; this.cachedRates = rates; this.cacheExpiresAt = now + cacheTtlMs; this.lastSuccessAt = now; @@ -199,13 +222,9 @@ export class CbeExchangeProvider implements ExchangeRateProvider { `CBE rates refreshed — ${rates.size} currencies quoted, USD→ETB=${usd} (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. USD only: every other - // currency derives from it, so a second stored number would just be a second thing - // that can go stale. - if (usd !== previous) { - await this.persistFallback(usd); - } + // Persist each changed rate as that currency's new fallback, so a later outage reuses + // the last good one. Skipped when unchanged, to avoid pointless writes and audit noise. + await this.persistFallbacks(rates, previous); return rates; } @@ -226,16 +245,24 @@ 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 { + private async persistFallbacks( + rates: Map, + previous: Map | null, + ): Promise { const { saveFallbackRate } = this.options; if (!saveFallbackRate) return; - try { - await saveFallbackRate(rate); - } catch (err) { - this.logger.warn( - `Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`, - ); + for (const code of SUPPORTED) { + const rate = rates.get(code); + if (rate === undefined || rate === previous?.get(code)) continue; + + try { + await saveFallbackRate(code, rate); + } catch (err) { + this.logger.warn( + `Failed to persist CBE fallback rate ${rate} for ${code}: ${(err as Error).message}`, + ); + } } } @@ -243,17 +270,17 @@ export class CbeExchangeProvider implements ExchangeRateProvider { * Reads the persisted fallback. Returns `null` — falling through to the * static default — when unconfigured, unusable, or itself failing. */ - private async loadStoredFallback(): Promise { + private async loadStoredFallback(code: CurrencyCode): Promise { 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; } diff --git a/packages/api-common/src/services/exchange/exchange.options.ts b/packages/api-common/src/services/exchange/exchange.options.ts index 6ccf83489..62e32a1f3 100644 --- a/packages/api-common/src/services/exchange/exchange.options.ts +++ b/packages/api-common/src/services/exchange/exchange.options.ts @@ -1,3 +1,5 @@ +import type { CurrencyCode } from "./exchange.types"; + /** Injection token carrying the resolved {@link ExchangeOptions}. */ export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS"); @@ -11,31 +13,32 @@ 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. + * Last-resort USD→ETB rate, used only when the fetch fails, no cached rate exists, and + * {@link loadFallbackRate} supplies nothing. Other currencies derive from it through + * their peg — it is the floor under the whole set, not just under USD. * @default 162 */ fallbackRate?: 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 `currency`→ETB fallback — 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}. * - * Optional: omit it and the provider uses the static `fallbackRate` alone. + * Per currency, because each one is separately settable and separately stale. Optional: + * omit it and the provider uses the static `fallbackRate` alone. */ - loadFallbackRate?: () => Promise; + loadFallbackRate?: (currency: CurrencyCode) => Promise; /** - * 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 as that currency's new fallback, so the stored + * value is never more than one successful fetch stale. Called after every successful + * fetch, for each currency whose rate changed. * * Failures here are logged and swallowed — persisting the fallback must * never break the pricing call that triggered it. */ - saveFallbackRate?: (rate: number) => Promise; + saveFallbackRate?: (currency: CurrencyCode, rate: number) => Promise; /** * How long a successfully fetched rate is cached, in milliseconds.