From e145eb16e3f0b968d31709d2ecffc990ae2c78d2 Mon Sep 17 00:00:00 2001 From: edochieblessing09-max Date: Tue, 28 Jul 2026 16:28:48 +0100 Subject: [PATCH 1/2] feat: cost-aware FX provider selection - Add FxProviderBudgetRegistry: tracks per-tenant monthly spend per provider using a pluggable SpendStore interface; enforces monthlyCapUsd caps with configurable degradationThreshold (default 90 %). - Add CostAwareRateSelector: ranks providers by accuracyRank (desc), skips exhausted providers, degrades to cheaper alternatives when near budget limit, records spend on every API call, and emits: * fx.provider.spend_month gauge (spend in USD this month) * fx.provider.degraded_total counter (budget-pressure degradations) * fx.provider.selection_total counter (all selections) - Add InMemorySpendStore: single-process store for dev/test; production deployments should wire a Postgres-backed implementation. - Add docs/fx-cost-aware-selection.md covering architecture, config, security assumptions, abuse/failure paths, and Postgres schema example. - Add src/services/fxCostAwareSelection.test.ts with comprehensive test suite (>= 95 % coverage target) covering: * Budget registry CRUD and cap enforcement * Normal provider selection (most accurate first) * Near-limit and exhaustion degradation * Budget cap exhaustion never blocks distributions * Provider error / null rate handling * Metric emission for gauge and counters * Multi-tenant isolation * Security edge cases (negative spend, storage failures, tenant injection, fail-open behaviour) --- docs/fx-cost-aware-selection.md | 255 +++++++++ src/services/fxCostAwareSelection.test.ts | 645 ++++++++++++++++++++++ src/services/fxCostAwareSelector.ts | 350 ++++++++++++ src/services/fxProviderBudgetRegistry.ts | 360 ++++++++++++ 4 files changed, 1610 insertions(+) create mode 100644 docs/fx-cost-aware-selection.md create mode 100644 src/services/fxCostAwareSelection.test.ts create mode 100644 src/services/fxCostAwareSelector.ts create mode 100644 src/services/fxProviderBudgetRegistry.ts diff --git a/docs/fx-cost-aware-selection.md b/docs/fx-cost-aware-selection.md new file mode 100644 index 00000000..d20cab91 --- /dev/null +++ b/docs/fx-cost-aware-selection.md @@ -0,0 +1,255 @@ +# FX Cost-Aware Provider Selection + +## Overview + +Some FX rate providers (e.g. Bloomberg, Refinitiv) are more accurate but charge +per API call. This feature adds **cost-aware provider selection** that: + +- Tracks per-provider API spend on a per-tenant, per-calendar-month basis. +- Automatically degrades to cheaper alternatives when a provider's monthly + budget cap is approached or exceeded. +- Emits the `fx.provider.spend_month` gauge so dashboards can track live spend + against caps. +- **Never blocks distributions** — a zero-cost fallback provider is mandatory. + +--- + +## Architecture + +``` +FxConversionEngine + │ + │ getRate(from, to) + ▼ +CostAwareRateSelector ← selects the best affordable provider + │ + ├── CostedRateProvider[] ← sorted by accuracy (descending) + │ ├── bloomberg ($0.05/call, rank 100) + │ ├── refinitiv ($0.02/call, rank 80) + │ └── ecb ($0.00/call, rank 50) ← mandatory free tier + │ + └── FxProviderBudgetRegistry + │ + └── SpendStore (InMemorySpendStore or Postgres-backed) +``` + +### Key files + +| File | Purpose | +|---|---| +| `src/services/fxProviderBudgetRegistry.ts` | Per-tenant monthly spend tracking and budget cap enforcement | +| `src/services/fxCostAwareSelector.ts` | Provider selection logic with degradation and metric emission | +| `src/services/fxCostAwareSelection.test.ts` | Comprehensive test suite (≥95% coverage) | + +--- + +## Provider Selection Algorithm + +1. Sort all providers by `accuracyRank` descending (most-accurate first). +2. For each provider (most-accurate → least-accurate): + - **Skip** if budget is **exhausted** (`spend ≥ cap`). + - **Skip** if budget is **near the limit** (`spend ≥ cap × degradationThreshold`) + *and* a cheaper non-exhausted provider exists below. + - Otherwise **select** this provider. +3. Call `getRate(from, to)` on the selected provider. +4. Record the call cost in `FxProviderBudgetRegistry`. +5. Emit Prometheus metrics. + +> **Invariant:** If all paid providers are exhausted, the free (`costUsdPerCall: 0`) +> provider is always used. The selector constructor enforces the presence of at +> least one free provider at startup. + +--- + +## Emitted Metrics + +| Metric | Type | Description | +|---|---|---| +| `fx.provider.spend_month` | Gauge | Accumulated spend in USD for a provider in the current month. Labels: `provider_id`. | +| `fx.provider.degraded_total` | Counter | Times a cheaper provider was used due to budget pressure. Labels: `provider_id`. | +| `fx.provider.selection_total` | Counter | Total provider selection attempts. Labels: `provider_id`. | + +--- + +## Configuration + +### 1. Configure provider budgets per tenant + +```typescript +import { FxProviderBudgetRegistry, InMemorySpendStore } from './services/fxProviderBudgetRegistry'; + +const store = new InMemorySpendStore(); // replace with Postgres in production +const registry = new FxProviderBudgetRegistry(store); + +// Set a $500/month cap for the bloomberg provider for tenant "acme" +registry.configureProvider('acme', { + providerId: 'bloomberg', + monthlyCapUsd: 500, + degradationThreshold: 0.9, // degrade when 90% of cap is used +}); + +registry.configureProvider('acme', { + providerId: 'refinitiv', + monthlyCapUsd: 200, + degradationThreshold: 0.85, +}); +// ecb (free) needs no budget config +``` + +### 2. Create the selector + +```typescript +import { CostAwareRateSelector } from './services/fxCostAwareSelector'; + +const selector = new CostAwareRateSelector( + [ + { + providerId: 'bloomberg', + provider: bloombergProvider, + costUsdPerCall: 0.05, + accuracyRank: 100, + }, + { + providerId: 'refinitiv', + provider: refinitivProvider, + costUsdPerCall: 0.02, + accuracyRank: 80, + }, + { + // REQUIRED: at least one free provider to guarantee availability + providerId: 'ecb', + provider: ecbProvider, + costUsdPerCall: 0, + accuracyRank: 50, + }, + ], + registry, + { metrics } +); +``` + +### 3. Use in FxConversionEngine + +```typescript +// Wrap the selector as a RateProvider +const costAwareProvider: RateProvider = { + getRate: (from, to) => + selector.selectRate(tenantId, from, to).then((r) => r.rate), +}; + +const engine = new FxConversionEngine(costAwareProvider, { metrics }); +``` + +### 4. Emit spend gauges on a schedule + +```typescript +// Every 60 seconds, emit gauge for all providers that have spend this month +setInterval(() => selector.emitSpendGauges(tenantId), 60_000); +``` + +--- + +## Budget Storage + +### Development / testing + +`InMemorySpendStore` is provided for local development and tests. It is +non-durable and not safe for multi-replica deployments. + +### Production + +Implement the `SpendStore` interface against a durable backend: + +```typescript +export interface SpendStore { + increment(tenantId: string, providerId: string, monthKey: string, amountUsd: number): Promise; + get(tenantId: string, providerId: string, monthKey: string): Promise; + listByTenant(tenantId: string, monthKey: string): Promise; +} +``` + +**Recommended Postgres implementation:** + +```sql +-- schema +CREATE TABLE fx_provider_spend ( + tenant_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + month_key CHAR(7) NOT NULL, -- 'YYYY-MM' + spend_usd NUMERIC(12,6) NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, provider_id, month_key) +); + +-- atomic increment +INSERT INTO fx_provider_spend (tenant_id, provider_id, month_key, spend_usd) +VALUES ($1, $2, $3, $4) +ON CONFLICT (tenant_id, provider_id, month_key) +DO UPDATE SET spend_usd = fx_provider_spend.spend_usd + EXCLUDED.spend_usd, + updated_at = now(); +``` + +Use `SELECT FOR UPDATE` or advisory locks if strict serialisation is required. + +--- + +## Security Assumptions + +1. **`tenantId` is pre-authenticated.** The registry and selector perform no + authentication; they inherit the trust of the calling layer. + +2. **Cost values are operator-defined.** `costUsdPerCall` is set in code, not + derived from external inputs, preventing cost-manipulation attacks. + +3. **Fail-open on storage errors.** If the spend store is unreachable, the + registry returns "available" for all providers so distributions are never + blocked. Operators should alert on storage errors separately. + +4. **Metric labels carry no PII.** Only `provider_id` (not `tenant_id`) is + used as a metric label to keep cardinality low and prevent PII exposure. + +5. **Negative spend increments are rejected.** `InMemorySpendStore.increment` + and `FxProviderBudgetRegistry.recordSpend` both throw `RangeError` on + negative values to prevent underflow manipulation. + +6. **Budget caps are hard limits, not soft advice.** When `isExhausted` is + `true`, the selector never sends traffic to that provider regardless of + operator intent. + +--- + +## Abuse / Failure Paths + +| Scenario | Behaviour | +|---|---| +| All paid providers exhausted | Free (`ecb`) provider selected; no error | +| Spend store unreachable | Fail-open: all providers treated as available | +| Provider API error / timeout | `rate` is `null`; cost still recorded; caller handles null | +| Negative spend attempt | `RangeError` thrown immediately | +| No budget config for provider | Treated as "always available" (open budget) | +| Degradation threshold > cap | `RangeError` at `configureProvider` time | +| Missing free provider at startup | `Error` thrown in `CostAwareRateSelector` constructor | +| Provider with negative cost | `RangeError` thrown in `CostAwareRateSelector` constructor | + +--- + +## Testing + +```bash +# Run only cost-aware selection tests +npx jest src/services/fxCostAwareSelection.test.ts --coverage + +# Run the full FX service suite +npx jest src/services/fxConversionEngine.test.ts src/services/fxCostAwareSelection.test.ts --coverage +``` + +Test coverage target: **≥ 95%** on `fxProviderBudgetRegistry.ts` and +`fxCostAwareSelector.ts`. + +--- + +## Changelog + +| Date | Change | +|---|---| +| 2026-07-28 | Initial implementation of cost-aware FX provider selection | diff --git a/src/services/fxCostAwareSelection.test.ts b/src/services/fxCostAwareSelection.test.ts new file mode 100644 index 00000000..06345574 --- /dev/null +++ b/src/services/fxCostAwareSelection.test.ts @@ -0,0 +1,645 @@ +/** + * Tests for FX Cost-Aware Provider Selection + * + * Covers: + * - FxProviderBudgetRegistry (unit) + * - CostAwareRateSelector (unit + integration) + * - Budget cap exhaustion never blocks distributions + * - Near-limit degradation selects cheaper provider + * - Spend recording on successful and null rate retrieval + * - Metric emission (fx.provider.spend_month gauge, degraded counter) + * - Security / abuse edge cases (negative spend, misconfiguration) + */ + +import { + InMemorySpendStore, + FxProviderBudgetRegistry, + currentMonthKey, + MonthKey, + SpendStore, +} from './fxProviderBudgetRegistry'; + +import { + CostAwareRateSelector, + CostedRateProvider, + METRIC_SPEND_MONTH, + METRIC_DEGRADED_TOTAL, + METRIC_SELECTION_TOTAL, +} from './fxCostAwareSelector'; + +import { InMemoryRateProvider } from './fxConversionEngine'; +import { MetricsCollector } from '../lib/metrics'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const TENANT = 'tenant-test'; +const MONTH: MonthKey = '2025-01'; + + + +function makeRateProvider(pair: string, mid = '1.00'): InMemoryRateProvider { + const p = new InMemoryRateProvider(); + p.setRateFromValues(pair, mid, mid, mid, 300000); + return p; +} + +/** Build a CostAwareRateSelector with three providers: expensive > mid > free. */ +function makeSelector( + store: InMemorySpendStore, + registry: FxProviderBudgetRegistry, + metrics?: MetricsCollector +): { selector: CostAwareRateSelector; expensive: InMemoryRateProvider; cheap: InMemoryRateProvider; free: InMemoryRateProvider } { + const expensive = makeRateProvider('USD/EUR', '0.92'); + const cheap = makeRateProvider('USD/EUR', '0.91'); + const free = makeRateProvider('USD/EUR', '0.90'); + + const providers: CostedRateProvider[] = [ + { providerId: 'bloomberg', provider: expensive, costUsdPerCall: 0.05, accuracyRank: 100 }, + { providerId: 'refinitiv', provider: cheap, costUsdPerCall: 0.02, accuracyRank: 80 }, + { providerId: 'ecb', provider: free, costUsdPerCall: 0, accuracyRank: 50 }, + ]; + + const selector = new CostAwareRateSelector(providers, registry, { metrics }); + return { selector, expensive, cheap, free }; +} + +// ─── InMemorySpendStore ─────────────────────────────────────────────────────── + +describe('InMemorySpendStore', () => { + let store: InMemorySpendStore; + beforeEach(() => { store = new InMemorySpendStore(); }); + + it('returns 0 for unknown key', async () => { + expect(await store.get(TENANT, 'bloomberg', MONTH)).toBe(0); + }); + + it('increments spend correctly', async () => { + await store.increment(TENANT, 'bloomberg', MONTH, 1.50); + await store.increment(TENANT, 'bloomberg', MONTH, 0.50); + expect(await store.get(TENANT, 'bloomberg', MONTH)).toBeCloseTo(2.0); + }); + + it('tracks different providers independently', async () => { + await store.increment(TENANT, 'bloomberg', MONTH, 10); + await store.increment(TENANT, 'ecb', MONTH, 0); + expect(await store.get(TENANT, 'bloomberg', MONTH)).toBe(10); + expect(await store.get(TENANT, 'ecb', MONTH)).toBe(0); + }); + + it('tracks different tenants independently', async () => { + await store.increment('tenant-a', 'bloomberg', MONTH, 5); + await store.increment('tenant-b', 'bloomberg', MONTH, 15); + expect(await store.get('tenant-a', 'bloomberg', MONTH)).toBe(5); + expect(await store.get('tenant-b', 'bloomberg', MONTH)).toBe(15); + }); + + it('tracks different months independently', async () => { + await store.increment(TENANT, 'bloomberg', '2025-01', 10); + await store.increment(TENANT, 'bloomberg', '2025-02', 20); + expect(await store.get(TENANT, 'bloomberg', '2025-01')).toBe(10); + expect(await store.get(TENANT, 'bloomberg', '2025-02')).toBe(20); + }); + + it('rejects negative increment', async () => { + await expect(store.increment(TENANT, 'bloomberg', MONTH, -1)) + .rejects.toThrow('non-negative'); + }); + + it('listByTenant returns all records for tenant and month', async () => { + await store.increment(TENANT, 'bloomberg', MONTH, 5); + await store.increment(TENANT, 'ecb', MONTH, 0); + await store.increment('other-tenant', 'bloomberg', MONTH, 99); + const records = await store.listByTenant(TENANT, MONTH); + expect(records).toHaveLength(1); // ecb was 0 so not inserted + expect(records[0].providerId).toBe('bloomberg'); + }); + + it('clear resets all data', async () => { + await store.increment(TENANT, 'bloomberg', MONTH, 5); + store.clear(); + expect(await store.get(TENANT, 'bloomberg', MONTH)).toBe(0); + }); +}); + +// ─── currentMonthKey ───────────────────────────────────────────────────────── + +describe('currentMonthKey', () => { + it('returns a string matching YYYY-MM format', () => { + expect(currentMonthKey()).toMatch(/^\d{4}-\d{2}$/); + }); +}); + +// ─── FxProviderBudgetRegistry ───────────────────────────────────────────────── + +describe('FxProviderBudgetRegistry', () => { + let store: InMemorySpendStore; + let registry: FxProviderBudgetRegistry; + + beforeEach(() => { + store = new InMemorySpendStore(); + registry = new FxProviderBudgetRegistry(store); + registry.configureProvider(TENANT, { providerId: 'bloomberg', monthlyCapUsd: 100, degradationThreshold: 0.8 }); + }); + + // ── Configuration ────────────────────────────────────────────────────────── + + describe('configureProvider', () => { + it('accepts valid configuration', () => { + expect(() => + registry.configureProvider(TENANT, { providerId: 'ecb', monthlyCapUsd: 10 }) + ).not.toThrow(); + }); + + it('rejects zero monthlyCapUsd', () => { + expect(() => + registry.configureProvider(TENANT, { providerId: 'ecb', monthlyCapUsd: 0 }) + ).toThrow('positive finite number'); + }); + + it('rejects negative monthlyCapUsd', () => { + expect(() => + registry.configureProvider(TENANT, { providerId: 'ecb', monthlyCapUsd: -50 }) + ).toThrow('positive finite number'); + }); + + it('rejects non-finite monthlyCapUsd', () => { + expect(() => + registry.configureProvider(TENANT, { providerId: 'ecb', monthlyCapUsd: Infinity }) + ).toThrow('positive finite number'); + }); + + it('rejects degradationThreshold > 1', () => { + expect(() => + registry.configureProvider(TENANT, { providerId: 'ecb', monthlyCapUsd: 10, degradationThreshold: 1.5 }) + ).toThrow('degradationThreshold'); + }); + + it('rejects degradationThreshold <= 0', () => { + expect(() => + registry.configureProvider(TENANT, { providerId: 'ecb', monthlyCapUsd: 10, degradationThreshold: 0 }) + ).toThrow('degradationThreshold'); + }); + + it('overwrites existing config', () => { + registry.configureProvider(TENANT, { providerId: 'bloomberg', monthlyCapUsd: 200 }); + expect(registry.hasConfig(TENANT, 'bloomberg')).toBe(true); + }); + + it('hasConfig returns false for unconfigured pair', () => { + expect(registry.hasConfig(TENANT, 'unknown-provider')).toBe(false); + }); + }); + + // ── recordSpend ──────────────────────────────────────────────────────────── + + describe('recordSpend', () => { + it('records positive spend', async () => { + await registry.recordSpend(TENANT, 'bloomberg', 1.50, MONTH); + const status = await registry.getBudgetStatus(TENANT, 'bloomberg', MONTH); + expect(status.spendUsd).toBeCloseTo(1.50); + }); + + it('zero spend is a no-op (no store write)', async () => { + await registry.recordSpend(TENANT, 'bloomberg', 0, MONTH); + const records = await store.listByTenant(TENANT, MONTH); + expect(records).toHaveLength(0); + }); + + it('rejects negative spend', async () => { + await expect(registry.recordSpend(TENANT, 'bloomberg', -5, MONTH)) + .rejects.toThrow('non-negative'); + }); + + it('accumulates across multiple calls', async () => { + await registry.recordSpend(TENANT, 'bloomberg', 10, MONTH); + await registry.recordSpend(TENANT, 'bloomberg', 20, MONTH); + const status = await registry.getBudgetStatus(TENANT, 'bloomberg', MONTH); + expect(status.spendUsd).toBeCloseTo(30); + }); + }); + + // ── getBudgetStatus ──────────────────────────────────────────────────────── + + describe('getBudgetStatus', () => { + it('returns zero spend on fresh registry', async () => { + const status = await registry.getBudgetStatus(TENANT, 'bloomberg', MONTH); + expect(status.spendUsd).toBe(0); + expect(status.capUsd).toBe(100); + expect(status.remainingUsd).toBe(100); + expect(status.isExhausted).toBe(false); + expect(status.isNearLimit).toBe(false); + }); + + it('throws for unconfigured provider', async () => { + await expect(registry.getBudgetStatus(TENANT, 'unknown', MONTH)) + .rejects.toThrow('No budget config'); + }); + + it('isNearLimit when spend >= cap × threshold', async () => { + // threshold = 0.8, cap = 100 → near limit at 80 + await registry.recordSpend(TENANT, 'bloomberg', 80, MONTH); + const status = await registry.getBudgetStatus(TENANT, 'bloomberg', MONTH); + expect(status.isNearLimit).toBe(true); + expect(status.isExhausted).toBe(false); + }); + + it('isExhausted when spend >= cap', async () => { + await registry.recordSpend(TENANT, 'bloomberg', 100, MONTH); + const status = await registry.getBudgetStatus(TENANT, 'bloomberg', MONTH); + expect(status.isExhausted).toBe(true); + expect(status.remainingUsd).toBe(0); + }); + + it('remainingUsd goes negative on overspend', async () => { + await registry.recordSpend(TENANT, 'bloomberg', 120, MONTH); + const status = await registry.getBudgetStatus(TENANT, 'bloomberg', MONTH); + expect(status.remainingUsd).toBeLessThan(0); + expect(status.isExhausted).toBe(true); + }); + + it('uses default threshold of 0.9 when not specified', async () => { + const r2 = new FxProviderBudgetRegistry(store); + r2.configureProvider(TENANT, { providerId: 'ecb', monthlyCapUsd: 100 }); + // threshold should be 0.9 → near limit at 90 + await r2.recordSpend(TENANT, 'ecb', 89, MONTH); + expect((await r2.getBudgetStatus(TENANT, 'ecb', MONTH)).isNearLimit).toBe(false); + await r2.recordSpend(TENANT, 'ecb', 2, MONTH); // total 91 + expect((await r2.getBudgetStatus(TENANT, 'ecb', MONTH)).isNearLimit).toBe(true); + }); + }); + + // ── isProviderAvailable ──────────────────────────────────────────────────── + + describe('isProviderAvailable', () => { + it('returns true when under cap', async () => { + expect(await registry.isProviderAvailable(TENANT, 'bloomberg', MONTH)).toBe(true); + }); + + it('returns false when budget exhausted', async () => { + await registry.recordSpend(TENANT, 'bloomberg', 100, MONTH); + expect(await registry.isProviderAvailable(TENANT, 'bloomberg', MONTH)).toBe(false); + }); + + it('returns true for unconfigured provider (fail open)', async () => { + expect(await registry.isProviderAvailable(TENANT, 'unconfigured', MONTH)).toBe(true); + }); + }); + + // ── isProviderNearLimit ──────────────────────────────────────────────────── + + describe('isProviderNearLimit', () => { + it('returns false when under threshold', async () => { + expect(await registry.isProviderNearLimit(TENANT, 'bloomberg', MONTH)).toBe(false); + }); + + it('returns true when near limit', async () => { + await registry.recordSpend(TENANT, 'bloomberg', 80, MONTH); + expect(await registry.isProviderNearLimit(TENANT, 'bloomberg', MONTH)).toBe(true); + }); + + it('returns false for unconfigured provider', async () => { + expect(await registry.isProviderNearLimit(TENANT, 'unconfigured', MONTH)).toBe(false); + }); + }); + + // ── listTenantSpend ──────────────────────────────────────────────────────── + + describe('listTenantSpend', () => { + it('returns empty array when no spend', async () => { + const records = await registry.listTenantSpend(TENANT, MONTH); + expect(records).toHaveLength(0); + }); + + it('returns spend for all configured providers', async () => { + registry.configureProvider(TENANT, { providerId: 'ecb', monthlyCapUsd: 10 }); + await registry.recordSpend(TENANT, 'bloomberg', 5, MONTH); + await registry.recordSpend(TENANT, 'ecb', 1, MONTH); + const records = await registry.listTenantSpend(TENANT, MONTH); + expect(records).toHaveLength(2); + const bloomberg = records.find((r) => r.providerId === 'bloomberg'); + expect(bloomberg?.spendUsd).toBeCloseTo(5); + }); + }); +}); + +// ─── CostAwareRateSelector ──────────────────────────────────────────────────── + +describe('CostAwareRateSelector', () => { + let store: InMemorySpendStore; + let registry: FxProviderBudgetRegistry; + + beforeEach(() => { + store = new InMemorySpendStore(); + registry = new FxProviderBudgetRegistry(store); + // Configure budgets for the three providers + registry.configureProvider(TENANT, { providerId: 'bloomberg', monthlyCapUsd: 10, degradationThreshold: 0.8 }); + registry.configureProvider(TENANT, { providerId: 'refinitiv', monthlyCapUsd: 20, degradationThreshold: 0.8 }); + // ecb is free – no budget config needed + }); + + // ── Construction ────────────────────────────────────────────────────────── + + describe('construction', () => { + it('throws when no providers supplied', () => { + expect(() => new CostAwareRateSelector([], registry)) + .toThrow('at least one provider'); + }); + + it('throws when no free provider exists', () => { + expect(() => + new CostAwareRateSelector( + [{ providerId: 'bloomberg', provider: makeRateProvider('USD/EUR'), costUsdPerCall: 0.05, accuracyRank: 100 }], + registry + ) + ).toThrow('zero-cost'); + }); + + it('throws when provider has negative costUsdPerCall', () => { + expect(() => + new CostAwareRateSelector( + [ + { providerId: 'bad', provider: makeRateProvider('USD/EUR'), costUsdPerCall: -1, accuracyRank: 10 }, + { providerId: 'ecb', provider: makeRateProvider('USD/EUR'), costUsdPerCall: 0, accuracyRank: 1 }, + ], + registry + ) + ).toThrow('negative costUsdPerCall'); + }); + + it('sorts providers by descending accuracyRank', () => { + const { selector } = makeSelector(store, registry); + expect(selector.providers[0].providerId).toBe('bloomberg'); + expect(selector.providers[1].providerId).toBe('refinitiv'); + expect(selector.providers[2].providerId).toBe('ecb'); + }); + }); + + // ── Normal selection (under budget) ─────────────────────────────────────── + + describe('normal selection (budget not constrained)', () => { + it('selects the most accurate (expensive) provider when budget is available', async () => { + const { selector } = makeSelector(store, registry); + const result = await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + expect(result.providerId).toBe('bloomberg'); + expect(result.degraded).toBe(false); + expect(result.exhaustedSkipCount).toBe(0); + expect(result.nearLimitSkipCount).toBe(0); + }); + + it('returns the rate from the selected provider', async () => { + const { selector } = makeSelector(store, registry); + const result = await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + expect(result.rate).not.toBeNull(); + expect(result.rate!.pair).toBe('USD/EUR'); + }); + + it('records spend after successful rate retrieval', async () => { + const { selector } = makeSelector(store, registry); + await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + const status = await registry.getBudgetStatus(TENANT, 'bloomberg', MONTH); + expect(status.spendUsd).toBeCloseTo(0.05); + }); + + it('does not record spend for free providers', async () => { + // Use only the free provider + const freeProvider = makeRateProvider('USD/EUR', '0.90'); + const selector = new CostAwareRateSelector( + [{ providerId: 'ecb', provider: freeProvider, costUsdPerCall: 0, accuracyRank: 1 }], + registry + ); + await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + const records = await store.listByTenant(TENANT, MONTH); + expect(records.find((r) => r.providerId === 'ecb')).toBeUndefined(); + }); + }); + + // ── Budget-pressure degradation ──────────────────────────────────────────── + + describe('budget-pressure degradation', () => { + it('skips expensive provider when its budget is near limit, uses cheaper one', async () => { + // bloomberg cap = $10, threshold = 80% → near limit at $8 + await registry.recordSpend(TENANT, 'bloomberg', 8.50, MONTH); + + const { selector } = makeSelector(store, registry); + const result = await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + // bloomberg is near limit AND a cheaper provider (refinitiv) exists + expect(result.providerId).toBe('refinitiv'); + expect(result.degraded).toBe(true); + expect(result.nearLimitSkipCount).toBe(1); + }); + + it('uses exhausted provider when it is the ONLY non-exhausted option', async () => { + // Exhaust bloomberg; put refinitiv near limit but still available; + // ecb has no budget → treated as always available. + // If bloomberg and refinitiv are both exhausted, ecb (free) must be chosen. + await registry.recordSpend(TENANT, 'bloomberg', 10, MONTH); // exhausted + await registry.recordSpend(TENANT, 'refinitiv', 20, MONTH); // exhausted + + const { selector } = makeSelector(store, registry); + const result = await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + // ecb has no budget config → isProviderAvailable returns true + expect(result.providerId).toBe('ecb'); + expect(result.degraded).toBe(true); + expect(result.exhaustedSkipCount).toBe(2); + }); + + it('budget exhaustion NEVER blocks distributions (always returns a rate)', async () => { + // Exhaust all paid providers; free provider must still serve + await registry.recordSpend(TENANT, 'bloomberg', 100, MONTH); + await registry.recordSpend(TENANT, 'refinitiv', 100, MONTH); + + const { selector } = makeSelector(store, registry); + const result = await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + expect(result.providerId).toBe('ecb'); + expect(result.rate).not.toBeNull(); + }); + + it('uses most-preferred provider after budget resets (new month)', async () => { + // Exhaust bloomberg in MONTH + await registry.recordSpend(TENANT, 'bloomberg', 10, MONTH); + const { selector } = makeSelector(store, registry); + + const resultOldMonth = await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + expect(resultOldMonth.providerId).not.toBe('bloomberg'); + + // New month key: bloomberg spend is zero + const newMonth = '2025-02'; + const resultNewMonth = await selector.selectRate(TENANT, 'USD', 'EUR', newMonth); + expect(resultNewMonth.providerId).toBe('bloomberg'); + }); + + it('skips near-limit provider but uses it when no cheaper alternative exists', async () => { + // Only the expensive provider is near limit AND there's no cheaper non-exhausted one + // Exhaust ecb and refinitiv; bloomberg is near limit + await registry.recordSpend(TENANT, 'refinitiv', 20, MONTH); // exhausted + await registry.recordSpend(TENANT, 'bloomberg', 8.5, MONTH); // near limit + + // ecb has no config → available; but bloomberg near limit + ecb available + // selector should prefer ecb over near-limit bloomberg + const { selector } = makeSelector(store, registry); + const result = await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + // bloomberg is near limit, but there's ecb (cheaper, not exhausted) → degrade + expect(result.degraded).toBe(true); + }); + }); + + // ── Provider returning null rate ─────────────────────────────────────────── + + describe('provider returning null rate', () => { + it('returns null rate from selected provider', async () => { + const emptyProvider = new InMemoryRateProvider(); // no rates set + const freeProvider = makeRateProvider('USD/EUR'); + const selector = new CostAwareRateSelector( + [ + { providerId: 'bloomberg', provider: emptyProvider, costUsdPerCall: 0.05, accuracyRank: 100 }, + { providerId: 'ecb', provider: freeProvider, costUsdPerCall: 0, accuracyRank: 10 }, + ], + registry + ); + const result = await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + // bloomberg has no rate but is selected first (most accurate) + expect(result.providerId).toBe('bloomberg'); + expect(result.rate).toBeNull(); + // spend still recorded (API was called) + const status = await registry.getBudgetStatus(TENANT, 'bloomberg', MONTH); + expect(status.spendUsd).toBeCloseTo(0.05); + }); + + it('records cost even when provider returns null', async () => { + // Provider that throws + const crashingProvider: any = { + getRate: jest.fn().mockRejectedValue(new Error('provider down')), + }; + const freeProvider = makeRateProvider('USD/EUR'); + const selector = new CostAwareRateSelector( + [ + { providerId: 'bloomberg', provider: crashingProvider, costUsdPerCall: 0.05, accuracyRank: 100 }, + { providerId: 'ecb', provider: freeProvider, costUsdPerCall: 0, accuracyRank: 10 }, + ], + registry + ); + const result = await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + // provider crashed → rate is null, cost still charged + expect(result.rate).toBeNull(); + const status = await registry.getBudgetStatus(TENANT, 'bloomberg', MONTH); + expect(status.spendUsd).toBeCloseTo(0.05); + }); + }); + + // ── Metrics ─────────────────────────────────────────────────────────────── + + describe('metrics emission', () => { + let metrics: MetricsCollector; + + beforeEach(() => { + metrics = new MetricsCollector({ enabled: true, enablePIIDetection: false }); + }); + + it('emits fx_provider_selection_total counter', async () => { + const { selector } = makeSelector(store, registry, metrics); + await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + expect(metrics.exportPrometheus()).toContain(METRIC_SELECTION_TOTAL); + }); + + it('emits fx_provider_spend_month gauge after selection', async () => { + const { selector } = makeSelector(store, registry, metrics); + await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + expect(metrics.exportPrometheus()).toContain(METRIC_SPEND_MONTH); + }); + + it('emits fx_provider_degraded_total when degradation occurs', async () => { + await registry.recordSpend(TENANT, 'bloomberg', 8.5, MONTH); // near limit + const { selector } = makeSelector(store, registry, metrics); + await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + expect(metrics.exportPrometheus()).toContain(METRIC_DEGRADED_TOTAL); + }); + + it('does NOT emit degraded counter when no degradation', async () => { + const { selector } = makeSelector(store, registry, metrics); + await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + expect(metrics.exportPrometheus()).not.toContain(METRIC_DEGRADED_TOTAL); + }); + + it('emitSpendGauges emits gauge for all providers with spend', async () => { + await registry.recordSpend(TENANT, 'bloomberg', 3, MONTH); + await registry.recordSpend(TENANT, 'refinitiv', 1, MONTH); + const { selector } = makeSelector(store, registry, metrics); + await selector.emitSpendGauges(TENANT, MONTH); + const prom = metrics.exportPrometheus(); + expect(prom).toContain(METRIC_SPEND_MONTH); + }); + + it('emitSpendGauges is a no-op when no metrics configured', async () => { + const { selector } = makeSelector(store, registry); // no metrics + // Should not throw + await expect(selector.emitSpendGauges(TENANT, MONTH)).resolves.toBeUndefined(); + }); + }); + + // ── Multiple tenants ─────────────────────────────────────────────────────── + + describe('multi-tenant isolation', () => { + it('budget exhaustion for one tenant does not affect another', async () => { + const TENANT_B = 'tenant-b'; + registry.configureProvider(TENANT_B, { providerId: 'bloomberg', monthlyCapUsd: 10 }); + + // Exhaust bloomberg for TENANT only + await registry.recordSpend(TENANT, 'bloomberg', 10, MONTH); + + const { selector } = makeSelector(store, registry); + + const resultA = await selector.selectRate(TENANT, 'USD', 'EUR', MONTH); + const resultB = await selector.selectRate(TENANT_B, 'USD', 'EUR', MONTH); + + // Tenant A should degrade (bloomberg exhausted) + expect(resultA.providerId).not.toBe('bloomberg'); + // Tenant B should still use bloomberg + expect(resultB.providerId).toBe('bloomberg'); + }); + }); + + // ── Security / abuse edge cases ──────────────────────────────────────────── + + describe('security / abuse edge cases', () => { + it('does not allow tenantId injection via provider selection', async () => { + // Provider should use exactly the tenantId passed in, not any embedded injection + const maliciousTenantId = 'tenant-a:bloomberg:9999-99'; + const { selector } = makeSelector(store, registry); + // Should not crash or match another tenant's budget + const result = await selector.selectRate(maliciousTenantId, 'USD', 'EUR', MONTH); + expect(result).toBeDefined(); + // Bloomberg has no budget config for the malicious tenantId → treated as available + expect(result.providerId).toBe('bloomberg'); + }); + + it('spend recording failure (store throws) does not block rate retrieval', async () => { + const brokenStore: SpendStore = { + increment: jest.fn().mockRejectedValue(new Error('DB down')), + get: jest.fn().mockResolvedValue(0), + listByTenant: jest.fn().mockResolvedValue([]), + }; + const brokenRegistry = new FxProviderBudgetRegistry(brokenStore as any); + brokenRegistry.configureProvider(TENANT, { providerId: 'bloomberg', monthlyCapUsd: 100 }); + + const { selector: brokenSelector } = makeSelector(brokenStore as any, brokenRegistry); + // Should still return a rate even if spend recording fails + const result = await brokenSelector.selectRate(TENANT, 'USD', 'EUR', MONTH); + expect(result.rate).not.toBeNull(); + }); + + it('budget status storage failure causes fail-open (provider treated as available)', async () => { + const flakyStore: any = { + increment: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockRejectedValue(new Error('DB down')), + listByTenant: jest.fn().mockResolvedValue([]), + }; + const flakyRegistry = new FxProviderBudgetRegistry(flakyStore); + flakyRegistry.configureProvider(TENANT, { providerId: 'bloomberg', monthlyCapUsd: 10 }); + + // isProviderAvailable should return true on failure (fail open) + const available = await flakyRegistry.isProviderAvailable(TENANT, 'bloomberg', MONTH); + expect(available).toBe(true); + }); + }); +}); + diff --git a/src/services/fxCostAwareSelector.ts b/src/services/fxCostAwareSelector.ts new file mode 100644 index 00000000..fc51f5f8 --- /dev/null +++ b/src/services/fxCostAwareSelector.ts @@ -0,0 +1,350 @@ +/** + * Cost-Aware FX Rate Provider Selector + * + * Wraps a ranked list of `RateProvider` instances – each annotated with its + * per-call cost in USD – and selects the most accurate provider that is still + * within the tenant's monthly budget. When the preferred provider's budget is + * exhausted or near its limit, the selector automatically degrades to a + * cheaper alternative. + * + * Key design invariants + * --------------------- + * 1. **Distributions are never blocked.** The selector MUST always include at + * least one provider with `costUsdPerCall: 0` (e.g. ECB, open-exchange-free). + * `CostAwareRateSelector` enforces this at construction time. + * 2. **Cheapest first under budget pressure.** When a provider is near its + * monthly budget limit, the selector skips it and tries the next cheaper + * provider. Exhausted providers are always skipped. + * 3. **Best quality first under normal conditions.** Providers are ordered by + * descending accuracy/cost so the most accurate (expensive) provider is + * tried first when budget allows. + * 4. **Spend is recorded only on successful rate retrieval.** A provider call + * that returns `null` (no rate available) is still charged its cost because + * the API was invoked. + * 5. **Metrics are emitted** after every selection round so dashboards can + * track `fx.provider.spend_month` and `fx.provider.degraded_total`. + * + * Security Assumptions + * -------------------- + * - `tenantId` is authenticated upstream; the selector inherits that trust. + * - Cost values are defined in code by operators, not derived from external + * inputs, to prevent cost-manipulation attacks. + * - The `FxProviderBudgetRegistry` is the authoritative source for spend data; + * the selector does not maintain its own spend counters. + * - Metric labels never include tenant IDs (PII) – only `providerId` and + * `pair` are used as label dimensions. + * + * @module services/fxCostAwareSelector + */ + +import { RateProvider, ExchangeRate } from './fxConversionEngine'; +import { + FxProviderBudgetRegistry, + MonthKey, + currentMonthKey, +} from './fxProviderBudgetRegistry'; +import { MetricsCollector } from '../lib/metrics'; + +// ─── Metric names ───────────────────────────────────────────────────────────── + +/** Gauge: accumulated spend in USD for a provider in the current month. */ +export const METRIC_SPEND_MONTH = 'fx_provider_spend_month'; + +/** Counter: total number of times a cheaper provider was used due to budget pressure. */ +export const METRIC_DEGRADED_TOTAL = 'fx_provider_degraded_total'; + +/** Counter: total provider selection attempts. */ +export const METRIC_SELECTION_TOTAL = 'fx_provider_selection_total'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** + * A `RateProvider` decorated with cost and quality metadata. + */ +export interface CostedRateProvider { + /** + * Unique, stable identifier for this provider (e.g. `"bloomberg"`, `"ecb"`). + * Must match the `providerId` used in `FxProviderBudgetRegistry.configureProvider`. + */ + providerId: string; + + /** The underlying rate provider implementation. */ + provider: RateProvider; + + /** + * Cost per API call in USD. Must be ≥ 0. + * Set to `0` for free-tier providers. + */ + costUsdPerCall: number; + + /** + * Relative accuracy rank (higher = more accurate / preferred). + * Providers are tried in descending order of this value under normal budget. + * Under budget pressure the selector falls back to lower-ranked providers. + * + * @default 0 + */ + accuracyRank?: number; +} + +/** + * Result of a provider-selection round, enriched with cost accounting metadata. + */ +export interface CostAwareRateResult { + /** The exchange rate returned by the selected provider, or null if none. */ + rate: ExchangeRate | null; + /** The provider that was ultimately used. */ + providerId: string; + /** Whether the selector had to degrade (skip a preferred provider). */ + degraded: boolean; + /** Number of providers that were skipped due to budget exhaustion. */ + exhaustedSkipCount: number; + /** Number of providers that were skipped due to near-limit degradation. */ + nearLimitSkipCount: number; +} + +// ─── Selector ───────────────────────────────────────────────────────────────── + +/** + * CostAwareRateSelector + * + * Selects the best available FX rate provider for a given tenant, respecting + * per-provider monthly budget caps and automatically degrading to cheaper + * providers when limits are approached or exceeded. + * + * Example: + * ```typescript + * const selector = new CostAwareRateSelector( + * [ + * { providerId: 'bloomberg', provider: bloombergProvider, costUsdPerCall: 0.05, accuracyRank: 100 }, + * { providerId: 'refinitiv', provider: refinitivProvider, costUsdPerCall: 0.02, accuracyRank: 80 }, + * { providerId: 'ecb', provider: ecbProvider, costUsdPerCall: 0, accuracyRank: 50 }, + * ], + * registry, + * { metrics } + * ); + * + * const { rate, providerId, degraded } = await selector.selectRate( + * 'tenant-abc', 'USD', 'EUR' + * ); + * ``` + */ +export class CostAwareRateSelector { + /** Providers sorted by descending accuracyRank (most preferred first). */ + private readonly ranked: CostedRateProvider[]; + + constructor( + providers: CostedRateProvider[], + private readonly registry: FxProviderBudgetRegistry, + private readonly options?: { + metrics?: MetricsCollector; + } + ) { + if (providers.length === 0) { + throw new Error('CostAwareRateSelector requires at least one provider'); + } + // Guard: at least one free (zero-cost) provider must exist so distributions + // are never blocked by budget exhaustion. + const hasFreeProvider = providers.some((p) => p.costUsdPerCall === 0); + if (!hasFreeProvider) { + throw new Error( + 'CostAwareRateSelector requires at least one provider with costUsdPerCall === 0 ' + + 'to guarantee distributions are never blocked by budget exhaustion.' + ); + } + // Validate costs + for (const p of providers) { + if (p.costUsdPerCall < 0) { + throw new RangeError( + `Provider "${p.providerId}" has negative costUsdPerCall (${p.costUsdPerCall})` + ); + } + } + // Sort descending by accuracyRank (highest = most preferred) + this.ranked = [...providers].sort( + (a, b) => (b.accuracyRank ?? 0) - (a.accuracyRank ?? 0) + ); + } + + /** + * Select a rate provider for the given tenant and currency pair, respecting + * budget constraints. + * + * Selection algorithm: + * 1. Iterate providers from most-accurate to least-accurate. + * 2. Skip any provider whose budget is **exhausted** for this tenant. + * 3. Skip any provider whose budget is **near the limit** (≥ degradationThreshold) + * *if* a cheaper/lower-ranked provider is still available. + * 4. Call `getRate` on the first eligible provider. + * 5. Record the call cost in the budget registry. + * 6. Emit metrics. + * + * The function never throws due to budget constraints – it always returns the + * result from the cheapest available provider as a last resort. + * + * @param tenantId Authenticated tenant identifier. + * @param from Source currency ISO-4217 code. + * @param to Destination currency ISO-4217 code. + * @param monthKey Optional month override (for testing). + */ + async selectRate( + tenantId: string, + from: string, + to: string, + monthKey?: MonthKey + ): Promise { + const month = monthKey ?? currentMonthKey(); + const metrics = this.options?.metrics; + + let exhaustedSkipCount = 0; + let nearLimitSkipCount = 0; + let firstEligibleIndex = -1; + + // ── Pass 1: determine the first eligible provider ──────────────────────── + // We collect budget statuses up-front so we can decide whether "near limit" + // skipping is safe (i.e. there is still a cheaper provider available). + + const statuses = await Promise.all( + this.ranked.map(async (p) => ({ + costed: p, + exhausted: await this.registry.isProviderAvailable(tenantId, p.providerId, month).then((a) => !a), + nearLimit: await this.registry.isProviderNearLimit(tenantId, p.providerId, month), + })) + ); + + // Find the last non-exhausted provider index (the "last resort") + const lastNonExhaustedIndex = statuses.reduce( + (last, s, i) => (!s.exhausted ? i : last), + -1 + ); + + if (lastNonExhaustedIndex === -1) { + // All providers exhausted – this should be impossible given the free-provider + // invariant, but we defend against it defensively. + // Fall through to the cheapest provider regardless. + firstEligibleIndex = this.ranked.length - 1; + } else { + for (let i = 0; i < statuses.length; i++) { + const { exhausted, nearLimit } = statuses[i]; + + if (exhausted) { + exhaustedSkipCount++; + continue; + } + + // Skip near-limit providers only when a cheaper alternative still exists + // below this index that is not exhausted. + if (nearLimit && lastNonExhaustedIndex > i) { + nearLimitSkipCount++; + continue; + } + + firstEligibleIndex = i; + break; + } + + // If somehow all providers were skipped (shouldn't happen), use last resort. + if (firstEligibleIndex === -1) { + firstEligibleIndex = lastNonExhaustedIndex; + } + } + + const selected = this.ranked[firstEligibleIndex]; + const degraded = exhaustedSkipCount > 0 || nearLimitSkipCount > 0; + + // ── Call the selected provider ──────────────────────────────────────────── + let rate: ExchangeRate | null = null; + try { + rate = await selected.provider.getRate(from, to); + } catch { + // Provider error – treat as null rate; caller will handle missing rate. + rate = null; + } + + // ── Record spend regardless of rate availability ────────────────────────── + // The API was invoked, so the cost is incurred even on a cache miss. + if (selected.costUsdPerCall > 0) { + try { + await this.registry.recordSpend( + tenantId, + selected.providerId, + selected.costUsdPerCall, + month + ); + } catch { + // Defensive: spend recording failure must never block rate retrieval. + } + } + + // ── Emit metrics ────────────────────────────────────────────────────────── + if (metrics) { + // Emit spend gauge for each provider (all tenants share the same metric + // name; the providerId label disambiguates). We emit for the selected + // provider only to keep cardinality low. + try { + const spendUsd = await this.registry + .listTenantSpend(tenantId, month) + .then((records) => { + const rec = records.find((r) => r.providerId === selected.providerId); + return rec?.spendUsd ?? 0; + }); + metrics.setGauge( + METRIC_SPEND_MONTH, + spendUsd, + { provider_id: selected.providerId } + ); + } catch { + // Metric emission failure must never block operations. + } + + metrics.incrementCounter(METRIC_SELECTION_TOTAL, { + provider_id: selected.providerId, + }); + + if (degraded) { + metrics.incrementCounter(METRIC_DEGRADED_TOTAL, { + provider_id: selected.providerId, + }); + } + } + + return { + rate, + providerId: selected.providerId, + degraded, + exhaustedSkipCount, + nearLimitSkipCount, + }; + } + + /** + * Emit the `fx.provider.spend_month` gauge for ALL configured providers + * for a given tenant. Call this on a regular schedule (e.g. every minute) + * to keep the metric fresh even during low-traffic periods. + * + * @param tenantId Tenant to report on. + * @param monthKey Optional month override. + */ + async emitSpendGauges(tenantId: string, monthKey?: MonthKey): Promise { + const metrics = this.options?.metrics; + if (!metrics) return; + + const month = monthKey ?? currentMonthKey(); + const records = await this.registry.listTenantSpend(tenantId, month); + + for (const record of records) { + metrics.setGauge( + METRIC_SPEND_MONTH, + record.spendUsd, + { provider_id: record.providerId } + ); + } + } + + /** + * Expose the ordered provider list for inspection / testing. + */ + get providers(): ReadonlyArray { + return this.ranked; + } +} diff --git a/src/services/fxProviderBudgetRegistry.ts b/src/services/fxProviderBudgetRegistry.ts new file mode 100644 index 00000000..e3ccc6cf --- /dev/null +++ b/src/services/fxProviderBudgetRegistry.ts @@ -0,0 +1,360 @@ +/** + * FX Provider Budget Registry + * + * Tracks per-provider API spend on a per-tenant, per-calendar-month basis and + * enforces a configurable monthly budget cap. When a tenant is approaching (or + * has exceeded) its cap for a given provider, the registry signals that the + * engine should degrade to a cheaper alternative. + * + * Security Assumptions + * -------------------- + * 1. `tenantId` is an opaque identifier that has already been authenticated and + * authorised by the caller; the registry performs no auth itself. + * 2. Spend amounts are always non-negative USD values; the registry rejects + * negative increments to prevent underflow manipulation. + * 3. The in-memory store is single-process only. Production deployments MUST + * wire the `SpendStore` interface to a durable, atomically-updated backend + * (e.g. a Postgres row with `FOR UPDATE` locking) to prevent race-condition + * double-spending across replicas. + * 4. Budget caps must be set explicitly per tenant; there is no implicit global + * default so that misconfiguration is loud rather than silent. + * 5. This registry never blocks a conversion – it only advises which provider + * tier to use. The caller is responsible for ensuring a zero-cost fallback + * always exists (see `CostAwareRateSelector`). + * + * @module services/fxProviderBudgetRegistry + */ + +/** Opaque month key in the form `YYYY-MM`. */ +export type MonthKey = string; + +/** + * Returns the current calendar month key in the form `YYYY-MM`. + * Uses UTC so that rollover is deterministic across time zones. + */ +export function currentMonthKey(): MonthKey { + const now = new Date(); + const y = now.getUTCFullYear(); + const m = String(now.getUTCMonth() + 1).padStart(2, '0'); + return `${y}-${m}`; +} + +/** + * Persistent spend record for a single (tenant, provider, month) triple. + */ +export interface SpendRecord { + tenantId: string; + providerId: string; + monthKey: MonthKey; + /** Accumulated spend in USD (always ≥ 0). */ + spendUsd: number; +} + +/** + * Pluggable storage backend for spend records. + * + * Implementations must be atomically safe: `increment` must use + * optimistic-locking / `SELECT FOR UPDATE` / Redis atomic add, etc. + */ +export interface SpendStore { + /** + * Atomically add `amountUsd` to the running total for the given key. + * Must reject (throw) if `amountUsd` is negative. + */ + increment(tenantId: string, providerId: string, monthKey: MonthKey, amountUsd: number): Promise; + + /** + * Return the accumulated spend for the given key, or 0 if no record exists. + */ + get(tenantId: string, providerId: string, monthKey: MonthKey): Promise; + + /** + * Return all spend records for a tenant in the given month (for reporting). + */ + listByTenant(tenantId: string, monthKey: MonthKey): Promise; +} + +// ─── In-Memory Implementation ───────────────────────────────────────────────── + +/** + * Non-durable, single-process spend store suitable for testing and local dev. + * + * @remarks + * NOT safe for multi-replica deployments. Replace with a Postgres-backed + * implementation in production. + */ +export class InMemorySpendStore implements SpendStore { + /** key: `${tenantId}:${providerId}:${monthKey}` */ + private readonly records = new Map(); + + private key(tenantId: string, providerId: string, monthKey: MonthKey): string { + return `${tenantId}:${providerId}:${monthKey}`; + } + + async increment( + tenantId: string, + providerId: string, + monthKey: MonthKey, + amountUsd: number + ): Promise { + if (amountUsd < 0) { + throw new RangeError( + `spend increment must be non-negative (got ${amountUsd})` + ); + } + const k = this.key(tenantId, providerId, monthKey); + const existing = this.records.get(k); + if (existing) { + existing.spendUsd += amountUsd; + } else { + this.records.set(k, { tenantId, providerId, monthKey, spendUsd: amountUsd }); + } + } + + async get(tenantId: string, providerId: string, monthKey: MonthKey): Promise { + return this.records.get(this.key(tenantId, providerId, monthKey))?.spendUsd ?? 0; + } + + async listByTenant(tenantId: string, monthKey: MonthKey): Promise { + const results: SpendRecord[] = []; + for (const record of this.records.values()) { + if (record.tenantId === tenantId && record.monthKey === monthKey) { + results.push({ ...record }); + } + } + return results; + } + + /** Test / dev helper: reset all data. */ + clear(): void { + this.records.clear(); + } +} + +// ─── Budget Registry ────────────────────────────────────────────────────────── + +/** + * Per-tenant monthly budget cap for a single FX provider. + */ +export interface ProviderBudgetConfig { + /** Unique provider identifier (e.g. `"bloomberg"`, `"ecb"`, `"open_exchange"`). */ + providerId: string; + /** Monthly budget ceiling in USD. Must be > 0. */ + monthlyCapUsd: number; + /** + * Fraction of the cap (0–1) at which the registry signals "near limit" so + * the engine can preemptively degrade to a cheaper provider. + * + * @default 0.9 + */ + degradationThreshold?: number; +} + +/** + * Budget status for a single (tenant, provider, month) triple. + */ +export interface BudgetStatus { + tenantId: string; + providerId: string; + monthKey: MonthKey; + /** Accumulated spend in USD this month. */ + spendUsd: number; + /** Configured cap in USD. */ + capUsd: number; + /** Remaining budget in USD (may be negative if overspent). */ + remainingUsd: number; + /** True when spend ≥ cap. */ + isExhausted: boolean; + /** + * True when spend ≥ (cap × degradationThreshold). + * The engine should prefer cheaper providers when this is true. + */ + isNearLimit: boolean; +} + +/** + * FxProviderBudgetRegistry + * + * Manages monthly budget caps across tenants and FX rate providers. + * The registry is the single source of truth for budget-aware provider + * selection decisions. + * + * Usage: + * ```typescript + * const store = new InMemorySpendStore(); + * const registry = new FxProviderBudgetRegistry(store); + * + * registry.configureProvider('tenant-1', { + * providerId: 'bloomberg', + * monthlyCapUsd: 500, + * degradationThreshold: 0.9, + * }); + * + * await registry.recordSpend('tenant-1', 'bloomberg', 1.20); + * const status = await registry.getBudgetStatus('tenant-1', 'bloomberg'); + * console.log(status.isNearLimit); // false – only $1.20 of $500 used + * ``` + */ +export class FxProviderBudgetRegistry { + /** + * key: `${tenantId}:${providerId}` → budget config + * Kept in memory; configs are typically loaded at startup. + */ + private readonly configs = new Map(); + + constructor(private readonly store: SpendStore) {} + + /** + * Register (or overwrite) the monthly budget cap for a provider under a + * specific tenant. + * + * @param tenantId Authenticated tenant identifier. + * @param config Budget configuration for the provider. + * @throws {RangeError} if `monthlyCapUsd` is not a positive finite number. + */ + configureProvider(tenantId: string, config: ProviderBudgetConfig): void { + if (!Number.isFinite(config.monthlyCapUsd) || config.monthlyCapUsd <= 0) { + throw new RangeError( + `monthlyCapUsd must be a positive finite number (got ${config.monthlyCapUsd})` + ); + } + const threshold = config.degradationThreshold ?? 0.9; + if (threshold <= 0 || threshold > 1) { + throw new RangeError( + `degradationThreshold must be in (0, 1] (got ${threshold})` + ); + } + this.configs.set(`${tenantId}:${config.providerId}`, { + ...config, + tenantId, + degradationThreshold: threshold, + }); + } + + /** + * Record API spend for a provider call. + * + * @param tenantId Authenticated tenant identifier. + * @param providerId Provider that was called. + * @param amountUsd Cost of the call in USD. Must be ≥ 0. + * @param monthKey Optional override for the month key (defaults to current + * UTC month). Useful in tests. + */ + async recordSpend( + tenantId: string, + providerId: string, + amountUsd: number, + monthKey?: MonthKey + ): Promise { + if (amountUsd < 0) { + throw new RangeError( + `spend amount must be non-negative (got ${amountUsd})` + ); + } + if (amountUsd === 0) return; // free calls don't need a record + await this.store.increment(tenantId, providerId, monthKey ?? currentMonthKey(), amountUsd); + } + + /** + * Return the current budget status for a (tenant, provider) pair. + * + * @param tenantId Authenticated tenant identifier. + * @param providerId Provider to query. + * @param monthKey Optional override for the month key. + * @throws {Error} if no budget config has been registered for the pair. + */ + async getBudgetStatus( + tenantId: string, + providerId: string, + monthKey?: MonthKey + ): Promise { + const config = this.configs.get(`${tenantId}:${providerId}`); + if (!config) { + throw new Error( + `No budget config for provider "${providerId}" under tenant "${tenantId}". ` + + `Call configureProvider() first.` + ); + } + const month = monthKey ?? currentMonthKey(); + const spendUsd = await this.store.get(tenantId, providerId, month); + const remainingUsd = config.monthlyCapUsd - spendUsd; + const threshold = config.degradationThreshold ?? 0.9; + + return { + tenantId, + providerId, + monthKey: month, + spendUsd, + capUsd: config.monthlyCapUsd, + remainingUsd, + isExhausted: spendUsd >= config.monthlyCapUsd, + isNearLimit: spendUsd >= config.monthlyCapUsd * threshold, + }; + } + + /** + * Returns true if the given provider is available for use by the tenant. + * + * A provider is considered *unavailable* only when its budget is fully + * exhausted (`isExhausted`). Being "near limit" still allows usage; it + * merely triggers a preference for cheaper alternatives. + * + * @remarks + * If no config is registered for the pair the provider is assumed available + * (open budget), so that misconfiguration is non-blocking. + */ + async isProviderAvailable( + tenantId: string, + providerId: string, + monthKey?: MonthKey + ): Promise { + const config = this.configs.get(`${tenantId}:${providerId}`); + if (!config) return true; // no budget config → treat as free + + try { + const status = await this.getBudgetStatus(tenantId, providerId, monthKey); + return !status.isExhausted; + } catch { + // Defensive: storage failure → fail open (do not block distributions) + return true; + } + } + + /** + * Returns true if the given provider is operating near its budget limit. + * When true the engine should prefer cheaper alternatives if available. + */ + async isProviderNearLimit( + tenantId: string, + providerId: string, + monthKey?: MonthKey + ): Promise { + const config = this.configs.get(`${tenantId}:${providerId}`); + if (!config) return false; + + try { + const status = await this.getBudgetStatus(tenantId, providerId, monthKey); + return status.isNearLimit; + } catch { + return false; + } + } + + /** + * Return all spend records for a tenant in the current (or specified) month. + * Useful for building dashboards and the `fx.provider.spend_month` gauge. + */ + async listTenantSpend( + tenantId: string, + monthKey?: MonthKey + ): Promise { + return this.store.listByTenant(tenantId, monthKey ?? currentMonthKey()); + } + + /** + * Check whether a budget config exists for the given (tenant, provider) pair. + */ + hasConfig(tenantId: string, providerId: string): boolean { + return this.configs.has(`${tenantId}:${providerId}`); + } +} From adbcdc803c8e46a56928a7792bbed68c9d104416 Mon Sep 17 00:00:00 2001 From: edochieblessing09-max Date: Tue, 28 Jul 2026 21:00:30 +0100 Subject: [PATCH 2/2] trigger PR banner