refactor xmr rate module to exchange rate module for multi crypto support
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExchangeRateService } from './services/ExchangeRateService';
|
||||
|
||||
@Module({
|
||||
providers: [ExchangeRateService],
|
||||
exports: [ExchangeRateService]
|
||||
})
|
||||
export class ExchangeRateModule {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ExchangeRatePaymentMethod } from './exchangeRatePaymentMethods';
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
|
||||
export const COINPAPRIKA_ID_BY_PAYMENT_METHOD = {
|
||||
[PaymentMethod.Xmr]: 'xmr-monero',
|
||||
[PaymentMethod.Btc]: 'btc-bitcoin'
|
||||
} as const satisfies Record<ExchangeRatePaymentMethod, string>;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ExchangeRatePaymentMethod } from './exchangeRatePaymentMethods';
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
|
||||
export const COINGECKO_ID_BY_PAYMENT_METHOD = {
|
||||
[PaymentMethod.Xmr]: 'monero',
|
||||
[PaymentMethod.Btc]: 'bitcoin'
|
||||
} as const satisfies Record<ExchangeRatePaymentMethod, string>;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
|
||||
export const EXCHANGE_RATE_PAYMENT_METHODS = [PaymentMethod.Xmr, PaymentMethod.Btc] as const;
|
||||
|
||||
export type ExchangeRatePaymentMethod = (typeof EXCHANGE_RATE_PAYMENT_METHODS)[number];
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
type ValidationArguments,
|
||||
type ValidatorConstraintInterface
|
||||
} from 'class-validator';
|
||||
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
|
||||
|
||||
@ValidatorConstraint({ name: 'coinPaprikaResponse' })
|
||||
class CoinPaprikaResponseConstraint implements ValidatorConstraintInterface {
|
||||
validate(response: unknown, args: ValidationArguments): boolean {
|
||||
const { shopFiatCurrency } = args.object as CoinPaprikaTickerResponseDto;
|
||||
|
||||
if (!shopFiatCurrency || typeof response !== 'object' || response === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const quotes = (response as { quotes?: unknown }).quotes;
|
||||
|
||||
if (typeof quotes !== 'object' || quotes === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const quote = (quotes as Record<string, { price?: number }>)[shopFiatCurrency];
|
||||
|
||||
return typeof quote?.price === 'number' && Number.isFinite(quote.price) && quote.price > 0;
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return 'CoinPaprika fiat quote price must be a positive number';
|
||||
}
|
||||
}
|
||||
|
||||
export class CoinPaprikaTickerResponseDto {
|
||||
@IsEnum(ShopFiatCurrency)
|
||||
shopFiatCurrency: ShopFiatCurrency;
|
||||
|
||||
@IsNotEmpty()
|
||||
@Validate(CoinPaprikaResponseConstraint)
|
||||
response: { quotes: Record<string, { price: number }> };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsString,
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
type ValidationArguments,
|
||||
type ValidatorConstraintInterface
|
||||
} from 'class-validator';
|
||||
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
|
||||
|
||||
@ValidatorConstraint({ name: 'coingeckoResponse' })
|
||||
class CoingeckoResponseConstraint implements ValidatorConstraintInterface {
|
||||
validate(response: Record<string, unknown>, args: ValidationArguments): boolean {
|
||||
const { shopFiatCurrency, coinIds } = args.object as CoingeckoSimplePriceResponseDto;
|
||||
|
||||
if (!shopFiatCurrency || coinIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fiatKey = shopFiatCurrency.toLowerCase();
|
||||
|
||||
return coinIds.every(coinId => {
|
||||
const coin = response[coinId];
|
||||
|
||||
if (typeof coin !== 'object' || coin === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rate = coin[fiatKey];
|
||||
|
||||
return typeof rate === 'number' && Number.isFinite(rate) && rate > 0;
|
||||
});
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return 'CoinGecko coin fiat rate must be a positive number';
|
||||
}
|
||||
}
|
||||
|
||||
export class CoingeckoSimplePriceResponseDto {
|
||||
@IsEnum(ShopFiatCurrency)
|
||||
shopFiatCurrency: ShopFiatCurrency;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
coinIds: string[];
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsObject()
|
||||
@Validate(CoingeckoResponseConstraint)
|
||||
response: Record<string, Record<string, number>>;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
import { ExchangeRateService } from './ExchangeRateService';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
const buildCoinPaprikaTicker = (price: number, fiat = 'USD') => ({
|
||||
quotes: { [fiat]: { price } }
|
||||
});
|
||||
|
||||
const buildCoinPaprikaAxiosMock =
|
||||
(xmrPrice: number, btcPrice: number, fiat = 'USD') =>
|
||||
(url: string) => {
|
||||
if (url === `https://coinpaprika.test/v1/tickers/xmr-monero?quotes=${fiat}`) {
|
||||
return Promise.resolve({ data: buildCoinPaprikaTicker(xmrPrice, fiat) });
|
||||
}
|
||||
|
||||
if (url === `https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=${fiat}`) {
|
||||
return Promise.resolve({ data: buildCoinPaprikaTicker(btcPrice, fiat) });
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`unexpected url: ${url}`));
|
||||
};
|
||||
|
||||
describe('ExchangeRateService', () => {
|
||||
let service: ExchangeRateService;
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
let warnLogSpy: jest.SpiedFunction<typeof Logger.prototype.warn>;
|
||||
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
||||
let logSpy: jest.SpiedFunction<typeof Logger.prototype.log>;
|
||||
|
||||
beforeEach(() => {
|
||||
warnLogSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
|
||||
configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'shopSettings') {
|
||||
return { shopFiatCurrency: 'USD' };
|
||||
}
|
||||
|
||||
if (key === 'coingecko') {
|
||||
return { apiBaseUrl: 'https://coingecko.test', rateFetchTimeoutMs: 5000 };
|
||||
}
|
||||
|
||||
if (key === 'coinPaprika') {
|
||||
return { apiBaseUrl: 'https://coinpaprika.test/v1', rateFetchTimeoutMs: 5000 };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})
|
||||
};
|
||||
|
||||
service = new ExchangeRateService(configService as unknown as ConfigService);
|
||||
mockedAxios.get.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnLogSpy.mockRestore();
|
||||
errorLogSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('stores batched CoinGecko rates when the fetch succeeds', async () => {
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
monero: { usd: 152.3456 },
|
||||
bitcoin: { usd: 95_432.1987 }
|
||||
}
|
||||
});
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://coingecko.test/simple/price?ids=monero,bitcoin&vs_currencies=usd',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(152.35);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(95_432.2);
|
||||
});
|
||||
|
||||
it('falls back to CoinPaprika when CoinGecko fails', async () => {
|
||||
mockedAxios.get
|
||||
.mockRejectedValueOnce(new Error('coingecko down'))
|
||||
.mockImplementation(buildCoinPaprikaAxiosMock(149.876, 94_999.5));
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(warnLogSpy).toHaveBeenCalled();
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(149.88);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(94_999.5);
|
||||
});
|
||||
|
||||
it('leaves cached rates null when both providers fail on the first fetch', async () => {
|
||||
mockedAxios.get
|
||||
.mockRejectedValueOnce(new Error('coingecko down'))
|
||||
.mockRejectedValue(new Error('coinpaprika down'));
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalled();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBeNull();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to CoinPaprika when CoinGecko returns an invalid payload', async () => {
|
||||
mockedAxios.get
|
||||
.mockResolvedValueOnce({ data: { monero: { usd: -1 }, bitcoin: { usd: 95_000 } } })
|
||||
.mockImplementation(buildCoinPaprikaAxiosMock(151.11, 95_111.11));
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(warnLogSpy).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith('Successfully fetched live rates from CoinPaprika for USD');
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(3);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(151.11);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(95_111.11);
|
||||
});
|
||||
|
||||
it('falls back to CoinPaprika when CoinGecko omits a coin', async () => {
|
||||
mockedAxios.get
|
||||
.mockResolvedValueOnce({ data: { monero: { usd: 150 } } })
|
||||
.mockImplementation(buildCoinPaprikaAxiosMock(149.5, 94_000));
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(warnLogSpy).toHaveBeenCalled();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(149.5);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(94_000);
|
||||
});
|
||||
|
||||
it('leaves cached rates null when CoinPaprika returns an invalid payload for one coin', async () => {
|
||||
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockImplementation((url: string) => {
|
||||
if (url === 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD') {
|
||||
return Promise.resolve({ data: buildCoinPaprikaTicker(151.11) });
|
||||
}
|
||||
|
||||
if (url === 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD') {
|
||||
return Promise.resolve({ data: { quotes: { USD: { price: -1 } } } });
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`unexpected url: ${url}`));
|
||||
});
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalled();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBeNull();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the previous live rates when a refresh fails after a successful fetch', async () => {
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
monero: { usd: 140 },
|
||||
bitcoin: { usd: 90_000 }
|
||||
}
|
||||
});
|
||||
await service.fetchLiveRates();
|
||||
|
||||
mockedAxios.get
|
||||
.mockRejectedValueOnce(new Error('coingecko down'))
|
||||
.mockRejectedValue(new Error('coinpaprika down'));
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(140);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(90_000);
|
||||
});
|
||||
|
||||
it('keeps the previous live rates when CoinPaprika returns an invalid payload for one coin on refresh', async () => {
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
monero: { usd: 140 },
|
||||
bitcoin: { usd: 90_000 }
|
||||
}
|
||||
});
|
||||
await service.fetchLiveRates();
|
||||
|
||||
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockImplementation((url: string) => {
|
||||
if (url === 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD') {
|
||||
return Promise.resolve({ data: buildCoinPaprikaTicker(151.11) });
|
||||
}
|
||||
|
||||
if (url === 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD') {
|
||||
return Promise.resolve({ data: { quotes: { USD: { price: -1 } } } });
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`unexpected url: ${url}`));
|
||||
});
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalled();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(140);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(90_000);
|
||||
});
|
||||
|
||||
it('uses the shop fiat currency when building provider URLs', async () => {
|
||||
configService.get.mockImplementation((key: string) => {
|
||||
if (key === 'shopSettings') {
|
||||
return { shopFiatCurrency: 'EUR' };
|
||||
}
|
||||
|
||||
if (key === 'coingecko') {
|
||||
return { apiBaseUrl: 'https://coingecko.test', rateFetchTimeoutMs: 5000 };
|
||||
}
|
||||
|
||||
if (key === 'coinPaprika') {
|
||||
return { apiBaseUrl: 'https://coinpaprika.test/v1', rateFetchTimeoutMs: 5000 };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
monero: { eur: 130.5 },
|
||||
bitcoin: { eur: 88_000.4 }
|
||||
}
|
||||
});
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://coingecko.test/simple/price?ids=monero,bitcoin&vs_currencies=eur',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(130.5);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(88_000.4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ExchangeRatePaymentMethod } from '../const/exchangeRatePaymentMethods';
|
||||
|
||||
export type FiatPerCryptoRates = Record<ExchangeRatePaymentMethod, number>;
|
||||
@@ -0,0 +1,25 @@
|
||||
import Decimal from 'decimal.js';
|
||||
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
|
||||
|
||||
export const extractCoinPaprikaTickerPrice = (
|
||||
response: { quotes: Record<string, { price: number }> },
|
||||
shopFiatCurrency: ShopFiatCurrency
|
||||
): number | null => {
|
||||
const quote = response.quotes[shopFiatCurrency];
|
||||
|
||||
if (!quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rate = quote.price;
|
||||
|
||||
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Decimal(rate).toDecimalPlaces(2).toNumber();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import Decimal from 'decimal.js';
|
||||
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
|
||||
|
||||
export const extractCoingeckoSimplePrice = (
|
||||
coins: Record<string, Record<string, number>>,
|
||||
coinId: string,
|
||||
shopFiatCurrency: ShopFiatCurrency
|
||||
): number | null => {
|
||||
const coin = coins[coinId];
|
||||
|
||||
if (!coin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fiatKey = shopFiatCurrency.toLowerCase();
|
||||
|
||||
const rate = coin[fiatKey];
|
||||
|
||||
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Decimal(rate).toDecimalPlaces(2).toNumber();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user