diff --git a/.env.example b/.env.example index c9ee101..132d859 100644 --- a/.env.example +++ b/.env.example @@ -87,6 +87,8 @@ COINPAPRIKA_RATE_FETCH_TIMEOUT_MS=5000 BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate with: openssl rand -base64 32 +PAYMENT_METHODS_ENABLED=xmr,btc + MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]' MONERO_VERSION=0.18.3.4 MONERO_NETWORK=stagenet @@ -99,6 +101,10 @@ MONERO_WALLET_RPC_TIMEOUT_MS=10000 MONERO_WALLET_DIR=./monero-wallet-rpc/wallet MONERO_WALLET_NAME=shop MONERO_WALLET_PASSWORD=change-me +MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment) + +BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":1},{"upToTotalFiat":"300","minConfirmations":3},{"minConfirmations":6}]' +BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment) SIMPLEX_CHAT_VERSION=v6.5.6 SIMPLEX_WS_URL=ws://simplex-cli:5225 @@ -110,8 +116,6 @@ ORDER_CHECKOUT_STATUS_REFRESH_SEC=15 ORDER_SHIPPING_PAYMENT_VALIDITY_MS=259200000 # 72 hours ORDER_DATA_RETENTION_DAYS=30 -MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR - VITE_API_BASE_URL=http://localhost:3000/api VITE_SHOP_FIAT_CURRENCY=USD VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index a785cf7..b844f67 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -17,6 +17,7 @@ import { NodeEnv } from '../types/NodeEnv'; import { ShopFiatCurrency } from '../types/ShopFiatCurrency'; import { PaymentMethod } from '../modules/payment/types/PaymentMethod'; import { SimplexConfig } from '../types/SimplexConfig'; +import { parseEnabledPaymentMethods } from '../utils/payment/parseEnabledPaymentMethods'; const env = (key: string): string => process.env[key] || ''; @@ -177,8 +178,12 @@ export const getEncryptionConfig = (): EncryptionConfig => ({ export const getShopSettingsConfig = (): ShopSettingsConfig => ({ shopName: env('SHOP_NAME'), shopFiatCurrency: env('SHOP_FIAT_CURRENCY') as ShopFiatCurrency, + enabledPaymentMethods: parseEnabledPaymentMethods(env('PAYMENT_METHODS_ENABLED')), monero: { confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as ConfirmationTier[] + }, + bitcoin: { + confirmationTiers: JSON.parse(env('BITCOIN_CONFIRMATION_TIERS')) as ConfirmationTier[] } }); @@ -192,7 +197,7 @@ export const getOrderConfig = (): OrderConfig => ({ export const getInvoiceConfig = (): InvoiceConfig => ({ minByMethod: { [PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC')), - [PaymentMethod.Btc]: String(envInt('BTC_MIN_INCOMING_ATOMIC')) + [PaymentMethod.Btc]: String(envInt('BITCOIN_MIN_INCOMING_ATOMIC')) } }); diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts index 5663dc0..ea802cf 100644 --- a/backend/src/config/validate.ts +++ b/backend/src/config/validate.ts @@ -4,6 +4,7 @@ import { NodeEnv } from '../types/NodeEnv'; import { ShopFiatCurrency } from '../types/ShopFiatCurrency'; import { IsBase64 } from '../validation/decorators/isBase64'; import { IsConfirmationTiers } from '../validation/decorators/isConfirmationTiers'; +import { IsEnabledPaymentMethods } from '../validation/decorators/isEnabledPaymentMethods'; import { MoneroNetwork } from '../types/MoneroNetwork'; class EnvironmentVariables { @@ -268,6 +269,16 @@ class EnvironmentVariables { @IsConfirmationTiers() MONERO_CONFIRMATION_TIERS: string; + @IsNotEmpty() + @IsString() + @IsConfirmationTiers() + BITCOIN_CONFIRMATION_TIERS: string; + + @IsNotEmpty() + @IsString() + @IsEnabledPaymentMethods() + PAYMENT_METHODS_ENABLED: string; + @IsNotEmpty() @IsNumber() @Min(60000) @@ -294,6 +305,12 @@ class EnvironmentVariables { @Max(Number.MAX_SAFE_INTEGER) MONERO_MIN_INCOMING_ATOMIC: number; + @IsNotEmpty() + @IsNumber() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + BITCOIN_MIN_INCOMING_ATOMIC: number; + @IsNotEmpty() @IsString() MONERO_DAEMON_ADDRESS: string; diff --git a/backend/src/modules/shopSettings/services/ShopSettingsService.ts b/backend/src/modules/shopSettings/services/ShopSettingsService.ts index 8df7153..293e154 100644 --- a/backend/src/modules/shopSettings/services/ShopSettingsService.ts +++ b/backend/src/modules/shopSettings/services/ShopSettingsService.ts @@ -196,7 +196,9 @@ export class ShopSettingsService { }: ShopSettings): ShopSettingsView { const setupChecklist = this.buildSetupChecklist({ logoStorageKey, faviconStorageKey, simplexLink, shippingNote }); - const { shopName, shopFiatCurrency, monero } = this.configService.get('shopSettings') as Config['shopSettings']; + const { shopName, shopFiatCurrency, monero, bitcoin } = this.configService.get( + 'shopSettings' + ) as Config['shopSettings']; const logoUrl = logoStorageKey ? getShopBrandingPublicUrl(logoStorageKey) : null; const faviconUrl = faviconStorageKey ? getShopBrandingPublicUrl(faviconStorageKey) : null; @@ -208,6 +210,7 @@ export class ShopSettingsService { shopName, shopFiatCurrency, monero, + bitcoin, logoUrl, faviconUrl, simplexLink, diff --git a/backend/src/modules/shopSettings/types/ShopSettingsBitcoinView.ts b/backend/src/modules/shopSettings/types/ShopSettingsBitcoinView.ts new file mode 100644 index 0000000..5579930 --- /dev/null +++ b/backend/src/modules/shopSettings/types/ShopSettingsBitcoinView.ts @@ -0,0 +1,5 @@ +import { ConfirmationTier } from '../../../types/ConfirmationTier'; + +export interface ShopSettingsBitcoinView { + confirmationTiers: ConfirmationTier[]; +} diff --git a/backend/src/modules/shopSettings/types/ShopSettingsView.ts b/backend/src/modules/shopSettings/types/ShopSettingsView.ts index 6e4cfef..9c889de 100644 --- a/backend/src/modules/shopSettings/types/ShopSettingsView.ts +++ b/backend/src/modules/shopSettings/types/ShopSettingsView.ts @@ -1,12 +1,14 @@ import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency'; import { SetupChecklist } from './SetupChecklist'; import { ShopSettingsMoneroView } from './ShopSettingsMoneroView'; +import { ShopSettingsBitcoinView } from './ShopSettingsBitcoinView'; export interface ShopSettingsView { id: string | null; shopName: string; shopFiatCurrency: ShopFiatCurrency; monero: ShopSettingsMoneroView; + bitcoin: ShopSettingsBitcoinView; logoUrl: string | null; faviconUrl: string | null; simplexLink: string | null; diff --git a/backend/src/types/Config.ts b/backend/src/types/Config.ts index 24248cb..66f5584 100644 --- a/backend/src/types/Config.ts +++ b/backend/src/types/Config.ts @@ -54,9 +54,13 @@ export interface AppConfig { export interface ShopSettingsConfig { shopName: string; shopFiatCurrency: ShopFiatCurrency; + enabledPaymentMethods: PaymentMethod[]; monero: { confirmationTiers: ConfirmationTier[]; }; + bitcoin: { + confirmationTiers: ConfirmationTier[]; + }; } export interface JwtConfig { diff --git a/backend/src/utils/payment/isPaymentMethodEnabled.ts b/backend/src/utils/payment/isPaymentMethodEnabled.ts new file mode 100644 index 0000000..d9fcd4a --- /dev/null +++ b/backend/src/utils/payment/isPaymentMethodEnabled.ts @@ -0,0 +1,6 @@ +import type { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; + +export const isPaymentMethodEnabled = ( + method: PaymentMethod, + enabledMethods: readonly PaymentMethod[] +): boolean => enabledMethods.includes(method); diff --git a/backend/src/utils/payment/isPaymentMethodValue.ts b/backend/src/utils/payment/isPaymentMethodValue.ts new file mode 100644 index 0000000..900832b --- /dev/null +++ b/backend/src/utils/payment/isPaymentMethodValue.ts @@ -0,0 +1,5 @@ +import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; + +const PAYMENT_METHOD_VALUES = new Set(Object.values(PaymentMethod)); + +export const isPaymentMethodValue = (value: string): value is PaymentMethod => PAYMENT_METHOD_VALUES.has(value); diff --git a/backend/src/utils/payment/parseEnabledPaymentMethods.spec.ts b/backend/src/utils/payment/parseEnabledPaymentMethods.spec.ts new file mode 100644 index 0000000..d651ed2 --- /dev/null +++ b/backend/src/utils/payment/parseEnabledPaymentMethods.spec.ts @@ -0,0 +1,25 @@ +import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; +import { parseEnabledPaymentMethods } from './parseEnabledPaymentMethods'; + +describe('parseEnabledPaymentMethods', () => { + it('parses a single enabled method', () => { + expect(parseEnabledPaymentMethods('xmr')).toEqual([PaymentMethod.Xmr]); + }); + + it('parses multiple enabled methods', () => { + expect(parseEnabledPaymentMethods('xmr, btc')).toEqual([PaymentMethod.Xmr, PaymentMethod.Btc]); + }); + + it('returns an empty array for empty input', () => { + expect(parseEnabledPaymentMethods('')).toEqual([]); + }); + + it('filters out unsupported methods', () => { + expect(parseEnabledPaymentMethods('eth')).toEqual([]); + expect(parseEnabledPaymentMethods('xmr,eth')).toEqual([PaymentMethod.Xmr]); + }); + + it('filters out duplicate methods', () => { + expect(parseEnabledPaymentMethods('xmr,xmr')).toEqual([PaymentMethod.Xmr]); + }); +}); diff --git a/backend/src/utils/payment/parseEnabledPaymentMethods.ts b/backend/src/utils/payment/parseEnabledPaymentMethods.ts new file mode 100644 index 0000000..3753e58 --- /dev/null +++ b/backend/src/utils/payment/parseEnabledPaymentMethods.ts @@ -0,0 +1,12 @@ +import type { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; +import { isPaymentMethodValue } from './isPaymentMethodValue'; + +export const parseEnabledPaymentMethods = (raw: string): PaymentMethod[] => { + const methods = raw + .split(',') + .map(value => value.trim()) + .filter(Boolean) + .filter(isPaymentMethodValue); + + return [...new Set(methods)]; +}; diff --git a/backend/src/validation/decorators/isEnabledPaymentMethods.spec.ts b/backend/src/validation/decorators/isEnabledPaymentMethods.spec.ts new file mode 100644 index 0000000..fb7c7fd --- /dev/null +++ b/backend/src/validation/decorators/isEnabledPaymentMethods.spec.ts @@ -0,0 +1,35 @@ +import { validateSync } from 'class-validator'; +import { IsEnabledPaymentMethods } from './isEnabledPaymentMethods'; + +class TestDto { + @IsEnabledPaymentMethods() + PAYMENT_METHODS_ENABLED: string; +} + +const validateMethods = (value: string) => { + const dto = Object.assign(new TestDto(), { PAYMENT_METHODS_ENABLED: value }); + + return validateSync(dto); +}; + +describe('IsEnabledPaymentMethods', () => { + it('accepts xmr only', () => { + expect(validateMethods('xmr')).toHaveLength(0); + }); + + it('accepts xmr and btc', () => { + expect(validateMethods('xmr,btc')).toHaveLength(0); + }); + + it('rejects empty string', () => { + expect(validateMethods('').length).toBeGreaterThan(0); + }); + + it('rejects unsupported methods', () => { + expect(validateMethods('eth').length).toBeGreaterThan(0); + }); + + it('rejects duplicate methods', () => { + expect(validateMethods('xmr,xmr').length).toBeGreaterThan(0); + }); +}); diff --git a/backend/src/validation/decorators/isEnabledPaymentMethods.ts b/backend/src/validation/decorators/isEnabledPaymentMethods.ts new file mode 100644 index 0000000..7d2de7d --- /dev/null +++ b/backend/src/validation/decorators/isEnabledPaymentMethods.ts @@ -0,0 +1,32 @@ +import { Validate, ValidatorConstraint, type ValidatorConstraintInterface } from 'class-validator'; +import { isPaymentMethodValue } from '../../utils/payment/isPaymentMethodValue'; + +@ValidatorConstraint({ name: 'isEnabledPaymentMethods' }) +class IsEnabledPaymentMethodsConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (typeof value !== 'string' || !value.trim()) { + return false; + } + + const tokens = value + .split(',') + .map(token => token.trim()) + .filter(Boolean); + + if (tokens.length === 0) { + return false; + } + + if (new Set(tokens).size !== tokens.length) { + return false; + } + + return tokens.every(isPaymentMethodValue); + } + + defaultMessage(): string { + return '$property must be a comma-separated list of supported payment methods without duplicates'; + } +} + +export const IsEnabledPaymentMethods = () => Validate(IsEnabledPaymentMethodsConstraint); diff --git a/cms/src/types/shopSettings/BitcoinConfirmationTier.ts b/cms/src/types/shopSettings/BitcoinConfirmationTier.ts new file mode 100644 index 0000000..c84cff6 --- /dev/null +++ b/cms/src/types/shopSettings/BitcoinConfirmationTier.ts @@ -0,0 +1,4 @@ +export interface BitcoinConfirmationTier { + upToTotalFiat?: string; + minConfirmations: number; +} diff --git a/cms/src/types/shopSettings/ShopSettings.ts b/cms/src/types/shopSettings/ShopSettings.ts index 2e41c52..c68dc5b 100644 --- a/cms/src/types/shopSettings/ShopSettings.ts +++ b/cms/src/types/shopSettings/ShopSettings.ts @@ -1,3 +1,4 @@ +import type { ShopSettingsBitcoin } from './ShopSettingsBitcoin'; import type { ShopSettingsMonero } from './ShopSettingsMonero'; import type { SetupChecklist } from './SetupChecklist'; @@ -6,6 +7,7 @@ export interface ShopSettings { shopName: string; shopFiatCurrency: string; monero: ShopSettingsMonero; + bitcoin: ShopSettingsBitcoin; logoUrl: string | null; faviconUrl: string | null; simplexLink: string | null; diff --git a/cms/src/types/shopSettings/ShopSettingsBitcoin.ts b/cms/src/types/shopSettings/ShopSettingsBitcoin.ts new file mode 100644 index 0000000..873922b --- /dev/null +++ b/cms/src/types/shopSettings/ShopSettingsBitcoin.ts @@ -0,0 +1,5 @@ +import type { BitcoinConfirmationTier } from './BitcoinConfirmationTier'; + +export interface ShopSettingsBitcoin { + confirmationTiers: BitcoinConfirmationTier[]; +}