import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Cron, CronExpression } from '@nestjs/schedule'; import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import axios from 'axios'; import type { Config } from '../../../types/Config'; import { getErrorMessage } from '../../../utils/getErrorMessage'; import { COINGECKO_ID_BY_PAYMENT_METHOD } from '../const/coingeckoIds'; import { COINPAPRIKA_ID_BY_PAYMENT_METHOD } from '../const/coinPaprikaIds'; import { EXCHANGE_RATE_PAYMENT_METHODS, type ExchangeRatePaymentMethod } from '../const/exchangeRatePaymentMethods'; import { CoingeckoSimplePriceResponseDto } from '../dto/CoingeckoSimplePriceResponseDto'; import { CoinPaprikaTickerResponseDto } from '../dto/CoinPaprikaTickerResponseDto'; import type { FiatPerCryptoRates } from '../types/FiatPerCryptoRates'; import { extractCoingeckoSimplePrice } from '../utils/extractCoingeckoSimplePrice'; import { extractCoinPaprikaTickerPrice } from '../utils/extractCoinPaprikaTickerPrice'; @Injectable() export class ExchangeRateService implements OnModuleInit { private readonly logger = new Logger(ExchangeRateService.name); private liveFiatPerCrypto = Object.fromEntries( EXCHANGE_RATE_PAYMENT_METHODS.map(method => [method, null]) ) as Record; constructor(private readonly configService: ConfigService) {} async onModuleInit(): Promise { await this.fetchLiveRates(); } getLiveFiatPerCrypto(method: ExchangeRatePaymentMethod): number | null { return this.liveFiatPerCrypto[method]; } @Cron(CronExpression.EVERY_5_MINUTES) async fetchLiveRates(): Promise { const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; try { const rates = await this.fetchRatesFromCoingecko(); this.applyRates(rates); } catch { this.logger.warn(`CoinGecko rate fetch failed for ${shopFiatCurrency}, trying CoinPaprika`); try { const rates = await this.fetchRatesFromCoinPaprika(); this.applyRates(rates); this.logger.log(`Successfully fetched live rates from CoinPaprika for ${shopFiatCurrency}`); } catch (error) { this.logger.error( `Failed to fetch live rates from CoinGecko and CoinPaprika for ${shopFiatCurrency}: ${getErrorMessage(error)}` ); } } } private async fetchRatesFromCoingecko(): Promise { const { rateFetchTimeoutMs } = this.configService.get('coingecko') as Config['coingecko']; const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; const url = this.buildCoingeckoRateUrl(); const { data } = await axios.get(url, { timeout: rateFetchTimeoutMs }); const coinIds = EXCHANGE_RATE_PAYMENT_METHODS.map(method => COINGECKO_ID_BY_PAYMENT_METHOD[method]); const dto = plainToInstance(CoingeckoSimplePriceResponseDto, { response: data, shopFiatCurrency, coinIds }); const errors = validateSync(dto); if (errors.length > 0) { throw new Error('CoinGecko response validation failed'); } const rates = {} as FiatPerCryptoRates; for (const method of EXCHANGE_RATE_PAYMENT_METHODS) { const coinId = COINGECKO_ID_BY_PAYMENT_METHOD[method]; const price = extractCoingeckoSimplePrice(dto.response, coinId, shopFiatCurrency); if (price === null) { throw new Error(`CoinGecko response validation failed for coin ${coinId}`); } rates[method] = price; } return rates; } private async fetchRatesFromCoinPaprika(): Promise { const { apiBaseUrl, rateFetchTimeoutMs } = this.configService.get('coinPaprika') as Config['coinPaprika']; const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; const rates = {} as FiatPerCryptoRates; await Promise.all( EXCHANGE_RATE_PAYMENT_METHODS.map(async method => { const coinId = COINPAPRIKA_ID_BY_PAYMENT_METHOD[method]; const url = `${apiBaseUrl}/tickers/${coinId}?quotes=${shopFiatCurrency}`; const { data } = await axios.get(url, { timeout: rateFetchTimeoutMs }); const dto = plainToInstance(CoinPaprikaTickerResponseDto, { response: data, shopFiatCurrency }); const errors = validateSync(dto); if (errors.length > 0) { throw new Error(`CoinPaprika response validation failed for coin ${coinId}`); } const price = extractCoinPaprikaTickerPrice(dto.response, shopFiatCurrency); if (price === null) { throw new Error(`CoinPaprika response validation failed for coin ${coinId}`); } rates[method] = price; }) ); return rates; } private buildCoingeckoRateUrl(): string { const { apiBaseUrl } = this.configService.get('coingecko') as Config['coingecko']; const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; const coinIds = EXCHANGE_RATE_PAYMENT_METHODS.map(method => COINGECKO_ID_BY_PAYMENT_METHOD[method]).join(','); const vs = shopFiatCurrency.toLowerCase(); return `${apiBaseUrl}/simple/price?ids=${coinIds}&vs_currencies=${vs}`; } private applyRates(rates: FiatPerCryptoRates): void { for (const method of EXCHANGE_RATE_PAYMENT_METHODS) { this.liveFiatPerCrypto[method] = rates[method]; } } }