Files
nullcart/backend/src/modules/exchangeRate/services/ExchangeRateService.ts
T

157 lines
6.0 KiB
TypeScript

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<ExchangeRatePaymentMethod, number | null>;
constructor(private readonly configService: ConfigService) {}
async onModuleInit(): Promise<void> {
await this.fetchLiveRates();
}
getLiveFiatPerCrypto(method: ExchangeRatePaymentMethod): number | null {
return this.liveFiatPerCrypto[method];
}
@Cron(CronExpression.EVERY_5_MINUTES)
async fetchLiveRates(): Promise<void> {
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<FiatPerCryptoRates> {
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<unknown>(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<FiatPerCryptoRates> {
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<unknown>(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];
}
}
}