157 lines
6.0 KiB
TypeScript
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];
|
|
}
|
|
}
|
|
}
|