refactor xmr rate module to exchange rate module for multi crypto support
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
|||||||
getCoingeckoConfig,
|
getCoingeckoConfig,
|
||||||
getEncryptionConfig,
|
getEncryptionConfig,
|
||||||
getJwtConfig,
|
getJwtConfig,
|
||||||
getKrakenConfig,
|
getCoinPaprikaConfig,
|
||||||
getOrderConfig,
|
getOrderConfig,
|
||||||
getInvoiceConfig,
|
getInvoiceConfig,
|
||||||
getMoneroWalletConfig,
|
getMoneroWalletConfig,
|
||||||
@@ -35,7 +35,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron
|
|||||||
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
|
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
|
||||||
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
|
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
|
||||||
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
|
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
|
||||||
import { XmrRateModule } from './modules/xmrRate/XmrRateModule';
|
import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
|
||||||
import { Config } from './types/Config';
|
import { Config } from './types/Config';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -49,7 +49,7 @@ import { Config } from './types/Config';
|
|||||||
registerAs('app', getAppConfig),
|
registerAs('app', getAppConfig),
|
||||||
registerAs('jwt', getJwtConfig),
|
registerAs('jwt', getJwtConfig),
|
||||||
registerAs('coingecko', getCoingeckoConfig),
|
registerAs('coingecko', getCoingeckoConfig),
|
||||||
registerAs('kraken', getKrakenConfig),
|
registerAs('coinPaprika', getCoinPaprikaConfig),
|
||||||
registerAs('encryption', getEncryptionConfig),
|
registerAs('encryption', getEncryptionConfig),
|
||||||
registerAs('shopSettings', getShopSettingsConfig),
|
registerAs('shopSettings', getShopSettingsConfig),
|
||||||
registerAs('order', getOrderConfig),
|
registerAs('order', getOrderConfig),
|
||||||
@@ -74,7 +74,7 @@ import { Config } from './types/Config';
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
XmrRateModule,
|
ExchangeRateModule,
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'],
|
useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'],
|
||||||
inject: [ConfigService]
|
inject: [ConfigService]
|
||||||
|
|||||||
@@ -191,7 +191,8 @@ export const getOrderConfig = (): OrderConfig => ({
|
|||||||
|
|
||||||
export const getInvoiceConfig = (): InvoiceConfig => ({
|
export const getInvoiceConfig = (): InvoiceConfig => ({
|
||||||
minByMethod: {
|
minByMethod: {
|
||||||
[PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC'))
|
[PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC')),
|
||||||
|
[PaymentMethod.Btc]: String(envInt('BTC_MIN_INCOMING_ATOMIC'))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
|
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
|
||||||
import { XmrRateModule } from '../xmrRate/XmrRateModule';
|
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
|
||||||
import { Invoice } from './entities/Invoice';
|
import { Invoice } from './entities/Invoice';
|
||||||
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
|
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
|
||||||
import { InvoicePayment } from './entities/InvoicePayment';
|
import { InvoicePayment } from './entities/InvoicePayment';
|
||||||
@@ -12,7 +12,7 @@ import { InvoiceService } from './services/InvoiceService';
|
|||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]),
|
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]),
|
||||||
MoneroWalletModule,
|
MoneroWalletModule,
|
||||||
XmrRateModule
|
ExchangeRateModule
|
||||||
],
|
],
|
||||||
providers: [InvoicePaymentService, InvoiceService],
|
providers: [InvoicePaymentService, InvoiceService],
|
||||||
exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])]
|
exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Logger, ServiceUnavailableException } from '@nestjs/common';
|
|||||||
import type { ConfigService } from '@nestjs/config';
|
import type { ConfigService } from '@nestjs/config';
|
||||||
import type { Repository } from 'typeorm';
|
import type { Repository } from 'typeorm';
|
||||||
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
||||||
import type { XmrRateService } from '../../xmrRate/services/XmrRateService';
|
import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
||||||
import { Invoice } from '../entities/Invoice';
|
import { Invoice } from '../entities/Invoice';
|
||||||
import { InvoiceReason } from '../types/InvoiceReason';
|
import { InvoiceReason } from '../types/InvoiceReason';
|
||||||
import { PaymentMethod } from '../types/PaymentMethod';
|
import { PaymentMethod } from '../types/PaymentMethod';
|
||||||
@@ -22,8 +22,8 @@ describe('InvoiceService', () => {
|
|||||||
let walletRpcClient: {
|
let walletRpcClient: {
|
||||||
createAddress: jest.Mock;
|
createAddress: jest.Mock;
|
||||||
};
|
};
|
||||||
let xmrRateService: {
|
let exchangeRateService: {
|
||||||
getLiveFiatPerXmr: jest.Mock;
|
getLiveFiatPerCrypto: jest.Mock;
|
||||||
};
|
};
|
||||||
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
||||||
|
|
||||||
@@ -60,15 +60,15 @@ describe('InvoiceService', () => {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
xmrRateService = {
|
exchangeRateService = {
|
||||||
getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
|
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
|
||||||
};
|
};
|
||||||
|
|
||||||
service = new InvoiceService(
|
service = new InvoiceService(
|
||||||
invoiceRepo as unknown as Repository<Invoice>,
|
invoiceRepo as unknown as Repository<Invoice>,
|
||||||
configService as unknown as ConfigService,
|
configService as unknown as ConfigService,
|
||||||
walletRpcClient as unknown as MoneroWalletRpcClient,
|
walletRpcClient as unknown as MoneroWalletRpcClient,
|
||||||
xmrRateService as unknown as XmrRateService
|
exchangeRateService as unknown as ExchangeRateService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ describe('InvoiceService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws when the live XMR rate is unavailable for checkout invoices', async () => {
|
it('throws when the live XMR rate is unavailable for checkout invoices', async () => {
|
||||||
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
|
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
|
||||||
|
|
||||||
await expect(issueCheckoutInvoice()).rejects.toThrow(
|
await expect(issueCheckoutInvoice()).rejects.toThrow(
|
||||||
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
|
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
|
||||||
@@ -169,7 +169,7 @@ describe('InvoiceService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses shipping-specific messages and address labels for shipping invoices', async () => {
|
it('uses shipping-specific messages and address labels for shipping invoices', async () => {
|
||||||
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
|
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.issueInvoice({
|
service.issueInvoice({
|
||||||
@@ -184,7 +184,7 @@ describe('InvoiceService', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
xmrRateService.getLiveFiatPerXmr.mockReturnValue(150);
|
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
|
||||||
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
|
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
|
|||||||
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic';
|
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic';
|
||||||
import { resolveMinConfirmations } from '../../../utils/monero/resolveMinConfirmations';
|
import { resolveMinConfirmations } from '../../../utils/monero/resolveMinConfirmations';
|
||||||
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
||||||
import { XmrRateService } from '../../xmrRate/services/XmrRateService';
|
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
||||||
import { Invoice } from '../entities/Invoice';
|
import { Invoice } from '../entities/Invoice';
|
||||||
import { InvoiceReason } from '../types/InvoiceReason';
|
import { InvoiceReason } from '../types/InvoiceReason';
|
||||||
import type { IssueInvoiceData } from '../types/IssueInvoiceData';
|
import type { IssueInvoiceData } from '../types/IssueInvoiceData';
|
||||||
@@ -25,10 +25,10 @@ export class InvoiceService {
|
|||||||
private readonly invoiceRepo: Repository<Invoice>,
|
private readonly invoiceRepo: Repository<Invoice>,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly walletRpcClient: MoneroWalletRpcClient,
|
private readonly walletRpcClient: MoneroWalletRpcClient,
|
||||||
private readonly xmrRateService: XmrRateService
|
private readonly exchangeRateService: ExchangeRateService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async issueInvoice(data: IssueInvoiceData): Promise<Invoice> {
|
async issueInvoice(data: IssueInvoiceData): Promise<Invoice | undefined> {
|
||||||
switch (data.paymentMethod) {
|
switch (data.paymentMethod) {
|
||||||
case PaymentMethod.Xmr:
|
case PaymentMethod.Xmr:
|
||||||
return this.issueXmrInvoice(data);
|
return this.issueXmrInvoice(data);
|
||||||
@@ -44,7 +44,7 @@ export class InvoiceService {
|
|||||||
contextId
|
contextId
|
||||||
);
|
);
|
||||||
|
|
||||||
const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
|
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
|
||||||
|
|
||||||
if (fiatPerXmr === null) {
|
if (fiatPerXmr === null) {
|
||||||
throw new ServiceUnavailableException(rateUnavailableMessage);
|
throw new ServiceUnavailableException(rateUnavailableMessage);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export enum PaymentMethod {
|
export enum PaymentMethod {
|
||||||
Xmr = 'xmr'
|
Xmr = 'xmr',
|
||||||
|
Btc = 'btc'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { DiscountCodesModule } from '../discountCode/DiscountCodesModule';
|
|||||||
import { ProductsModule } from '../product/ProductsModule';
|
import { ProductsModule } from '../product/ProductsModule';
|
||||||
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
|
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
|
||||||
import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule';
|
import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule';
|
||||||
import { XmrRateModule } from '../xmrRate/XmrRateModule';
|
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
|
||||||
import { StorefrontCartController } from './controllers/StorefrontCartController';
|
import { StorefrontCartController } from './controllers/StorefrontCartController';
|
||||||
import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver';
|
import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver';
|
||||||
import { StorefrontCartService } from './services/StorefrontCartService';
|
import { StorefrontCartService } from './services/StorefrontCartService';
|
||||||
import { StorefrontDiscountService } from './services/StorefrontDiscountService';
|
import { StorefrontDiscountService } from './services/StorefrontDiscountService';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, XmrRateModule],
|
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, ExchangeRateModule],
|
||||||
controllers: [StorefrontCartController],
|
controllers: [StorefrontCartController],
|
||||||
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
|
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
|
||||||
exports: [StorefrontCartService]
|
exports: [StorefrontCartService]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
||||||
import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
|
import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
|
||||||
import type { XmrRateService } from '../../xmrRate/services/XmrRateService';
|
import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
||||||
|
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||||
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
|
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
|
||||||
import type { StorefrontDiscountService } from './StorefrontDiscountService';
|
import type { StorefrontDiscountService } from './StorefrontDiscountService';
|
||||||
import { StorefrontCartService } from './StorefrontCartService';
|
import { StorefrontCartService } from './StorefrontCartService';
|
||||||
@@ -29,8 +30,8 @@ describe('StorefrontCartService', () => {
|
|||||||
getStorefrontVariantsByIds: jest.Mock;
|
getStorefrontVariantsByIds: jest.Mock;
|
||||||
getStorefrontVariant: jest.Mock;
|
getStorefrontVariant: jest.Mock;
|
||||||
};
|
};
|
||||||
let xmrRateService: {
|
let exchangeRateService: {
|
||||||
getLiveFiatPerXmr: jest.Mock;
|
getLiveFiatPerCrypto: jest.Mock;
|
||||||
};
|
};
|
||||||
let discountService: {
|
let discountService: {
|
||||||
getDiscountStateForCart: jest.Mock;
|
getDiscountStateForCart: jest.Mock;
|
||||||
@@ -43,8 +44,8 @@ describe('StorefrontCartService', () => {
|
|||||||
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
|
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
|
||||||
};
|
};
|
||||||
|
|
||||||
xmrRateService = {
|
exchangeRateService = {
|
||||||
getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
|
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
|
||||||
};
|
};
|
||||||
|
|
||||||
discountService = {
|
discountService = {
|
||||||
@@ -58,7 +59,7 @@ describe('StorefrontCartService', () => {
|
|||||||
|
|
||||||
service = new StorefrontCartService(
|
service = new StorefrontCartService(
|
||||||
productsService as unknown as StorefrontProductsService,
|
productsService as unknown as StorefrontProductsService,
|
||||||
xmrRateService as unknown as XmrRateService,
|
exchangeRateService as unknown as ExchangeRateService,
|
||||||
discountService as unknown as StorefrontDiscountService
|
discountService as unknown as StorefrontDiscountService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -118,7 +119,7 @@ describe('StorefrontCartService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('leaves cartTotalXmr null when no live rate is available', async () => {
|
it('leaves cartTotalXmr null when no live rate is available', async () => {
|
||||||
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
|
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
|
||||||
|
|
||||||
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
|
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type { CookieCart } from '../../storefrontCore/types/cart/CookieCart';
|
|||||||
import { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart';
|
import { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart';
|
||||||
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
||||||
import { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
|
import { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
|
||||||
import { XmrRateService } from '../../xmrRate/services/XmrRateService';
|
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
||||||
|
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||||
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
|
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
|
||||||
import { CookieCartLineDto } from '../dto/CookieCartLineDto';
|
import { CookieCartLineDto } from '../dto/CookieCartLineDto';
|
||||||
import type { CookieCartExtended } from '../types/CookieCartExtended';
|
import type { CookieCartExtended } from '../types/CookieCartExtended';
|
||||||
@@ -22,7 +23,7 @@ import { StorefrontDiscountService } from './StorefrontDiscountService';
|
|||||||
export class StorefrontCartService {
|
export class StorefrontCartService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly productsService: StorefrontProductsService,
|
private readonly productsService: StorefrontProductsService,
|
||||||
private readonly xmrRateService: XmrRateService,
|
private readonly exchangeRateService: ExchangeRateService,
|
||||||
private readonly discountService: StorefrontDiscountService
|
private readonly discountService: StorefrontDiscountService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export class StorefrontCartService {
|
|||||||
|
|
||||||
const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal');
|
const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal');
|
||||||
const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended);
|
const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended);
|
||||||
const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
|
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
|
||||||
const cartTotalXmr = fiatPerXmr !== null ? convertFiatToXmr(discountState.cartTotalPrice, fiatPerXmr) : null;
|
const cartTotalXmr = fiatPerXmr !== null ? convertFiatToXmr(discountState.cartTotalPrice, fiatPerXmr) : null;
|
||||||
const hasManualLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Manual);
|
const hasManualLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Manual);
|
||||||
const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto);
|
const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { EncryptionModule } from '../encryption/EncryptionModule';
|
import { EncryptionModule } from '../encryption/EncryptionModule';
|
||||||
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
|
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
|
||||||
import { XmrRateModule } from '../xmrRate/XmrRateModule';
|
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
|
||||||
import { StorefrontErrorController } from './controllers/StorefrontErrorController';
|
import { StorefrontErrorController } from './controllers/StorefrontErrorController';
|
||||||
import { StorefrontPreferencesController } from './controllers/StorefrontPreferencesController';
|
import { StorefrontPreferencesController } from './controllers/StorefrontPreferencesController';
|
||||||
import { StorefrontExceptionFilter } from './filters/StorefrontExceptionFilter';
|
import { StorefrontExceptionFilter } from './filters/StorefrontExceptionFilter';
|
||||||
@@ -18,7 +18,7 @@ import { StorefrontSignedCookieService } from './services/StorefrontSignedCookie
|
|||||||
import { StorefrontThemeCookieService } from './services/StorefrontThemeCookieService';
|
import { StorefrontThemeCookieService } from './services/StorefrontThemeCookieService';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ShopSettingsModule, XmrRateModule, EncryptionModule],
|
imports: [ShopSettingsModule, ExchangeRateModule, EncryptionModule],
|
||||||
controllers: [StorefrontErrorController, StorefrontPreferencesController],
|
controllers: [StorefrontErrorController, StorefrontPreferencesController],
|
||||||
providers: [
|
providers: [
|
||||||
StorefrontSignedCookieService,
|
StorefrontSignedCookieService,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Request, Response } from 'express';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { StorefrontShopViewService } from './StorefrontShopViewService';
|
import { StorefrontShopViewService } from './StorefrontShopViewService';
|
||||||
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
||||||
import { XmrRateService } from '../../xmrRate/services/XmrRateService';
|
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
||||||
import { StorefrontCartCookieService } from './StorefrontCartCookieService';
|
import { StorefrontCartCookieService } from './StorefrontCartCookieService';
|
||||||
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
|
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
|
||||||
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
|
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
|
||||||
@@ -50,9 +50,9 @@ describe('StorefrontShopViewService', () => {
|
|||||||
req: reqOverrides = {}
|
req: reqOverrides = {}
|
||||||
} = overrides;
|
} = overrides;
|
||||||
|
|
||||||
const xmrRateService = {
|
const exchangeRateService = {
|
||||||
getLiveFiatPerXmr: jest.fn().mockReturnValue(fiatPerXmr)
|
getLiveFiatPerCrypto: jest.fn().mockReturnValue(fiatPerXmr)
|
||||||
} as unknown as XmrRateService;
|
} as unknown as ExchangeRateService;
|
||||||
const configService = {
|
const configService = {
|
||||||
get: jest.fn().mockReturnValue(shopSettings)
|
get: jest.fn().mockReturnValue(shopSettings)
|
||||||
} as unknown as ConfigService;
|
} as unknown as ConfigService;
|
||||||
@@ -73,7 +73,7 @@ describe('StorefrontShopViewService', () => {
|
|||||||
} as unknown as StorefrontThemeCookieService;
|
} as unknown as StorefrontThemeCookieService;
|
||||||
|
|
||||||
const service = new StorefrontShopViewService(
|
const service = new StorefrontShopViewService(
|
||||||
xmrRateService,
|
exchangeRateService,
|
||||||
configService,
|
configService,
|
||||||
shopSettingsService,
|
shopSettingsService,
|
||||||
cartCookieService,
|
cartCookieService,
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { getTotalCartQtyFromCart } from '../../../utils/cart/getTotalCartQtyFrom
|
|||||||
import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
|
import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
|
||||||
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
|
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
|
||||||
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
||||||
import { XmrRateService } from '../../xmrRate/services/XmrRateService';
|
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
||||||
|
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||||
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
|
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
|
||||||
import type { ShopRenderLocals } from '../types/ShopRenderLocals';
|
import type { ShopRenderLocals } from '../types/ShopRenderLocals';
|
||||||
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
|
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
|
||||||
@@ -22,7 +23,7 @@ import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class StorefrontShopViewService {
|
export class StorefrontShopViewService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly xmrRateService: XmrRateService,
|
private readonly exchangeRateService: ExchangeRateService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly shopSettingsService: ShopSettingsService,
|
private readonly shopSettingsService: ShopSettingsService,
|
||||||
private readonly cartCookieService: StorefrontCartCookieService,
|
private readonly cartCookieService: StorefrontCartCookieService,
|
||||||
@@ -40,7 +41,7 @@ export class StorefrontShopViewService {
|
|||||||
|
|
||||||
const { shopName, shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
|
const { shopName, shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
|
||||||
|
|
||||||
const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
|
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
|
||||||
|
|
||||||
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
|
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
|
||||||
await this.shopSettingsService.getStorefrontBranding();
|
await this.shopSettingsService.getStorefrontBranding();
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { XmrRateService } from './services/XmrRateService';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [XmrRateService],
|
|
||||||
exports: [XmrRateService]
|
|
||||||
})
|
|
||||||
export class XmrRateModule {}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import {
|
|
||||||
IsEnum,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsObject,
|
|
||||||
Validate,
|
|
||||||
ValidatorConstraint,
|
|
||||||
type ValidatorConstraintInterface,
|
|
||||||
type ValidationArguments
|
|
||||||
} from 'class-validator';
|
|
||||||
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
|
|
||||||
|
|
||||||
@ValidatorConstraint({ name: 'coingeckoMoneroFiat' })
|
|
||||||
class CoingeckoMoneroFiatConstraint implements ValidatorConstraintInterface {
|
|
||||||
validate(monero: Record<string, number>, args: ValidationArguments): boolean {
|
|
||||||
const { shopFiatCurrency } = args.object as CoingeckoSimplePriceResponseDto;
|
|
||||||
|
|
||||||
if (!shopFiatCurrency) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rate = monero[shopFiatCurrency.toLowerCase()];
|
|
||||||
|
|
||||||
return typeof rate === 'number' && Number.isFinite(rate) && rate > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultMessage(): string {
|
|
||||||
return 'CoinGecko monero fiat rate must be a positive number';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CoingeckoSimplePriceResponseDto {
|
|
||||||
@IsEnum(ShopFiatCurrency)
|
|
||||||
shopFiatCurrency: ShopFiatCurrency;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsObject()
|
|
||||||
@Validate(CoingeckoMoneroFiatConstraint)
|
|
||||||
monero: Record<string, number>;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import {
|
|
||||||
ArrayMaxSize,
|
|
||||||
IsArray,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsString,
|
|
||||||
Validate,
|
|
||||||
ValidatorConstraint,
|
|
||||||
type ValidatorConstraintInterface
|
|
||||||
} from 'class-validator';
|
|
||||||
import Decimal from 'decimal.js';
|
|
||||||
|
|
||||||
@ValidatorConstraint({ name: 'krakenResult' })
|
|
||||||
class KrakenResultConstraint implements ValidatorConstraintInterface {
|
|
||||||
validate(result: unknown): boolean {
|
|
||||||
if (typeof result !== 'object' || result === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pair = Object.values(result as Record<string, unknown>)[0];
|
|
||||||
|
|
||||||
if (typeof pair !== 'object' || pair === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('c' in pair)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(pair.c) || pair.c.length < 1 || typeof pair.c[0] !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const price = new Decimal(pair.c[0]);
|
|
||||||
|
|
||||||
return price.isFinite() && price.gt(0);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultMessage(): string {
|
|
||||||
return 'Kraken ticker last price (c[0]) must be a positive number';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class KrakenTickerResponseDto {
|
|
||||||
@IsArray()
|
|
||||||
@ArrayMaxSize(0)
|
|
||||||
@IsString({ each: true })
|
|
||||||
error: string[];
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
@Validate(KrakenResultConstraint)
|
|
||||||
result: Record<string, { c: string[] }>;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { ShopFiatCurrency } from '../../types/ShopFiatCurrency';
|
|
||||||
|
|
||||||
export const KRAKEN_XMR_PAIR_BY_FIAT: Record<ShopFiatCurrency, string> = {
|
|
||||||
[ShopFiatCurrency.Usd]: 'XMRUSD',
|
|
||||||
[ShopFiatCurrency.Eur]: 'XMREUR',
|
|
||||||
[ShopFiatCurrency.Gbp]: 'XMRGBP',
|
|
||||||
[ShopFiatCurrency.Cad]: 'XMRCAD',
|
|
||||||
[ShopFiatCurrency.Aud]: 'XMRAUD',
|
|
||||||
[ShopFiatCurrency.Chf]: 'XMRCHF'
|
|
||||||
};
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import type { ConfigService } from '@nestjs/config';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { XmrRateService } from './XmrRateService';
|
|
||||||
|
|
||||||
jest.mock('axios');
|
|
||||||
|
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
||||||
|
|
||||||
describe('XmrRateService', () => {
|
|
||||||
let service: XmrRateService;
|
|
||||||
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', xmrRateFetchTimeoutMs: 5000 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'kraken') {
|
|
||||||
return { apiBaseUrl: 'https://kraken.test', xmrRateFetchTimeoutMs: 5000 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
service = new XmrRateService(configService as unknown as ConfigService);
|
|
||||||
mockedAxios.get.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
warnLogSpy.mockRestore();
|
|
||||||
errorLogSpy.mockRestore();
|
|
||||||
logSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores the CoinGecko rate when the fetch succeeds', async () => {
|
|
||||||
mockedAxios.get.mockResolvedValueOnce({
|
|
||||||
data: { monero: { usd: 152.3456 } }
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.fetchFiatPerXmrRate();
|
|
||||||
|
|
||||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
|
||||||
'https://coingecko.test/simple/price?ids=monero&vs_currencies=usd',
|
|
||||||
{ timeout: 5000 }
|
|
||||||
);
|
|
||||||
expect(service.getLiveFiatPerXmr()).toBe(152.35);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to Kraken when CoinGecko fails', async () => {
|
|
||||||
mockedAxios.get
|
|
||||||
.mockRejectedValueOnce(new Error('coingecko down'))
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
data: {
|
|
||||||
error: [],
|
|
||||||
result: {
|
|
||||||
XMRUSD: { c: ['149.876'] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.fetchFiatPerXmrRate();
|
|
||||||
|
|
||||||
expect(warnLogSpy).toHaveBeenCalled();
|
|
||||||
expect(mockedAxios.get).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
'https://kraken.test/Ticker?pair=XMRUSD',
|
|
||||||
{ timeout: 5000 }
|
|
||||||
);
|
|
||||||
expect(service.getLiveFiatPerXmr()).toBe(149.88);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves the cached rate null when both providers fail', async () => {
|
|
||||||
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockRejectedValueOnce(new Error('kraken down'));
|
|
||||||
|
|
||||||
await service.fetchFiatPerXmrRate();
|
|
||||||
|
|
||||||
expect(errorLogSpy).toHaveBeenCalled();
|
|
||||||
expect(service.getLiveFiatPerXmr()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to Kraken when CoinGecko returns an invalid payload', async () => {
|
|
||||||
mockedAxios.get
|
|
||||||
.mockResolvedValueOnce({ data: { monero: { usd: -1 } } })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
data: {
|
|
||||||
error: [],
|
|
||||||
result: {
|
|
||||||
XMRUSD: { c: ['151.11'] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.fetchFiatPerXmrRate();
|
|
||||||
|
|
||||||
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
|
|
||||||
expect(service.getLiveFiatPerXmr()).toBe(151.11);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the previous live rate when a refresh fails after a successful fetch', async () => {
|
|
||||||
mockedAxios.get.mockResolvedValueOnce({
|
|
||||||
data: { monero: { usd: 140 } }
|
|
||||||
});
|
|
||||||
await service.fetchFiatPerXmrRate();
|
|
||||||
|
|
||||||
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockRejectedValueOnce(new Error('kraken down'));
|
|
||||||
await service.fetchFiatPerXmrRate();
|
|
||||||
|
|
||||||
expect(service.getLiveFiatPerXmr()).toBe(140);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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 Decimal from 'decimal.js';
|
|
||||||
import type { Config } from '../../../types/Config';
|
|
||||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
|
||||||
import { CoingeckoSimplePriceResponseDto } from '../dto/CoingeckoSimplePriceResponseDto';
|
|
||||||
import { KrakenTickerResponseDto } from '../dto/KrakenTickerResponseDto';
|
|
||||||
import { KRAKEN_XMR_PAIR_BY_FIAT } from '../krakenXmrPairs';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class XmrRateService implements OnModuleInit {
|
|
||||||
private readonly logger = new Logger(XmrRateService.name);
|
|
||||||
private fiatPerXmr: number | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
await this.fetchFiatPerXmrRate();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_30_SECONDS)
|
|
||||||
async fetchFiatPerXmrRate(): Promise<void> {
|
|
||||||
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.fiatPerXmr = await this.fetchFiatPerXmrFromCoingecko();
|
|
||||||
} catch {
|
|
||||||
this.logger.warn(`CoinGecko XMR/${shopFiatCurrency} rate fetch failed, trying Kraken`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.fiatPerXmr = await this.fetchFiatPerXmrFromKraken();
|
|
||||||
|
|
||||||
this.logger.log(`Successfully fetched XMR/${shopFiatCurrency} rate from Kraken`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to fetch XMR/${shopFiatCurrency} rate from CoinGecko and Kraken: ${getErrorMessage(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getLiveFiatPerXmr(): number | null {
|
|
||||||
return this.fiatPerXmr;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildCoingeckoRateUrl(): string {
|
|
||||||
const { apiBaseUrl } = this.configService.get('coingecko') as Config['coingecko'];
|
|
||||||
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
|
|
||||||
const vs = shopFiatCurrency.toLowerCase();
|
|
||||||
|
|
||||||
return `${apiBaseUrl}/simple/price?ids=monero&vs_currencies=${vs}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildKrakenRateUrl(): string {
|
|
||||||
const { apiBaseUrl } = this.configService.get('kraken') as Config['kraken'];
|
|
||||||
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
|
|
||||||
|
|
||||||
const pair = KRAKEN_XMR_PAIR_BY_FIAT[shopFiatCurrency];
|
|
||||||
|
|
||||||
return `${apiBaseUrl}/Ticker?pair=${pair}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchFiatPerXmrFromCoingecko(): Promise<number> {
|
|
||||||
const { xmrRateFetchTimeoutMs } = this.configService.get('coingecko') as Config['coingecko'];
|
|
||||||
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
|
|
||||||
|
|
||||||
const { data } = await axios.get<unknown>(this.buildCoingeckoRateUrl(), {
|
|
||||||
timeout: xmrRateFetchTimeoutMs
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = plainToInstance(CoingeckoSimplePriceResponseDto, data);
|
|
||||||
body.shopFiatCurrency = shopFiatCurrency;
|
|
||||||
|
|
||||||
const errors = validateSync(body);
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
throw new Error('CoinGecko response validation failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Decimal(body.monero[shopFiatCurrency.toLowerCase()]).toDecimalPlaces(2).toNumber();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchFiatPerXmrFromKraken(): Promise<number> {
|
|
||||||
const { xmrRateFetchTimeoutMs } = this.configService.get('kraken') as Config['kraken'];
|
|
||||||
|
|
||||||
const { data } = await axios.get<unknown>(this.buildKrakenRateUrl(), {
|
|
||||||
timeout: xmrRateFetchTimeoutMs
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = plainToInstance(KrakenTickerResponseDto, data);
|
|
||||||
const errors = validateSync(body);
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
throw new Error('Kraken response validation failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const pair = Object.values(body.result)[0];
|
|
||||||
|
|
||||||
return new Decimal(pair.c[0]).toDecimalPlaces(2).toNumber();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user