import { Injectable, Logger, NotFoundException, BadRequestException, } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { firstValueFrom } from 'rxjs'; import { PrismaService } from '../../common/prisma.service'; import { Currency } from '@prisma/client'; /** * Minor-unit decimal places per currency, used to round the CHARGE amount sent to the payment * microservice. DJF has no minor unit (whole francs only); ETB and USD use 2 decimals. */ const CHARGE_CURRENCY_DECIMALS: Record = { ETB: 2, USD: 2, DJF: 0, }; @Injectable() export class CurrencyService { private readonly logger = new Logger(CurrencyService.name); constructor( private readonly prisma: PrismaService, private readonly httpService: HttpService, private readonly configService: ConfigService, ) {} async convertEtbMinorToChargeMajor( amountMinorEtb: number, targetCurrency: string, ): Promise { const target = targetCurrency.toUpperCase(); const decimals = CHARGE_CURRENCY_DECIMALS[target]; if (decimals === undefined) { throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`); } const sourceMajor = amountMinorEtb / 100; if (target === Currency.ETB) { return this.roundTo(sourceMajor, decimals); } const rate = await this.getRateOrThrow(Currency.ETB, target as Currency); return this.roundTo(sourceMajor * rate, decimals); } async getRateOrThrow( fromCurrency: Currency, toCurrency: Currency, ): Promise { if (fromCurrency === toCurrency) return 1; const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ where: { fromCurrency, toCurrency }, orderBy: { effectiveDate: 'desc' }, }); if (!exchangeRate) { throw new BadRequestException( `No exchange rate configured for ${fromCurrency}->${toCurrency}`, ); } return Number(exchangeRate.rate); } private roundTo(value: number, decimals: number): number { const factor = 10 ** decimals; return Math.round(value * factor) / factor; } async convertAmount( amountMinor: number, fromCurrency: Currency, toCurrency: Currency, ): Promise { if (fromCurrency === toCurrency) { return amountMinor; } const rate = await this.getExchangeRate(fromCurrency, toCurrency); return Math.round(amountMinor * rate); } async getExchangeRate( fromCurrency: Currency, toCurrency: Currency, ): Promise { if (fromCurrency === toCurrency) return 1; const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ where: { fromCurrency, toCurrency }, orderBy: { effectiveDate: 'desc' }, }); if (!exchangeRate) { this.logger.warn( `No exchange rate found for ${fromCurrency} to ${toCurrency}, using 1.0`, ); return 1.0; } const ageMs = Date.now() - exchangeRate.effectiveDate.getTime(); if (ageMs > 2 * 24 * 60 * 60 * 1000) { this.logger.warn( `Stale exchange rate for ${fromCurrency}->${toCurrency}: last updated ${exchangeRate.effectiveDate.toISOString()}`, ); } return Number(exchangeRate.rate); } async syncExchangeRates(): Promise { this.logger.log('Syncing exchange rates from central bank API'); const today = this.todayUtc(); // Fallback rates used when the API is unreachable const fallbackRates = [ { from: Currency.ETB, to: Currency.ETB, rate: 1.0 }, { from: Currency.ETB, to: Currency.DJF, rate: 3.25 }, { from: Currency.ETB, to: Currency.USD, rate: 0.018 }, { from: Currency.DJF, to: Currency.ETB, rate: 0.3077 }, { from: Currency.USD, to: Currency.ETB, rate: 55.56 }, ]; const apiUrl = this.configService.get('EXCHANGE_RATE_API_URL'); if (apiUrl) { try { const response = await firstValueFrom( this.httpService.get>(apiUrl, { timeout: 5000 }), ); // Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... } const data = response.data; const apiRates = [ { from: Currency.ETB, to: Currency.ETB, rate: 1.0 }, { from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate }, { from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate }, { from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate }, { from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate }, ]; for (const { from, to, rate } of apiRates) { await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API'); } this.logger.log('Exchange rates synced from central bank API'); return; } catch (err) { this.logger.warn( `Central bank API unreachable (${(err as Error).message}), falling back to configured rates`, ); } } // Fallback: persist the static rates so the DB always has a current row for (const { from, to, rate } of fallbackRates) { await this.upsertRate(from, to, rate, today, 'FALLBACK'); } this.logger.log('Exchange rates synced using fallback values'); } async listRates() { return this.prisma.currencyExchangeRate.findMany({ orderBy: [{ fromCurrency: 'asc' }, { toCurrency: 'asc' }, { effectiveDate: 'desc' }], }); } async upsertRate( fromCurrency: Currency, toCurrency: Currency, rate: number, effectiveDate?: Date, source = 'MANUAL', ) { const date = effectiveDate ?? this.todayUtc(); return this.prisma.currencyExchangeRate.upsert({ where: { fromCurrency_toCurrency_effectiveDate: { fromCurrency, toCurrency, effectiveDate: date } }, update: { rate, source }, create: { fromCurrency, toCurrency, rate, effectiveDate: date, source }, }); } async updateRateById(id: string, rate: number, source = 'MANUAL') { const existing = await this.prisma.currencyExchangeRate.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('Exchange rate not found'); return this.prisma.currencyExchangeRate.update({ where: { id }, data: { rate, source } }); } async deleteRate(id: string) { return this.prisma.currencyExchangeRate.delete({ where: { id } }); } /** Returns midnight UTC for today — used as the date-only key for upserts. */ private todayUtc(): Date { const d = new Date(); d.setUTCHours(0, 0, 0, 0); return d; } }