import { Injectable, Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { ExchangeSetting } from "./entities/exchange-setting.entity"; /** * Rate used before the row exists and before the first successful CBE fetch — * the CBE USD transactional selling rate on 2026-08-04. */ const SEED_FALLBACK_RATE = 162.4165; /** Health of the CBE feed, as surfaced to the backoffice. */ export interface ExchangeFeedStatus { /** Rate most recently observed, whatever its source. */ rate: number | null; /** `live` means CBE answered; `stored`/`default` mean it is failing. */ source: "live" | "stored" | null; /** ISO timestamp of the last successful fetch. */ lastSuccessAt: string | null; /** Message from the most recent failure, cleared on success. */ lastError: string | null; } /** * Owns the single `exchange_settings` row: the USD→ETB fallback used when the * CBE endpoint is unreachable. * * 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. */ @Injectable() export class ExchangeSettingsService { private readonly logger = new Logger(ExchangeSettingsService.name); /** * Feed health, recorded from the exchange provider's callbacks rather than * read off an injected `ExchangeService`. The provider is registered several * times (bookings, contracts, warehouses), so no single instance sees every * fetch — and injecting one here would be circular, since those * registrations inject *this* service. */ private feed: ExchangeFeedStatus = { rate: null, source: null, lastSuccessAt: null, lastError: null, }; constructor( @InjectRepository(ExchangeSetting) private readonly repository: Repository, ) {} /** Health of the CBE feed as last observed by any provider instance. */ getFeedStatus(): ExchangeFeedStatus { return { ...this.feed }; } /** The settings row, created at the seed rate on first access. */ async get(): Promise { const existing = await this.repository.findOne({ where: {} }); if (existing) return existing; return this.repository.save( this.repository.create({ fallbackRate: SEED_FALLBACK_RATE, fallbackSource: "AUTO", lastSyncedAt: null, }), ); } /** * 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(); } }