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.