Compare commits
88
Commits
ab7f384014
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55630e33b4 | ||
|
|
e057b29cc7 | ||
|
|
226a31b937 | ||
|
|
6419439a3d | ||
|
|
762af5c4f3 | ||
|
|
17a26b60ed | ||
|
|
9cd7059928 | ||
|
|
9c7b8470d3 | ||
|
|
e5eb773404 | ||
|
|
0cd7158f7d | ||
|
|
09ad236cb9 | ||
|
|
a3d1842fb3 | ||
|
|
73f7ffed00 | ||
|
|
abe6208e4e | ||
|
|
d38def2302 | ||
|
|
92ccbec545 | ||
|
|
40bf16c03e | ||
|
|
54ec6edd0d | ||
|
|
8372c30e40 | ||
|
|
0345fe042e | ||
|
|
a9ffc57608 | ||
|
|
49d6b597c2 | ||
|
|
0ab7f21bea | ||
|
|
155549a2ec | ||
|
|
f1715f7e7f | ||
|
|
ffc9a0bbe5 | ||
|
|
77cd686347 | ||
|
|
08a0bdcdb8 | ||
|
|
93c132b0df | ||
|
|
771d95d1cd | ||
|
|
254736356b | ||
|
|
738e406725 | ||
|
|
de4630635d | ||
|
|
6b358011b0 | ||
|
|
8729098f20 | ||
|
|
deb5f8c2c3 | ||
|
|
b3e683cd71 | ||
|
|
4458edb0d1 | ||
|
|
dcd499664a | ||
|
|
0dcffb394e | ||
|
|
e6c21e58bb | ||
|
|
8639176af4 | ||
|
|
68503ea01d | ||
|
|
dd234421a4 | ||
|
|
2d8a508898 | ||
|
|
de52534271 | ||
|
|
626f2ea4f0 | ||
|
|
6c894d58ba | ||
|
|
3a72d0521a | ||
|
|
8d9054b119 | ||
|
|
c1e28c7545 | ||
|
|
83cb437f19 | ||
|
|
564b016f47 | ||
|
|
82932ce7c8 | ||
|
|
33600d5c1c | ||
|
|
7640f5d74d | ||
|
|
35052661f8 | ||
|
|
5f1c8bee4b | ||
|
|
9e776df79f | ||
|
|
e7c42fcaca | ||
|
|
665362eb32 | ||
|
|
c7ac66a5cd | ||
|
|
500fe6cf94 | ||
|
|
917e86b3de | ||
|
|
cf92212226 | ||
|
|
ced92ead37 | ||
|
|
b728c34357 | ||
|
|
7222d0c9be | ||
|
|
302c3729c4 | ||
|
|
3b6323658e | ||
|
|
7b62bc41ad | ||
|
|
b4618cf3b1 | ||
|
|
cae5b4fe50 | ||
|
|
8c0749875e | ||
|
|
1143b8873c | ||
|
|
bfb7b013f9 | ||
|
|
cde7fab90c | ||
|
|
9241474d48 | ||
|
|
7ee3bcb271 | ||
|
|
5c888551db | ||
|
|
932cbf64c1 | ||
|
|
947dd23dd5 | ||
|
|
11c94c7834 | ||
|
|
a7282a1888 | ||
|
|
5db64ea905 | ||
|
|
0510695598 | ||
|
|
6818f08dbe | ||
|
|
93182aed75 |
+34
-8
@@ -19,6 +19,7 @@ NODE_ENV=development
|
|||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|
||||||
CLEARNET_DOMAIN=localhost
|
CLEARNET_DOMAIN=localhost
|
||||||
|
ONION_HOSTNAME=samplexxxxxxxxx.onion
|
||||||
|
|
||||||
JWT_SECRET=change-me-in-production
|
JWT_SECRET=change-me-in-production
|
||||||
JWT_EXPIRES_IN_MS=604800000 # 7 days
|
JWT_EXPIRES_IN_MS=604800000 # 7 days
|
||||||
@@ -36,16 +37,20 @@ THROTTLE_LIMIT=120
|
|||||||
MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
|
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
|
|
||||||
|
MULTER_BLOG_IMAGE_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
|
MULTER_BLOG_IMAGE_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
|
|
||||||
MULTER_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
|
MULTER_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
MULTER_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
|
MULTER_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
|
|
||||||
MULTER_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
|
MULTER_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
|
||||||
MULTER_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB
|
MULTER_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB
|
||||||
|
|
||||||
MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,image/jpeg,image/png
|
MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,application/x-zip-compressed,image/jpeg,image/png
|
||||||
MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
|
MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
|
||||||
|
|
||||||
VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
|
VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
|
||||||
|
VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200
|
||||||
VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
|
VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
|
||||||
VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
|
VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
|
||||||
VALIDATION_VARIANT_IMAGES_MAX=10
|
VALIDATION_VARIANT_IMAGES_MAX=10
|
||||||
@@ -53,6 +58,7 @@ VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
|
|||||||
VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
|
VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
|
||||||
VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
|
VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
|
||||||
VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
|
VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
|
||||||
|
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
|
||||||
|
|
||||||
SIGNED_COOKIE_JWT_SECRET=change-me-in-production
|
SIGNED_COOKIE_JWT_SECRET=change-me-in-production
|
||||||
|
|
||||||
@@ -80,14 +86,16 @@ SIGNED_COOKIE_THEME_NAME=storefront_theme
|
|||||||
SIGNED_COOKIE_THEME_EXPIRES_IN_MS=31536000000 # 365 days
|
SIGNED_COOKIE_THEME_EXPIRES_IN_MS=31536000000 # 365 days
|
||||||
|
|
||||||
COINGECKO_API_BASE_URL=https://api.coingecko.com/api/v3
|
COINGECKO_API_BASE_URL=https://api.coingecko.com/api/v3
|
||||||
COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS=5000
|
COINGECKO_RATE_FETCH_TIMEOUT_MS=5000
|
||||||
|
|
||||||
KRAKEN_API_BASE_URL=https://api.kraken.com/0/public
|
COINPAPRIKA_API_BASE_URL=https://api.coinpaprika.com/v1
|
||||||
KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS=5000
|
COINPAPRIKA_RATE_FETCH_TIMEOUT_MS=5000
|
||||||
|
|
||||||
BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate with: openssl rand -base64 32
|
BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate with: openssl rand -base64 32
|
||||||
|
|
||||||
MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]'
|
PAYMENT_METHODS_ENABLED=xmr,btc
|
||||||
|
|
||||||
|
MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":1},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]'
|
||||||
MONERO_VERSION=0.18.3.4
|
MONERO_VERSION=0.18.3.4
|
||||||
MONERO_NETWORK=stagenet
|
MONERO_NETWORK=stagenet
|
||||||
MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081
|
MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081
|
||||||
@@ -99,6 +107,22 @@ MONERO_WALLET_RPC_TIMEOUT_MS=10000
|
|||||||
MONERO_WALLET_DIR=./monero-wallet-rpc/wallet
|
MONERO_WALLET_DIR=./monero-wallet-rpc/wallet
|
||||||
MONERO_WALLET_NAME=shop
|
MONERO_WALLET_NAME=shop
|
||||||
MONERO_WALLET_PASSWORD=change-me
|
MONERO_WALLET_PASSWORD=change-me
|
||||||
|
MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment)
|
||||||
|
|
||||||
|
BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"200","minConfirmations":1},{"upToTotalFiat":"1000","minConfirmations":2},{"upToTotalFiat":"2000","minConfirmations":3},{"minConfirmations":6}]'
|
||||||
|
BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment)
|
||||||
|
|
||||||
|
ELECTRUM_VERSION=4.8.1
|
||||||
|
ELECTRUM_NETWORK=testnet4
|
||||||
|
ELECTRUM_SERVER=testnet4-electrumx.wakiyamap.dev:51002:s
|
||||||
|
ELECTRUM_DAEMON_HOST=electrum-daemon
|
||||||
|
ELECTRUM_DAEMON_PORT=7777
|
||||||
|
ELECTRUM_DAEMON_RPC_USER=electrum
|
||||||
|
ELECTRUM_DAEMON_RPC_PASSWORD=change-me
|
||||||
|
ELECTRUM_DAEMON_RPC_TIMEOUT_MS=10000
|
||||||
|
ELECTRUM_WALLET_DIR=./electrum-daemon/wallet
|
||||||
|
ELECTRUM_WALLET_NAME=shop
|
||||||
|
ELECTRUM_WALLET_PASSWORD=change-me
|
||||||
|
|
||||||
SIMPLEX_CHAT_VERSION=v6.5.6
|
SIMPLEX_CHAT_VERSION=v6.5.6
|
||||||
SIMPLEX_WS_URL=ws://simplex-cli:5225
|
SIMPLEX_WS_URL=ws://simplex-cli:5225
|
||||||
@@ -110,19 +134,20 @@ ORDER_CHECKOUT_STATUS_REFRESH_SEC=15
|
|||||||
ORDER_SHIPPING_PAYMENT_VALIDITY_MS=259200000 # 72 hours
|
ORDER_SHIPPING_PAYMENT_VALIDITY_MS=259200000 # 72 hours
|
||||||
ORDER_DATA_RETENTION_DAYS=30
|
ORDER_DATA_RETENTION_DAYS=30
|
||||||
|
|
||||||
MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR
|
|
||||||
|
|
||||||
VITE_API_BASE_URL=http://localhost:3000/api
|
VITE_API_BASE_URL=http://localhost:3000/api
|
||||||
VITE_SHOP_FIAT_CURRENCY=USD
|
VITE_SHOP_FIAT_CURRENCY=USD
|
||||||
VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
VITE_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
|
VITE_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
|
VITE_BLOG_IMAGE_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
|
VITE_BLOG_IMAGE_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
VITE_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
|
VITE_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
VITE_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
|
VITE_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
|
VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
|
||||||
VITE_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB
|
VITE_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB
|
||||||
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,image/jpeg,image/png
|
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,application/x-zip-compressed,image/jpeg,image/png
|
||||||
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
|
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
|
||||||
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
|
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
|
||||||
|
VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200
|
||||||
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
|
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
|
||||||
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
|
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
|
||||||
VITE_VALIDATION_VARIANT_IMAGES_MAX=10
|
VITE_VALIDATION_VARIANT_IMAGES_MAX=10
|
||||||
@@ -130,4 +155,5 @@ VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
|
|||||||
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
|
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
|
||||||
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
|
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
|
||||||
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
|
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
|
||||||
|
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
|
||||||
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000
|
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
/deploy/certbot/www
|
/deploy/certbot/www
|
||||||
|
|
||||||
/monero-wallet-rpc/wallet
|
/monero-wallet-rpc/wallet
|
||||||
|
/electrum-daemon/wallet
|
||||||
|
|
||||||
/backend/node_modules
|
/backend/node_modules
|
||||||
/backend/dist
|
/backend/dist
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# NullCart
|
# NullCart
|
||||||
|
|
||||||
Self-hosted Monero shop with clearnet (HTTPS) and Tor onion hosting.
|
Self-hosted crypto shop with clearnet (HTTPS) and Tor onion hosting.
|
||||||
|
|
||||||
For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md).
|
For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md).
|
||||||
|
|
||||||
@@ -12,9 +12,6 @@ For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md
|
|||||||
# Create and edit .env.dev as needed
|
# Create and edit .env.dev as needed
|
||||||
cp .env.example .env.dev
|
cp .env.example .env.dev
|
||||||
|
|
||||||
# Create monero wallet used by shop
|
|
||||||
./monero-wallet-rpc/setup-monero-wallet.sh --env-file .env.dev
|
|
||||||
|
|
||||||
# Build and start docker containers
|
# Build and start docker containers
|
||||||
docker compose --env-file .env.dev -f docker-compose.dev.yml build --no-cache
|
docker compose --env-file .env.dev -f docker-compose.dev.yml build --no-cache
|
||||||
docker compose --env-file .env.dev -f docker-compose.dev.yml up --force-recreate
|
docker compose --env-file .env.dev -f docker-compose.dev.yml up --force-recreate
|
||||||
|
|||||||
@@ -9,19 +9,22 @@ import {
|
|||||||
getCoingeckoConfig,
|
getCoingeckoConfig,
|
||||||
getEncryptionConfig,
|
getEncryptionConfig,
|
||||||
getJwtConfig,
|
getJwtConfig,
|
||||||
getKrakenConfig,
|
getCoinPaprikaConfig,
|
||||||
getOrderConfig,
|
getOrderConfig,
|
||||||
getInvoiceConfig,
|
getInvoiceConfig,
|
||||||
getMoneroWalletConfig,
|
getMoneroWalletConfig,
|
||||||
|
getElectrumWalletConfig,
|
||||||
getPostgresConfig,
|
getPostgresConfig,
|
||||||
getShopSettingsConfig,
|
getShopSettingsConfig,
|
||||||
getSimplexConfig
|
getSimplexConfig
|
||||||
} from './config';
|
} from './config';
|
||||||
import { validate } from './config/validate';
|
import { validate } from './config/validate';
|
||||||
import { AuthModule } from './modules/auth/AuthModule';
|
import { AuthModule } from './modules/auth/AuthModule';
|
||||||
|
import { BlogModule } from './modules/blog/BlogModule';
|
||||||
import { EncryptionModule } from './modules/encryption/EncryptionModule';
|
import { EncryptionModule } from './modules/encryption/EncryptionModule';
|
||||||
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
|
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
|
||||||
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
|
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
|
||||||
|
import { BitcoinWalletModule } from './modules/bitcoinWallet/BitcoinWalletModule';
|
||||||
import { SimplexModule } from './modules/simplex/SimplexModule';
|
import { SimplexModule } from './modules/simplex/SimplexModule';
|
||||||
import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule';
|
import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule';
|
||||||
import { DataWipeModule } from './modules/dataWipe/DataWipeModule';
|
import { DataWipeModule } from './modules/dataWipe/DataWipeModule';
|
||||||
@@ -35,7 +38,8 @@ 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 { StorefrontBlogModule } from './modules/storefrontBlog/StorefrontBlogModule';
|
||||||
|
import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
|
||||||
import { Config } from './types/Config';
|
import { Config } from './types/Config';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -49,12 +53,13 @@ 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),
|
||||||
registerAs('invoice', getInvoiceConfig),
|
registerAs('invoice', getInvoiceConfig),
|
||||||
registerAs('moneroWallet', getMoneroWalletConfig),
|
registerAs('moneroWallet', getMoneroWalletConfig),
|
||||||
|
registerAs('electrumWallet', getElectrumWalletConfig),
|
||||||
registerAs('simplex', getSimplexConfig)
|
registerAs('simplex', getSimplexConfig)
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
@@ -74,7 +79,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]
|
||||||
@@ -82,6 +87,7 @@ import { Config } from './types/Config';
|
|||||||
HealthCheckModule,
|
HealthCheckModule,
|
||||||
EncryptionModule,
|
EncryptionModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
BlogModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
ShopSettingsModule,
|
ShopSettingsModule,
|
||||||
SimplexModule,
|
SimplexModule,
|
||||||
@@ -91,11 +97,13 @@ import { Config } from './types/Config';
|
|||||||
PaymentModule,
|
PaymentModule,
|
||||||
DataWipeModule,
|
DataWipeModule,
|
||||||
MoneroWalletModule,
|
MoneroWalletModule,
|
||||||
|
BitcoinWalletModule,
|
||||||
StorefrontCoreModule,
|
StorefrontCoreModule,
|
||||||
StorefrontProductModule,
|
StorefrontProductModule,
|
||||||
StorefrontCartModule,
|
StorefrontCartModule,
|
||||||
StorefrontCheckoutModule,
|
StorefrontCheckoutModule,
|
||||||
StorefrontOrderModule
|
StorefrontOrderModule,
|
||||||
|
StorefrontBlogModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
+42
-10
@@ -4,19 +4,22 @@ import {
|
|||||||
EncryptionConfig,
|
EncryptionConfig,
|
||||||
InvoiceConfig,
|
InvoiceConfig,
|
||||||
JwtConfig,
|
JwtConfig,
|
||||||
KrakenConfig,
|
CoinPaprikaConfig,
|
||||||
MulterConfig,
|
MulterConfig,
|
||||||
OrderConfig,
|
OrderConfig,
|
||||||
PostgresConfig,
|
PostgresConfig,
|
||||||
ShopSettingsConfig
|
ShopSettingsConfig
|
||||||
} from '../types/Config';
|
} from '../types/Config';
|
||||||
|
import { ElectrumNetwork } from '../types/ElectrumNetwork';
|
||||||
|
import { ElectrumWalletConfig } from '../types/ElectrumWalletConfig';
|
||||||
import { MoneroNetwork } from '../types/MoneroNetwork';
|
import { MoneroNetwork } from '../types/MoneroNetwork';
|
||||||
import { MoneroWalletConfig } from '../types/MoneroWalletConfig';
|
import { MoneroWalletConfig } from '../types/MoneroWalletConfig';
|
||||||
import { MoneroConfirmationTier } from '../types/MoneroConfirmationTier';
|
import { ConfirmationTier } from '../types/ConfirmationTier';
|
||||||
import { NodeEnv } from '../types/NodeEnv';
|
import { NodeEnv } from '../types/NodeEnv';
|
||||||
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
|
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
|
||||||
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
|
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
|
||||||
import { SimplexConfig } from '../types/SimplexConfig';
|
import { SimplexConfig } from '../types/SimplexConfig';
|
||||||
|
import { parseEnabledPaymentMethods } from '../utils/payment/parseEnabledPaymentMethods';
|
||||||
|
|
||||||
const env = (key: string): string => process.env[key] || '';
|
const env = (key: string): string => process.env[key] || '';
|
||||||
|
|
||||||
@@ -94,13 +97,15 @@ export const getAppConfig = (): AppConfig => {
|
|||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
productTitleMaxLength: envInt('VALIDATION_PRODUCT_TITLE_MAX_LENGTH'),
|
productTitleMaxLength: envInt('VALIDATION_PRODUCT_TITLE_MAX_LENGTH'),
|
||||||
|
blogPostTitleMaxLength: envInt('VALIDATION_BLOG_POST_TITLE_MAX_LENGTH'),
|
||||||
categoryNameMaxLength: envInt('VALIDATION_CATEGORY_NAME_MAX_LENGTH'),
|
categoryNameMaxLength: envInt('VALIDATION_CATEGORY_NAME_MAX_LENGTH'),
|
||||||
discountCodeMaxLength: envInt('VALIDATION_DISCOUNT_CODE_MAX_LENGTH'),
|
discountCodeMaxLength: envInt('VALIDATION_DISCOUNT_CODE_MAX_LENGTH'),
|
||||||
variantImagesMax: envInt('VALIDATION_VARIANT_IMAGES_MAX'),
|
variantImagesMax: envInt('VALIDATION_VARIANT_IMAGES_MAX'),
|
||||||
digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'),
|
digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'),
|
||||||
shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'),
|
shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'),
|
||||||
shippingNoteMaxLength: envInt('VALIDATION_SHIPPING_NOTE_MAX_LENGTH'),
|
shippingNoteMaxLength: envInt('VALIDATION_SHIPPING_NOTE_MAX_LENGTH'),
|
||||||
orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH')
|
orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH'),
|
||||||
|
bitcoinWithdrawMaxFeeRateSatVbyte: envInt('VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -123,6 +128,17 @@ export const getProductThumbMulterConfig = (): MulterConfig => {
|
|||||||
return { allowedMimes, maxFileBytes };
|
return { allowedMimes, maxFileBytes };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getBlogImageMulterConfig = (): MulterConfig => {
|
||||||
|
const allowedMimes = env('MULTER_BLOG_IMAGE_ALLOWED_MIMES')
|
||||||
|
.split(',')
|
||||||
|
.map(v => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const maxFileBytes = envInt('MULTER_BLOG_IMAGE_MAX_FILE_BYTES');
|
||||||
|
|
||||||
|
return { allowedMimes, maxFileBytes };
|
||||||
|
};
|
||||||
|
|
||||||
export const getShopLogoMulterConfig = (): MulterConfig => {
|
export const getShopLogoMulterConfig = (): MulterConfig => {
|
||||||
const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES')
|
const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -159,14 +175,14 @@ export const getDigitalStockAttachmentMulterConfig = (): MulterConfig => {
|
|||||||
export const getCoingeckoConfig = (): CoingeckoConfig => {
|
export const getCoingeckoConfig = (): CoingeckoConfig => {
|
||||||
return {
|
return {
|
||||||
apiBaseUrl: env('COINGECKO_API_BASE_URL'),
|
apiBaseUrl: env('COINGECKO_API_BASE_URL'),
|
||||||
xmrRateFetchTimeoutMs: envInt('COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS')
|
rateFetchTimeoutMs: envInt('COINGECKO_RATE_FETCH_TIMEOUT_MS')
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getKrakenConfig = (): KrakenConfig => {
|
export const getCoinPaprikaConfig = (): CoinPaprikaConfig => {
|
||||||
return {
|
return {
|
||||||
apiBaseUrl: env('KRAKEN_API_BASE_URL'),
|
apiBaseUrl: env('COINPAPRIKA_API_BASE_URL'),
|
||||||
xmrRateFetchTimeoutMs: envInt('KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS')
|
rateFetchTimeoutMs: envInt('COINPAPRIKA_RATE_FETCH_TIMEOUT_MS')
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,8 +193,12 @@ export const getEncryptionConfig = (): EncryptionConfig => ({
|
|||||||
export const getShopSettingsConfig = (): ShopSettingsConfig => ({
|
export const getShopSettingsConfig = (): ShopSettingsConfig => ({
|
||||||
shopName: env('SHOP_NAME'),
|
shopName: env('SHOP_NAME'),
|
||||||
shopFiatCurrency: env('SHOP_FIAT_CURRENCY') as ShopFiatCurrency,
|
shopFiatCurrency: env('SHOP_FIAT_CURRENCY') as ShopFiatCurrency,
|
||||||
|
enabledPaymentMethods: parseEnabledPaymentMethods(env('PAYMENT_METHODS_ENABLED')),
|
||||||
monero: {
|
monero: {
|
||||||
confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as MoneroConfirmationTier[]
|
confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as ConfirmationTier[]
|
||||||
|
},
|
||||||
|
bitcoin: {
|
||||||
|
confirmationTiers: JSON.parse(env('BITCOIN_CONFIRMATION_TIERS')) as ConfirmationTier[]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,7 +211,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('BITCOIN_MIN_INCOMING_ATOMIC'))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,6 +225,16 @@ export const getMoneroWalletConfig = (): MoneroWalletConfig => ({
|
|||||||
rpcTimeoutMs: envInt('MONERO_WALLET_RPC_TIMEOUT_MS')
|
rpcTimeoutMs: envInt('MONERO_WALLET_RPC_TIMEOUT_MS')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getElectrumWalletConfig = (): ElectrumWalletConfig => ({
|
||||||
|
network: env('ELECTRUM_NETWORK') as ElectrumNetwork,
|
||||||
|
server: env('ELECTRUM_SERVER'),
|
||||||
|
rpcUrl: `http://${env('ELECTRUM_DAEMON_HOST')}:${envInt('ELECTRUM_DAEMON_PORT')}`,
|
||||||
|
username: env('ELECTRUM_DAEMON_RPC_USER'),
|
||||||
|
password: env('ELECTRUM_DAEMON_RPC_PASSWORD'),
|
||||||
|
walletPassword: env('ELECTRUM_WALLET_PASSWORD'),
|
||||||
|
rpcTimeoutMs: envInt('ELECTRUM_DAEMON_RPC_TIMEOUT_MS')
|
||||||
|
});
|
||||||
|
|
||||||
export const getSimplexConfig = (): SimplexConfig => ({
|
export const getSimplexConfig = (): SimplexConfig => ({
|
||||||
wsUrl: env('SIMPLEX_WS_URL'),
|
wsUrl: env('SIMPLEX_WS_URL'),
|
||||||
botDisplayName: env('SIMPLEX_BOT_DISPLAY_NAME')
|
botDisplayName: env('SIMPLEX_BOT_DISPLAY_NAME')
|
||||||
@@ -214,11 +245,12 @@ export default () => ({
|
|||||||
app: getAppConfig(),
|
app: getAppConfig(),
|
||||||
jwt: getJwtConfig(),
|
jwt: getJwtConfig(),
|
||||||
coingecko: getCoingeckoConfig(),
|
coingecko: getCoingeckoConfig(),
|
||||||
kraken: getKrakenConfig(),
|
coinPaprika: getCoinPaprikaConfig(),
|
||||||
encryption: getEncryptionConfig(),
|
encryption: getEncryptionConfig(),
|
||||||
shopSettings: getShopSettingsConfig(),
|
shopSettings: getShopSettingsConfig(),
|
||||||
order: getOrderConfig(),
|
order: getOrderConfig(),
|
||||||
invoice: getInvoiceConfig(),
|
invoice: getInvoiceConfig(),
|
||||||
moneroWallet: getMoneroWalletConfig(),
|
moneroWallet: getMoneroWalletConfig(),
|
||||||
|
electrumWallet: getElectrumWalletConfig(),
|
||||||
simplex: getSimplexConfig()
|
simplex: getSimplexConfig()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const storefrontImgUrlPrefix = '/shop/assets/img';
|
||||||
|
|
||||||
|
export const storefrontCssUrlPrefix = '/shop/assets/css';
|
||||||
|
|
||||||
|
export const storefrontJsUrlPrefix = '/shop/assets/js';
|
||||||
|
|
||||||
|
export const getStorefrontPublicDir = (): string =>
|
||||||
|
path.join(__dirname, '..', 'modules', 'storefrontCore', 'public');
|
||||||
|
|
||||||
|
export const getStorefrontImgDir = (): string => path.join(getStorefrontPublicDir(), 'img');
|
||||||
|
|
||||||
|
export const getStorefrontCssDir = (): string => path.join(getStorefrontPublicDir(), 'css');
|
||||||
|
|
||||||
|
export const getStorefrontJsDir = (): string => path.join(getStorefrontPublicDir(), 'js');
|
||||||
@@ -31,6 +31,20 @@ export const variantImagesUrlPrefix = '/uploads/public/variants/images/';
|
|||||||
|
|
||||||
export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`;
|
export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`;
|
||||||
|
|
||||||
|
export const getBlogImagesDir = (): string => join(getPublicUploadsDir(), 'blog', 'images');
|
||||||
|
|
||||||
|
export const blogImagesUrlPrefix = '/uploads/public/blog/images/';
|
||||||
|
|
||||||
|
export const getBlogImagePublicUrl = (filename: string): string => `${blogImagesUrlPrefix}${filename}`;
|
||||||
|
|
||||||
|
export const resolveBlogImagePath = (storageKey: string): string => {
|
||||||
|
const baseDir = getBlogImagesDir();
|
||||||
|
|
||||||
|
const absolutePath = resolvePathWithinDir(baseDir, storageKey);
|
||||||
|
|
||||||
|
return absolutePath;
|
||||||
|
};
|
||||||
|
|
||||||
export const getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop');
|
export const getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop');
|
||||||
|
|
||||||
export const shopBrandingUrlPrefix = '/uploads/public/shop/';
|
export const shopBrandingUrlPrefix = '/uploads/public/shop/';
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min, validateSy
|
|||||||
import { NodeEnv } from '../types/NodeEnv';
|
import { NodeEnv } from '../types/NodeEnv';
|
||||||
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
|
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
|
||||||
import { IsBase64 } from '../validation/decorators/isBase64';
|
import { IsBase64 } from '../validation/decorators/isBase64';
|
||||||
import { IsMoneroConfirmationTiers } from '../validation/decorators/isMoneroConfirmationTiers';
|
import { IsConfirmationTiers } from '../validation/decorators/isConfirmationTiers';
|
||||||
|
import { IsEnabledPaymentMethods } from '../validation/decorators/isEnabledPaymentMethods';
|
||||||
|
import { ElectrumNetwork } from '../types/ElectrumNetwork';
|
||||||
import { MoneroNetwork } from '../types/MoneroNetwork';
|
import { MoneroNetwork } from '../types/MoneroNetwork';
|
||||||
|
|
||||||
class EnvironmentVariables {
|
class EnvironmentVariables {
|
||||||
@@ -73,6 +75,16 @@ class EnvironmentVariables {
|
|||||||
@Max(10 * 1024 * 1024) // 10MB
|
@Max(10 * 1024 * 1024) // 10MB
|
||||||
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number;
|
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
MULTER_BLOG_IMAGE_ALLOWED_MIMES: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10 * 1024 * 1024) // 10MB
|
||||||
|
MULTER_BLOG_IMAGE_MAX_FILE_BYTES: number;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
MULTER_SHOP_LOGO_ALLOWED_MIMES: string;
|
MULTER_SHOP_LOGO_ALLOWED_MIMES: string;
|
||||||
@@ -99,6 +111,12 @@ class EnvironmentVariables {
|
|||||||
@Max(256)
|
@Max(256)
|
||||||
VALIDATION_PRODUCT_TITLE_MAX_LENGTH: number;
|
VALIDATION_PRODUCT_TITLE_MAX_LENGTH: number;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(256)
|
||||||
|
VALIDATION_BLOG_POST_TITLE_MAX_LENGTH: number;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@@ -151,6 +169,12 @@ class EnvironmentVariables {
|
|||||||
@Max(10000)
|
@Max(10000)
|
||||||
VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number;
|
VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(1000)
|
||||||
|
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE: number;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(4)
|
@Min(4)
|
||||||
@@ -246,17 +270,17 @@ class EnvironmentVariables {
|
|||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
KRAKEN_API_BASE_URL: string;
|
COINPAPRIKA_API_BASE_URL: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS: number;
|
COINPAPRIKA_RATE_FETCH_TIMEOUT_MS: number;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS: number;
|
COINGECKO_RATE_FETCH_TIMEOUT_MS: number;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -265,9 +289,19 @@ class EnvironmentVariables {
|
|||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsMoneroConfirmationTiers()
|
@IsConfirmationTiers()
|
||||||
MONERO_CONFIRMATION_TIERS: string;
|
MONERO_CONFIRMATION_TIERS: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@IsConfirmationTiers()
|
||||||
|
BITCOIN_CONFIRMATION_TIERS: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@IsEnabledPaymentMethods()
|
||||||
|
PAYMENT_METHODS_ENABLED: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(60000)
|
@Min(60000)
|
||||||
@@ -294,6 +328,12 @@ class EnvironmentVariables {
|
|||||||
@Max(Number.MAX_SAFE_INTEGER)
|
@Max(Number.MAX_SAFE_INTEGER)
|
||||||
MONERO_MIN_INCOMING_ATOMIC: number;
|
MONERO_MIN_INCOMING_ATOMIC: number;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(Number.MAX_SAFE_INTEGER)
|
||||||
|
BITCOIN_MIN_INCOMING_ATOMIC: number;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
MONERO_DAEMON_ADDRESS: string;
|
MONERO_DAEMON_ADDRESS: string;
|
||||||
@@ -325,6 +365,41 @@ class EnvironmentVariables {
|
|||||||
@Min(1000)
|
@Min(1000)
|
||||||
MONERO_WALLET_RPC_TIMEOUT_MS: number;
|
MONERO_WALLET_RPC_TIMEOUT_MS: number;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(ElectrumNetwork)
|
||||||
|
ELECTRUM_NETWORK: ElectrumNetwork;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
ELECTRUM_SERVER: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
ELECTRUM_DAEMON_HOST: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(65535)
|
||||||
|
ELECTRUM_DAEMON_PORT: number;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
ELECTRUM_DAEMON_RPC_USER: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
ELECTRUM_DAEMON_RPC_PASSWORD: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1000)
|
||||||
|
ELECTRUM_DAEMON_RPC_TIMEOUT_MS: number;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
ELECTRUM_WALLET_PASSWORD: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
SIMPLEX_WS_URL: string;
|
SIMPLEX_WS_URL: string;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export const BTC_ATOMIC_PER_BTC = new Decimal(100_000_000);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { storefrontImgUrlPrefix } from '../config/storefrontAssetPaths';
|
||||||
|
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
|
||||||
|
|
||||||
|
export const paymentMethodIconUrl: Record<PaymentMethod, string> = {
|
||||||
|
[PaymentMethod.Xmr]: `${storefrontImgUrlPrefix}/xmr.png`,
|
||||||
|
[PaymentMethod.Btc]: `${storefrontImgUrlPrefix}/btc.png`
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
|
||||||
|
|
||||||
|
export const paymentMethodLabel: Record<PaymentMethod, string> = {
|
||||||
|
[PaymentMethod.Xmr]: 'XMR',
|
||||||
|
[PaymentMethod.Btc]: 'BTC'
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const STOREFRONT_THEME_PREFERENCES = ['light', 'dark', 'system'] as const;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBtcInvoiceDetails1784800000000 implements MigrationInterface {
|
||||||
|
name = 'AddBtcInvoiceDetails1784800000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TYPE "public"."invoices_paymentmethod_enum" ADD VALUE 'btc'`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "invoice_btc_details" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "fiatPerBtcAtCreation" numeric(12,2) NOT NULL, "requiredConfirmations" integer NOT NULL, "invoiceId" uuid, CONSTRAINT "UQ_invoice_btc_details_invoice_id" UNIQUE ("invoiceId"), CONSTRAINT "PK_invoice_btc_details" PRIMARY KEY ("id"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "invoice_btc_details" ADD CONSTRAINT "FK_d307f6e0ecc770f47c0bc320d3d" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "invoice_btc_details" DROP CONSTRAINT "FK_d307f6e0ecc770f47c0bc320d3d"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "invoice_btc_details"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBlog1784900000000 implements MigrationInterface {
|
||||||
|
name = 'AddBlog1784900000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blog_posts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "thumbnailStorageKey" character varying, "bannerStorageKey" character varying, "contentHtml" text NOT NULL, "isDraft" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_blog_posts_id" PRIMARY KEY ("id"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`ALTER TABLE "shop_settings" ADD "blogEnabled" boolean NOT NULL DEFAULT false`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "blogEnabled"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "blog_posts"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-4
@@ -5,6 +5,14 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import hbs from 'hbs';
|
import hbs from 'hbs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
getStorefrontCssDir,
|
||||||
|
getStorefrontImgDir,
|
||||||
|
getStorefrontJsDir,
|
||||||
|
storefrontCssUrlPrefix,
|
||||||
|
storefrontImgUrlPrefix,
|
||||||
|
storefrontJsUrlPrefix
|
||||||
|
} from './config/storefrontAssetPaths';
|
||||||
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
|
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
|
||||||
import { AppModule } from './AppModule';
|
import { AppModule } from './AppModule';
|
||||||
import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers';
|
import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers';
|
||||||
@@ -26,10 +34,18 @@ async function bootstrap() {
|
|||||||
immutable: true
|
immutable: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const storefrontPublicDir = path.join(__dirname, 'modules', 'storefrontCore', 'public');
|
app.useStaticAssets(getStorefrontImgDir(), {
|
||||||
|
prefix: storefrontImgUrlPrefix,
|
||||||
|
maxAge: '1y',
|
||||||
|
immutable: true
|
||||||
|
});
|
||||||
|
|
||||||
app.useStaticAssets(storefrontPublicDir, {
|
app.useStaticAssets(getStorefrontCssDir(), {
|
||||||
prefix: '/shop/assets'
|
prefix: storefrontCssUrlPrefix
|
||||||
|
});
|
||||||
|
|
||||||
|
app.useStaticAssets(getStorefrontJsDir(), {
|
||||||
|
prefix: storefrontJsUrlPrefix
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
@@ -61,7 +77,9 @@ async function bootstrap() {
|
|||||||
{ path: '/', method: RequestMethod.GET },
|
{ path: '/', method: RequestMethod.GET },
|
||||||
{ path: 'shop/categories/:id', method: RequestMethod.GET },
|
{ path: 'shop/categories/:id', method: RequestMethod.GET },
|
||||||
{ path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET },
|
{ path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET },
|
||||||
{ path: 'shop/assets/(.*)', method: RequestMethod.GET },
|
{ path: 'shop/blog', method: RequestMethod.GET },
|
||||||
|
{ path: 'shop/blog/:id', method: RequestMethod.GET },
|
||||||
|
{ path: 'shop/assets/{*path}', method: RequestMethod.GET },
|
||||||
{ path: 'shop/preferences/theme', method: RequestMethod.POST },
|
{ path: 'shop/preferences/theme', method: RequestMethod.POST },
|
||||||
{ path: 'shop/error', method: RequestMethod.GET },
|
{ path: 'shop/error', method: RequestMethod.GET },
|
||||||
{ path: 'shop/cart', method: RequestMethod.GET },
|
{ path: 'shop/cart', method: RequestMethod.GET },
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/AuthModule';
|
||||||
|
import { BitcoinWalletController } from './controllers/BitcoinWalletController';
|
||||||
|
import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService';
|
||||||
|
import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient';
|
||||||
|
import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [BitcoinWalletController],
|
||||||
|
providers: [ElectrumWalletRpcClient, ElectrumWalletRpcConnectionService, BitcoinWalletAdminService],
|
||||||
|
exports: [ElectrumWalletRpcClient]
|
||||||
|
})
|
||||||
|
export class BitcoinWalletModule {}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||||
|
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||||
|
import { BitcoinWalletRevealSeedDto } from '../dto/BitcoinWalletRevealSeedDto';
|
||||||
|
import { BitcoinWalletWithdrawDto } from '../dto/BitcoinWalletWithdrawDto';
|
||||||
|
import { BitcoinWalletAdminService } from '../services/BitcoinWalletAdminService';
|
||||||
|
|
||||||
|
@Controller('bitcoin-wallet')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
export class BitcoinWalletController {
|
||||||
|
constructor(private readonly walletAdminService: BitcoinWalletAdminService) {}
|
||||||
|
|
||||||
|
@Get('/')
|
||||||
|
getStatus() {
|
||||||
|
return this.walletAdminService.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/withdraw')
|
||||||
|
@Throttle(throttleProfiles.walletWithdraw)
|
||||||
|
withdraw(@Body() { destinationAddress, feeRateSatVbyte, password }: BitcoinWalletWithdrawDto) {
|
||||||
|
return this.walletAdminService.withdrawAll(destinationAddress, feeRateSatVbyte, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/reveal-seed')
|
||||||
|
@Throttle(throttleProfiles.walletRevealSeed)
|
||||||
|
revealSeed(@Body() { password }: BitcoinWalletRevealSeedDto) {
|
||||||
|
return this.walletAdminService.revealSeed(password);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class BitcoinWalletRevealSeedDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { getAppConfig } from '../../../config';
|
||||||
|
import { IsBitcoinAddress } from '../../../validation/decorators/isBitcoinAddress';
|
||||||
|
|
||||||
|
const {
|
||||||
|
validation: { bitcoinWithdrawMaxFeeRateSatVbyte }
|
||||||
|
} = getAppConfig();
|
||||||
|
|
||||||
|
export class BitcoinWalletWithdrawDto {
|
||||||
|
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsBitcoinAddress()
|
||||||
|
destinationAddress: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(bitcoinWithdrawMaxFeeRateSatVbyte)
|
||||||
|
feeRateSatVbyte: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import type { ConfigService } from '@nestjs/config';
|
||||||
|
import type { AuthService } from '../../auth/services/AuthService';
|
||||||
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
|
import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||||
|
import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
|
||||||
|
|
||||||
|
describe('BitcoinWalletAdminService', () => {
|
||||||
|
let service: BitcoinWalletAdminService;
|
||||||
|
let walletRpcClient: {
|
||||||
|
getVersion: jest.Mock;
|
||||||
|
isSynchronized: jest.Mock;
|
||||||
|
getInfo: jest.Mock;
|
||||||
|
getBalance: jest.Mock;
|
||||||
|
sweepAll: jest.Mock;
|
||||||
|
getSeed: jest.Mock;
|
||||||
|
};
|
||||||
|
let authService: {
|
||||||
|
verifyPassword: jest.Mock;
|
||||||
|
};
|
||||||
|
let configService: {
|
||||||
|
get: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
walletRpcClient = {
|
||||||
|
getVersion: jest.fn().mockResolvedValue('4.8.1'),
|
||||||
|
isSynchronized: jest.fn().mockResolvedValue(true),
|
||||||
|
getInfo: jest.fn().mockResolvedValue({
|
||||||
|
blockchain_height: 900_000,
|
||||||
|
server_height: 900_000
|
||||||
|
}),
|
||||||
|
getBalance: jest.fn().mockResolvedValue({
|
||||||
|
balanceAtomic: '150000',
|
||||||
|
confirmedBalanceAtomic: '150000'
|
||||||
|
}),
|
||||||
|
sweepAll: jest.fn().mockResolvedValue({
|
||||||
|
txHash: 'tx-hash-1',
|
||||||
|
amountAtomic: '140000'
|
||||||
|
}),
|
||||||
|
getSeed: jest.fn().mockResolvedValue('seed words')
|
||||||
|
};
|
||||||
|
|
||||||
|
authService = {
|
||||||
|
verifyPassword: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
configService = {
|
||||||
|
get: jest.fn().mockReturnValue({
|
||||||
|
network: 'testnet4'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new BitcoinWalletAdminService(
|
||||||
|
walletRpcClient as unknown as ElectrumWalletRpcClient,
|
||||||
|
authService as unknown as AuthService,
|
||||||
|
configService as unknown as ConfigService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns wallet status when RPC calls succeed', async () => {
|
||||||
|
const status = await service.getStatus();
|
||||||
|
|
||||||
|
expect(status).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
network: 'testnet4',
|
||||||
|
rpcVersion: '4.8.1',
|
||||||
|
blockHeight: 900_000,
|
||||||
|
serverHeight: 900_000,
|
||||||
|
syncStatus: WalletSyncStatus.Synced,
|
||||||
|
balanceBtc: '0.00150000',
|
||||||
|
confirmedBalanceBtc: '0.00150000'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports syncing when the wallet is not synchronized', async () => {
|
||||||
|
walletRpcClient.isSynchronized.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const status = await service.getStatus();
|
||||||
|
|
||||||
|
expect(status.syncStatus).toBe(WalletSyncStatus.Syncing);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when RPC calls fail', async () => {
|
||||||
|
walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down'));
|
||||||
|
|
||||||
|
await expect(service.getStatus()).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects withdrawals when there is no confirmed balance', async () => {
|
||||||
|
walletRpcClient.getBalance.mockResolvedValue({
|
||||||
|
balanceAtomic: '0',
|
||||||
|
confirmedBalanceAtomic: '0'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
|
||||||
|
new BadRequestException('No confirmed balance to withdraw.')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||||
|
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects withdrawals while the wallet is still syncing', async () => {
|
||||||
|
walletRpcClient.isSynchronized.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
|
||||||
|
new BadRequestException('Wallet is still syncing. Try again after sync completes.')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweeps confirmed funds when the wallet is synced', async () => {
|
||||||
|
const result = await service.withdrawAll('bc1qdestination', 12, 'password');
|
||||||
|
|
||||||
|
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('bc1qdestination', 12);
|
||||||
|
expect(result).toEqual({
|
||||||
|
txHash: 'tx-hash-1',
|
||||||
|
amountBtc: '0.00140000'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals the wallet seed after password verification', async () => {
|
||||||
|
await expect(service.revealSeed('password')).resolves.toEqual({ mnemonic: 'seed words' });
|
||||||
|
|
||||||
|
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||||
|
expect(walletRpcClient.getSeed).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when seed reveal fails', async () => {
|
||||||
|
walletRpcClient.getSeed.mockRejectedValue(new Error('rpc down'));
|
||||||
|
|
||||||
|
await expect(service.revealSeed('password')).rejects.toThrow(
|
||||||
|
new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when withdrawal fails after prechecks pass', async () => {
|
||||||
|
walletRpcClient.sweepAll.mockRejectedValue(new Error('payto failed'));
|
||||||
|
|
||||||
|
await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
|
||||||
|
new ServiceUnavailableException(
|
||||||
|
'Withdrawal failed. Funds may be unspendable dust, still unconfirmed, or the wallet may be out of sync. Refresh status and try again.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from '../../auth/services/AuthService';
|
||||||
|
import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc';
|
||||||
|
import type { Config } from '../../../types/Config';
|
||||||
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
|
import type { BitcoinWalletRevealSeedResult } from '../types/BitcoinWalletRevealSeedResult';
|
||||||
|
import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView';
|
||||||
|
import type { BitcoinWalletWithdrawResult } from '../types/BitcoinWalletWithdrawResult';
|
||||||
|
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BitcoinWalletAdminService {
|
||||||
|
constructor(
|
||||||
|
private readonly walletRpcClient: ElectrumWalletRpcClient,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly configService: ConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getStatus(): Promise<BitcoinWalletStatusView> {
|
||||||
|
const { network } = this.configService.get('electrumWallet') as Config['electrumWallet'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rpcVersion, isSynchronized, info, { balanceAtomic, confirmedBalanceAtomic }] = await Promise.all([
|
||||||
|
this.walletRpcClient.getVersion(),
|
||||||
|
this.walletRpcClient.isSynchronized(),
|
||||||
|
this.walletRpcClient.getInfo(),
|
||||||
|
this.walletRpcClient.getBalance()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const blockHeight = info.blockchain_height ?? null;
|
||||||
|
const serverHeight = info.server_height ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
network,
|
||||||
|
rpcVersion,
|
||||||
|
blockHeight,
|
||||||
|
serverHeight,
|
||||||
|
syncStatus: this.resolveSyncStatus(isSynchronized, blockHeight, serverHeight),
|
||||||
|
balanceBtc: convertBtcAtomicToBtc(balanceAtomic),
|
||||||
|
confirmedBalanceBtc: convertBtcAtomicToBtc(confirmedBalanceAtomic)
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Could not load wallet status. The Bitcoin wallet may be busy or unavailable.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async withdrawAll(
|
||||||
|
destinationAddress: string,
|
||||||
|
feeRateSatVbyte: number,
|
||||||
|
password: string
|
||||||
|
): Promise<BitcoinWalletWithdrawResult> {
|
||||||
|
this.authService.verifyPassword(password);
|
||||||
|
|
||||||
|
let isSynchronized: boolean;
|
||||||
|
let confirmedBalanceAtomic: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [syncResult, balanceResult] = await Promise.all([
|
||||||
|
this.walletRpcClient.isSynchronized(),
|
||||||
|
this.walletRpcClient.getBalance()
|
||||||
|
]);
|
||||||
|
|
||||||
|
isSynchronized = syncResult;
|
||||||
|
confirmedBalanceAtomic = balanceResult.confirmedBalanceAtomic;
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmedBalanceAtomic === '0') {
|
||||||
|
throw new BadRequestException('No confirmed balance to withdraw.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSynchronized) {
|
||||||
|
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let sweepResult: { txHash: string; amountAtomic: string };
|
||||||
|
|
||||||
|
try {
|
||||||
|
sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, feeRateSatVbyte);
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Withdrawal failed. Funds may be unspendable dust, still unconfirmed, or the wallet may be out of sync. Refresh status and try again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
txHash: sweepResult.txHash,
|
||||||
|
amountBtc: convertBtcAtomicToBtc(sweepResult.amountAtomic)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async revealSeed(password: string): Promise<BitcoinWalletRevealSeedResult> {
|
||||||
|
this.authService.verifyPassword(password);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mnemonic = await this.walletRpcClient.getSeed();
|
||||||
|
|
||||||
|
return { mnemonic };
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSyncStatus(
|
||||||
|
isSynchronized: boolean,
|
||||||
|
blockHeight: number | null,
|
||||||
|
serverHeight: number | null
|
||||||
|
): WalletSyncStatus {
|
||||||
|
if (isSynchronized) {
|
||||||
|
return WalletSyncStatus.Synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockHeight === null || serverHeight === null) {
|
||||||
|
return WalletSyncStatus.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WalletSyncStatus.Syncing;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { ElectrumWalletRpcClientTest } from '../types/ElectrumWalletRpcClientTest';
|
||||||
|
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
|
describe('ElectrumWalletRpcClient', () => {
|
||||||
|
let client: ElectrumWalletRpcClient;
|
||||||
|
let clientTest: ElectrumWalletRpcClientTest;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new ElectrumWalletRpcClient({
|
||||||
|
get: jest.fn().mockReturnValue({
|
||||||
|
rpcUrl: 'http://electrum.test:7777',
|
||||||
|
username: 'electrum',
|
||||||
|
password: 'secret',
|
||||||
|
walletPassword: 'wallet-secret',
|
||||||
|
rpcTimeoutMs: 5000
|
||||||
|
})
|
||||||
|
} as unknown as ConfigService);
|
||||||
|
|
||||||
|
clientTest = client as unknown as ElectrumWalletRpcClientTest;
|
||||||
|
mockedAxios.post.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapIncomingTransfer', () => {
|
||||||
|
it('maps confirmed transfers with confirmations derived from block height', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.mapIncomingTransfer(
|
||||||
|
{
|
||||||
|
tx_hash: 'abc123',
|
||||||
|
height: 800_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
||||||
|
},
|
||||||
|
'bc1qtest',
|
||||||
|
800_002
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
txHash: 'abc123',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 3,
|
||||||
|
inputOutpoints: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zero confirmations for unconfirmed transfers', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.mapIncomingTransfer(
|
||||||
|
{
|
||||||
|
tx_hash: 'abc123',
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
||||||
|
},
|
||||||
|
'bc1qtest',
|
||||||
|
800_002
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
txHash: 'abc123',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 0,
|
||||||
|
inputOutpoints: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-positive amounts', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.mapIncomingTransfer(
|
||||||
|
{
|
||||||
|
tx_hash: 'abc123',
|
||||||
|
height: 800_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
|
||||||
|
},
|
||||||
|
'bc1qtest',
|
||||||
|
800_002
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps input outpoints from transaction inputs', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.mapIncomingTransfer(
|
||||||
|
{
|
||||||
|
tx_hash: 'abc123',
|
||||||
|
height: 800_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputs: [
|
||||||
|
{ prevout_hash: 'abc123', prevout_n: 0 },
|
||||||
|
{ prevout_hash: 'def456', prevout_n: 2 }
|
||||||
|
],
|
||||||
|
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
||||||
|
},
|
||||||
|
'bc1qtest',
|
||||||
|
800_002
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
txHash: 'abc123',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 3,
|
||||||
|
inputOutpoints: ['abc123:0', 'def456:2']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips coinbase-like inputs without prevout data', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.mapIncomingTransfer(
|
||||||
|
{
|
||||||
|
tx_hash: 'abc123',
|
||||||
|
height: 800_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputs: [{}, { prevout_hash: '', prevout_n: 0 }],
|
||||||
|
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
||||||
|
},
|
||||||
|
'bc1qtest',
|
||||||
|
800_002
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
txHash: 'abc123',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 3,
|
||||||
|
inputOutpoints: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sumOutputValueAtomic', () => {
|
||||||
|
it('sums outputs paying to the target address', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.sumOutputValueAtomic(
|
||||||
|
{
|
||||||
|
outputs: [
|
||||||
|
{ address: 'bc1qother', value_sats: 10_000 },
|
||||||
|
{ address: 'bc1qtest', value_sats: 50_000 },
|
||||||
|
{ address: 'bc1qtest', value_sats: 25_000 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'bc1qtest'
|
||||||
|
)
|
||||||
|
).toBe('75000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zero when no outputs match the address', () => {
|
||||||
|
expect(
|
||||||
|
clientTest.sumOutputValueAtomic(
|
||||||
|
{
|
||||||
|
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
|
||||||
|
},
|
||||||
|
'bc1qtest'
|
||||||
|
)
|
||||||
|
).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getIncomingTransfers', () => {
|
||||||
|
it('resolves incoming amounts from transaction outputs', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: [{ tx_hash: 'abc123', height: 800_000 }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: '01000000'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: {
|
||||||
|
inputs: [{ prevout_hash: 'input123', prevout_n: 0 }],
|
||||||
|
outputs: [
|
||||||
|
{ address: 'bc1qother', value_sats: 10_000 },
|
||||||
|
{ address: 'bc1qtest', value_sats: 50_000 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([
|
||||||
|
{
|
||||||
|
txHash: 'abc123',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 3,
|
||||||
|
inputOutpoints: ['input123:0']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'gettransaction',
|
||||||
|
params: { txid: 'abc123' }
|
||||||
|
},
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'deserialize',
|
||||||
|
params: { tx: '01000000' }
|
||||||
|
},
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops superseded unconfirmed transfers that share inputs with a confirmed replacement', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: [
|
||||||
|
{ tx_hash: 'original', height: 0 },
|
||||||
|
{ tx_hash: 'replacement', height: 800_000 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: '01000000'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: {
|
||||||
|
inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }],
|
||||||
|
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: '02000000'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: {
|
||||||
|
inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }],
|
||||||
|
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([
|
||||||
|
{
|
||||||
|
txHash: 'replacement',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 3,
|
||||||
|
inputOutpoints: ['shared-input:0']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterSupersededBitcoinTransfers', () => {
|
||||||
|
it('keeps unrelated transfers unchanged', () => {
|
||||||
|
const transfers = [
|
||||||
|
{ txHash: 'a', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
|
||||||
|
{ txHash: 'b', amountAtomic: '1', confirmations: 3, inputOutpoints: ['in2:1'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an unconfirmed transfer superseded by a confirmed replacement', () => {
|
||||||
|
const transfers = [
|
||||||
|
{ txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
|
||||||
|
{ txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([
|
||||||
|
{ txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the later unconfirmed transfer when both conflict before confirmation', () => {
|
||||||
|
const transfers = [
|
||||||
|
{ txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
|
||||||
|
{ txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([
|
||||||
|
{ txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps transfers without input outpoints', () => {
|
||||||
|
const transfers = [
|
||||||
|
{ txHash: 'coinbase', amountAtomic: '1', confirmations: 0, inputOutpoints: [] },
|
||||||
|
{ txHash: 'payment', amountAtomic: '1', confirmations: 1, inputOutpoints: ['in1:0'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAddress', () => {
|
||||||
|
it('creates an address and sets a label when provided', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: 'bc1qtest' } })
|
||||||
|
.mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: true } });
|
||||||
|
|
||||||
|
await expect(client.createAddress('checkout - order-1')).resolves.toBe('bc1qtest');
|
||||||
|
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'setlabel',
|
||||||
|
params: { key: 'bc1qtest', label: 'checkout - order-1' }
|
||||||
|
},
|
||||||
|
expect.objectContaining({
|
||||||
|
auth: { username: 'electrum', password: 'secret' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBalance', () => {
|
||||||
|
it('returns confirmed and total balances in atomic units', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: {
|
||||||
|
confirmed: '0.00025',
|
||||||
|
unconfirmed: '0.0001'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getBalance()).resolves.toEqual({
|
||||||
|
balanceAtomic: '35000',
|
||||||
|
confirmedBalanceAtomic: '25000'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sweepAll', () => {
|
||||||
|
it('creates, signs, and broadcasts a max-payment transaction', async () => {
|
||||||
|
mockedAxios.post
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: 'signed-tx'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: {
|
||||||
|
outputs: [{ address: 'bc1qtest', value_sats: 140_000 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
result: 'tx-hash-1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.sweepAll('bc1qtest', 10)).resolves.toEqual({
|
||||||
|
txHash: 'tx-hash-1',
|
||||||
|
amountAtomic: '140000'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'payto',
|
||||||
|
params: {
|
||||||
|
destination: 'bc1qtest',
|
||||||
|
amount: '!',
|
||||||
|
feerate: '10',
|
||||||
|
password: 'wallet-secret'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expect.objectContaining({
|
||||||
|
timeout: 120_000
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'deserialize',
|
||||||
|
params: { tx: 'signed-tx' }
|
||||||
|
},
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
expect(mockedAxios.post).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
'http://electrum.test:7777',
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method: 'broadcast',
|
||||||
|
params: { tx: 'signed-tx' }
|
||||||
|
},
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { Config } from '../../../types/Config';
|
||||||
|
import { addAtomic } from '../../../utils/atomic/addAtomic';
|
||||||
|
import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic';
|
||||||
|
import type { ElectrumWalletAddressHistoryEntry } from '../types/ElectrumWalletAddressHistoryEntry';
|
||||||
|
import type { ElectrumWalletDeserializedTransaction } from '../types/ElectrumWalletDeserializedTransaction';
|
||||||
|
import type { ElectrumWalletGetBalanceResult } from '../types/ElectrumWalletGetBalanceResult';
|
||||||
|
import type { ElectrumWalletGetInfoResult } from '../types/ElectrumWalletGetInfoResult';
|
||||||
|
import type { ElectrumWalletIncomingTransfer } from '../types/ElectrumWalletIncomingTransfer';
|
||||||
|
import type { ElectrumWalletRpcResponse } from '../types/ElectrumWalletRpcResponse';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ElectrumWalletRpcClient {
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
private async call<T>(
|
||||||
|
method: string,
|
||||||
|
params: Record<string, unknown> | unknown[] = {},
|
||||||
|
options: { timeoutMs?: number } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get(
|
||||||
|
'electrumWallet'
|
||||||
|
) as Config['electrumWallet'];
|
||||||
|
|
||||||
|
const { data } = await axios.post<ElectrumWalletRpcResponse<T>>(
|
||||||
|
rpcUrl,
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'nullcart',
|
||||||
|
method,
|
||||||
|
params
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: options.timeoutMs ?? rpcTimeoutMs,
|
||||||
|
auth: {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.result === undefined) {
|
||||||
|
throw new Error(`Electrum wallet RPC ${method} returned no result`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVersion(): Promise<string> {
|
||||||
|
const version = await this.call<string>('version');
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
throw new Error('Electrum wallet RPC version returned no version');
|
||||||
|
}
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isSynchronized(): Promise<boolean> {
|
||||||
|
return this.call<boolean>('is_synchronized');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInfo(): Promise<ElectrumWalletGetInfoResult> {
|
||||||
|
return this.call<ElectrumWalletGetInfoResult>('getinfo');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(): Promise<{ balanceAtomic: string; confirmedBalanceAtomic: string }> {
|
||||||
|
const { confirmed, unconfirmed } = await this.call<ElectrumWalletGetBalanceResult>('getbalance');
|
||||||
|
|
||||||
|
if (confirmed === undefined) {
|
||||||
|
throw new Error('Electrum wallet RPC getbalance returned incomplete result');
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmedAtomic = convertBtcToBtcAtomic(confirmed);
|
||||||
|
|
||||||
|
const unconfirmedAtomic =
|
||||||
|
unconfirmed !== undefined && unconfirmed !== '0' ? convertBtcToBtcAtomic(unconfirmed) : '0';
|
||||||
|
|
||||||
|
return {
|
||||||
|
balanceAtomic: addAtomic(confirmedAtomic, unconfirmedAtomic),
|
||||||
|
confirmedBalanceAtomic: confirmedAtomic
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async sweepAll(
|
||||||
|
destinationAddress: string,
|
||||||
|
feeRateSatVbyte: number
|
||||||
|
): Promise<{ txHash: string; amountAtomic: string }> {
|
||||||
|
const signedTransaction = await this.payToMax(destinationAddress, feeRateSatVbyte);
|
||||||
|
const transaction = await this.deserializeTransaction(signedTransaction);
|
||||||
|
|
||||||
|
const amountAtomic = this.sumOutputValueAtomic(transaction, destinationAddress);
|
||||||
|
|
||||||
|
if (amountAtomic === '0') {
|
||||||
|
throw new Error('Withdrawal transaction has no spendable output to the destination address');
|
||||||
|
}
|
||||||
|
|
||||||
|
const txHash = await this.broadcast(signedTransaction);
|
||||||
|
|
||||||
|
return { txHash, amountAtomic };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async payToMax(destinationAddress: string, feeRateSatVbyte: number): Promise<string> {
|
||||||
|
const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
|
||||||
|
|
||||||
|
const signedTransaction = await this.call<string>(
|
||||||
|
'payto',
|
||||||
|
{
|
||||||
|
destination: destinationAddress,
|
||||||
|
amount: '!',
|
||||||
|
feerate: String(feeRateSatVbyte),
|
||||||
|
password: walletPassword
|
||||||
|
},
|
||||||
|
{ timeoutMs: 120_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!signedTransaction) {
|
||||||
|
throw new Error('Electrum wallet RPC payto returned no transaction');
|
||||||
|
}
|
||||||
|
|
||||||
|
return signedTransaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async broadcast(signedTransaction: string): Promise<string> {
|
||||||
|
const txHash = await this.call<string>('broadcast', { tx: signedTransaction });
|
||||||
|
|
||||||
|
if (!txHash) {
|
||||||
|
throw new Error('Electrum wallet RPC broadcast returned no transaction hash');
|
||||||
|
}
|
||||||
|
|
||||||
|
return txHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deserializeTransaction(signedTransaction: string): Promise<ElectrumWalletDeserializedTransaction> {
|
||||||
|
return this.call<ElectrumWalletDeserializedTransaction>('deserialize', { tx: signedTransaction });
|
||||||
|
}
|
||||||
|
|
||||||
|
private sumOutputValueAtomic(transaction: ElectrumWalletDeserializedTransaction, address: string): string {
|
||||||
|
if (!Array.isArray(transaction.outputs)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return transaction.outputs.reduce((sum, output) => {
|
||||||
|
if (output.address !== address || !Number.isFinite(output.value_sats) || output.value_sats <= 0) {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
return addAtomic(sum, String(output.value_sats));
|
||||||
|
}, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSeed(): Promise<string> {
|
||||||
|
const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
|
||||||
|
|
||||||
|
const mnemonic = await this.call<string>('getseed', { password: walletPassword });
|
||||||
|
|
||||||
|
if (!mnemonic) {
|
||||||
|
throw new Error('Electrum wallet RPC getseed returned no mnemonic');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mnemonic;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAddress(label?: string): Promise<string> {
|
||||||
|
const address = await this.call<string>('createnewaddress');
|
||||||
|
|
||||||
|
if (!address) {
|
||||||
|
throw new Error('Electrum wallet RPC createnewaddress returned no address');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
await this.call('setlabel', { key: address, label });
|
||||||
|
}
|
||||||
|
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIncomingTransfers(address: string, blockHeight: number | null): Promise<ElectrumWalletIncomingTransfer[]> {
|
||||||
|
const history = await this.call<ElectrumWalletAddressHistoryEntry[]>('getaddresshistory', {
|
||||||
|
address
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(history)) {
|
||||||
|
throw new Error('Electrum wallet RPC getaddresshistory returned invalid result');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transfers = await Promise.all(
|
||||||
|
history.map(async entry => {
|
||||||
|
const txHash = entry.tx_hash;
|
||||||
|
|
||||||
|
if (!txHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
|
||||||
|
const transaction = await this.deserializeTransaction(serializedTransaction);
|
||||||
|
|
||||||
|
return this.mapIncomingTransfer(entry, transaction, address, blockHeight);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedTransfers = transfers.filter(
|
||||||
|
(transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.filterSupersededBitcoinTransfers(resolvedTransfers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapIncomingTransfer(
|
||||||
|
entry: ElectrumWalletAddressHistoryEntry,
|
||||||
|
transaction: ElectrumWalletDeserializedTransaction,
|
||||||
|
address: string,
|
||||||
|
blockHeight: number | null
|
||||||
|
): ElectrumWalletIncomingTransfer | null {
|
||||||
|
const txHash = entry.tx_hash;
|
||||||
|
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
|
||||||
|
|
||||||
|
if (!txHash || amountAtomic === '0') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmations =
|
||||||
|
entry.height > 0 && blockHeight !== null ? Math.max(blockHeight - entry.height + 1, 0) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
txHash,
|
||||||
|
amountAtomic,
|
||||||
|
confirmations,
|
||||||
|
inputOutpoints: this.extractInputOutpoints(transaction)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterSupersededBitcoinTransfers(
|
||||||
|
transfers: ElectrumWalletIncomingTransfer[]
|
||||||
|
): ElectrumWalletIncomingTransfer[] {
|
||||||
|
return transfers.filter((transfer, index) => {
|
||||||
|
const isSuperseded = transfers.some((other, otherIndex) => {
|
||||||
|
if (otherIndex === index) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.sharesInputOutpoint(transfer.inputOutpoints, other.inputOutpoints)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (other.confirmations > transfer.confirmations) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return other.confirmations === transfer.confirmations && otherIndex > index;
|
||||||
|
});
|
||||||
|
|
||||||
|
return !isSuperseded;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractInputOutpoints(transaction: ElectrumWalletDeserializedTransaction): string[] {
|
||||||
|
if (!Array.isArray(transaction.inputs)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return transaction.inputs.flatMap(input => {
|
||||||
|
if (typeof input.prevout_hash !== 'string' || input.prevout_hash.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input.prevout_n !== 'number') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [`${input.prevout_hash}:${input.prevout_n}`];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sharesInputOutpoint(left: readonly string[], right: readonly string[]): boolean {
|
||||||
|
if (left.length === 0 || right.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightOutpoints = new Set(right);
|
||||||
|
|
||||||
|
return left.some(outpoint => rightOutpoints.has(outpoint));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||||
|
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ElectrumWalletRpcConnectionService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ElectrumWalletRpcConnectionService.name);
|
||||||
|
|
||||||
|
constructor(private readonly walletRpcClient: ElectrumWalletRpcClient) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const version = await this.walletRpcClient.getVersion();
|
||||||
|
|
||||||
|
this.logger.log(`Connected to electrum-daemon (version ${version})`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to reach electrum-daemon at startup: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface BitcoinWalletRevealSeedResult {
|
||||||
|
mnemonic: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ElectrumNetwork } from '../../../types/ElectrumNetwork';
|
||||||
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
|
|
||||||
|
export interface BitcoinWalletStatusView {
|
||||||
|
network: ElectrumNetwork;
|
||||||
|
rpcVersion: string;
|
||||||
|
blockHeight: number | null;
|
||||||
|
serverHeight: number | null;
|
||||||
|
syncStatus: WalletSyncStatus;
|
||||||
|
balanceBtc: string;
|
||||||
|
confirmedBalanceBtc: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface BitcoinWalletWithdrawResult {
|
||||||
|
txHash: string;
|
||||||
|
amountBtc: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ElectrumWalletAddressHistoryEntry = {
|
||||||
|
tx_hash: string;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ElectrumWalletDeserializedInput = {
|
||||||
|
prevout_hash?: string;
|
||||||
|
prevout_n?: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ElectrumWalletDeserializedOutput = {
|
||||||
|
address?: string;
|
||||||
|
value_sats: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ElectrumWalletDeserializedInput } from './ElectrumWalletDeserializedInput';
|
||||||
|
import type { ElectrumWalletDeserializedOutput } from './ElectrumWalletDeserializedOutput';
|
||||||
|
|
||||||
|
export type ElectrumWalletDeserializedTransaction = {
|
||||||
|
inputs?: ElectrumWalletDeserializedInput[];
|
||||||
|
outputs: ElectrumWalletDeserializedOutput[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ElectrumWalletGetBalanceResult = {
|
||||||
|
confirmed: string;
|
||||||
|
unconfirmed?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type ElectrumWalletGetInfoResult = {
|
||||||
|
blockchain_height?: number;
|
||||||
|
server?: string;
|
||||||
|
server_height?: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type ElectrumWalletIncomingTransfer = {
|
||||||
|
txHash: string;
|
||||||
|
amountAtomic: string;
|
||||||
|
confirmations: number;
|
||||||
|
inputOutpoints: string[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ElectrumWalletAddressHistoryEntry } from './ElectrumWalletAddressHistoryEntry';
|
||||||
|
import type { ElectrumWalletDeserializedTransaction } from './ElectrumWalletDeserializedTransaction';
|
||||||
|
import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTransfer';
|
||||||
|
|
||||||
|
export type ElectrumWalletRpcClientTest = {
|
||||||
|
mapIncomingTransfer: (
|
||||||
|
entry: ElectrumWalletAddressHistoryEntry,
|
||||||
|
transaction: ElectrumWalletDeserializedTransaction,
|
||||||
|
address: string,
|
||||||
|
blockHeight: number | null
|
||||||
|
) => ElectrumWalletIncomingTransfer | null;
|
||||||
|
filterSupersededBitcoinTransfers: (
|
||||||
|
transfers: ElectrumWalletIncomingTransfer[]
|
||||||
|
) => ElectrumWalletIncomingTransfer[];
|
||||||
|
sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type ElectrumWalletRpcResponse<T> = {
|
||||||
|
id: string | number;
|
||||||
|
jsonrpc: string;
|
||||||
|
result?: T;
|
||||||
|
error?: {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BlogPostsController } from './controllers/BlogPostsController';
|
||||||
|
import { BlogPost } from './entities/BlogPost';
|
||||||
|
import { BlogPostsService } from './services/BlogPostsService';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([BlogPost])],
|
||||||
|
controllers: [BlogPostsController],
|
||||||
|
providers: [BlogPostsService],
|
||||||
|
exports: [TypeOrmModule.forFeature([BlogPost])]
|
||||||
|
})
|
||||||
|
export class BlogModule {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { getBlogImageMulterConfig } from '../../../config';
|
||||||
|
import { getBlogImagesDir } from '../../../config/uploadPaths';
|
||||||
|
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
|
||||||
|
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
|
||||||
|
|
||||||
|
const uploadDir = getBlogImagesDir();
|
||||||
|
|
||||||
|
export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir);
|
||||||
|
|
||||||
|
const { allowedMimes, maxFileBytes } = getBlogImageMulterConfig();
|
||||||
|
|
||||||
|
export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||||
|
import { blogImageFilePipe, blogImageUploadOptions } from '../config/blogImageUpload';
|
||||||
|
import { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
||||||
|
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
||||||
|
import { BlogPostsService } from '../services/BlogPostsService';
|
||||||
|
|
||||||
|
@Controller('blog-posts')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
export class BlogPostsController {
|
||||||
|
constructor(private readonly blogPostsService: BlogPostsService) {}
|
||||||
|
|
||||||
|
@Post('/')
|
||||||
|
createDraft() {
|
||||||
|
return this.blogPostsService.createDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/')
|
||||||
|
findAll(@Query() query: ListBlogPostsQueryDto) {
|
||||||
|
return this.blogPostsService.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/:id')
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.blogPostsService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('/:id')
|
||||||
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBlogPostDto) {
|
||||||
|
return this.blogPostsService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/:id/thumbnail')
|
||||||
|
@UseInterceptors(FileInterceptor('file', blogImageUploadOptions))
|
||||||
|
uploadThumbnail(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile(blogImageFilePipe) file: Express.Multer.File
|
||||||
|
) {
|
||||||
|
return this.blogPostsService.uploadThumbnail(id, file.filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/:id/banner')
|
||||||
|
@UseInterceptors(FileInterceptor('file', blogImageUploadOptions))
|
||||||
|
uploadBanner(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile(blogImageFilePipe) file: Express.Multer.File
|
||||||
|
) {
|
||||||
|
return this.blogPostsService.uploadBanner(id, file.filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/:id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
await this.blogPostsService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListBlogPostsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsBoolean, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
import { getAppConfig } from '../../../config';
|
||||||
|
|
||||||
|
const {
|
||||||
|
validation: { blogPostTitleMaxLength }
|
||||||
|
} = getAppConfig();
|
||||||
|
|
||||||
|
export class UpdateBlogPostDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(blogPostTitleMaxLength)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
contentHtml: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsBoolean()
|
||||||
|
isDraft: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('blog_posts')
|
||||||
|
export class BlogPost {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
thumbnailStorageKey: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
bannerStorageKey: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
contentHtml: string;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
isDraft: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import type { Repository } from 'typeorm';
|
||||||
|
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||||
|
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||||
|
import { BlogPost } from '../entities/BlogPost';
|
||||||
|
import { BlogPostsService } from './BlogPostsService';
|
||||||
|
|
||||||
|
jest.mock('../../../utils/removeFileFromDisk', () => ({
|
||||||
|
removeFileFromDisk: jest.fn().mockResolvedValue(undefined)
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('BlogPostsService', () => {
|
||||||
|
let service: BlogPostsService;
|
||||||
|
let blogPostRepo: {
|
||||||
|
findAndCount: jest.Mock;
|
||||||
|
findOne: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
insert: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
exists: jest.Mock;
|
||||||
|
delete: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(removeFileFromDisk).mockClear();
|
||||||
|
|
||||||
|
blogPostRepo = {
|
||||||
|
findAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
create: jest.fn(data => ({ id: 'post-1', ...data })),
|
||||||
|
insert: jest.fn().mockResolvedValue(undefined),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
exists: jest.fn().mockResolvedValue(false),
|
||||||
|
delete: jest.fn().mockResolvedValue(undefined)
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new BlogPostsService(blogPostRepo as unknown as Repository<BlogPost>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a draft post with empty title and content', async () => {
|
||||||
|
blogPostRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'post-1',
|
||||||
|
title: '',
|
||||||
|
contentHtml: '',
|
||||||
|
isDraft: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await service.createDraft();
|
||||||
|
|
||||||
|
expect(blogPostRepo.create).toHaveBeenCalledWith({
|
||||||
|
title: '',
|
||||||
|
contentHtml: '',
|
||||||
|
isDraft: true
|
||||||
|
});
|
||||||
|
expect(blogPostRepo.insert).toHaveBeenCalled();
|
||||||
|
expect(created).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'post-1',
|
||||||
|
title: '',
|
||||||
|
contentHtml: '',
|
||||||
|
isDraft: true,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
bannerUrl: null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps image storage keys to public urls', async () => {
|
||||||
|
blogPostRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Hello',
|
||||||
|
thumbnailStorageKey: 'thumb.webp',
|
||||||
|
bannerStorageKey: 'banner.webp',
|
||||||
|
contentHtml: '<p>Body</p>',
|
||||||
|
isDraft: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.findOne('post-1')).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Hello',
|
||||||
|
thumbnailUrl: getBlogImagePublicUrl('thumb.webp'),
|
||||||
|
bannerUrl: getBlogImagePublicUrl('banner.webp')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists posts ordered by createdAt descending', async () => {
|
||||||
|
await service.findAll({ page: 2, limit: 10 });
|
||||||
|
|
||||||
|
expect(blogPostRepo.findAndCount).toHaveBeenCalledWith({
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
skip: 10,
|
||||||
|
take: 10
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims title on update', async () => {
|
||||||
|
blogPostRepo.exists.mockResolvedValueOnce(true);
|
||||||
|
blogPostRepo.findOne.mockResolvedValueOnce({
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Hello',
|
||||||
|
contentHtml: '<p>Body</p>',
|
||||||
|
isDraft: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update('post-1', {
|
||||||
|
title: ' Hello ',
|
||||||
|
contentHtml: '<p>Body</p>',
|
||||||
|
isDraft: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', {
|
||||||
|
title: 'Hello',
|
||||||
|
contentHtml: '<p>Body</p>',
|
||||||
|
isDraft: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads a thumbnail and removes the previous file', async () => {
|
||||||
|
blogPostRepo.findOne
|
||||||
|
.mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'old.webp' })
|
||||||
|
.mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'new.webp' });
|
||||||
|
|
||||||
|
await service.uploadThumbnail('post-1', 'new.webp');
|
||||||
|
|
||||||
|
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { thumbnailStorageKey: 'new.webp' });
|
||||||
|
expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('old.webp'), BlogPostsService.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads a banner and removes the previous file', async () => {
|
||||||
|
blogPostRepo.findOne
|
||||||
|
.mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'old-banner.webp' })
|
||||||
|
.mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'new-banner.webp' });
|
||||||
|
|
||||||
|
await service.uploadBanner('post-1', 'new-banner.webp');
|
||||||
|
|
||||||
|
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { bannerStorageKey: 'new-banner.webp' });
|
||||||
|
expect(removeFileFromDisk).toHaveBeenCalledWith(
|
||||||
|
resolveBlogImagePath('old-banner.webp'),
|
||||||
|
BlogPostsService.name
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when loading a missing post', async () => {
|
||||||
|
await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when updating a missing post', async () => {
|
||||||
|
blogPostRepo.exists.mockResolvedValueOnce(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('missing-id', {
|
||||||
|
title: 'Hello',
|
||||||
|
contentHtml: '',
|
||||||
|
isDraft: true
|
||||||
|
})
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes an existing post and its image files', async () => {
|
||||||
|
blogPostRepo.findOne.mockResolvedValueOnce({
|
||||||
|
id: 'post-1',
|
||||||
|
thumbnailStorageKey: 'thumb.webp',
|
||||||
|
bannerStorageKey: 'banner.webp'
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.remove('post-1');
|
||||||
|
|
||||||
|
expect(blogPostRepo.delete).toHaveBeenCalledWith('post-1');
|
||||||
|
expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('thumb.webp'), BlogPostsService.name);
|
||||||
|
expect(removeFileFromDisk).toHaveBeenCalledWith(
|
||||||
|
resolveBlogImagePath('banner.webp'),
|
||||||
|
BlogPostsService.name
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when deleting a missing post', async () => {
|
||||||
|
await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException);
|
||||||
|
expect(blogPostRepo.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||||
|
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
|
||||||
|
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||||
|
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
||||||
|
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
||||||
|
import { BlogPost } from '../entities/BlogPost';
|
||||||
|
import type { BlogPostExtended } from '../types/BlogPostExtended';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BlogPostsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BlogPost)
|
||||||
|
private readonly blogPostRepo: Repository<BlogPost>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPostExtended>> {
|
||||||
|
const [items, total] = await this.blogPostRepo.findAndCount({
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemsExtended = items.map(post => this.toPostExtended(post));
|
||||||
|
|
||||||
|
return { items: itemsExtended, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDraft(): Promise<BlogPostExtended> {
|
||||||
|
const entity = this.blogPostRepo.create({
|
||||||
|
title: '',
|
||||||
|
contentHtml: '',
|
||||||
|
isDraft: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.blogPostRepo.insert(entity);
|
||||||
|
|
||||||
|
return this.findOne(entity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<BlogPostExtended> {
|
||||||
|
const entity = await this.findEntity(id);
|
||||||
|
|
||||||
|
return this.toPostExtended(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPostExtended> {
|
||||||
|
const exists = await this.blogPostRepo.exists({ where: { id } });
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.blogPostRepo.update(id, {
|
||||||
|
title: title.trim(),
|
||||||
|
contentHtml,
|
||||||
|
isDraft
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadThumbnail(id: string, filename: string): Promise<BlogPostExtended> {
|
||||||
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
|
const previousStorageKey = post.thumbnailStorageKey;
|
||||||
|
|
||||||
|
await this.blogPostRepo.update(id, { thumbnailStorageKey: filename });
|
||||||
|
await this.removeBlogImageFile(previousStorageKey);
|
||||||
|
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadBanner(id: string, filename: string): Promise<BlogPostExtended> {
|
||||||
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
|
const previousStorageKey = post.bannerStorageKey;
|
||||||
|
|
||||||
|
await this.blogPostRepo.update(id, { bannerStorageKey: filename });
|
||||||
|
await this.removeBlogImageFile(previousStorageKey);
|
||||||
|
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
|
await this.blogPostRepo.delete(id);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.removeBlogImageFile(post.thumbnailStorageKey),
|
||||||
|
this.removeBlogImageFile(post.bannerStorageKey)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findEntity(id: string): Promise<BlogPost> {
|
||||||
|
const entity = await this.blogPostRepo.findOne({ where: { id } });
|
||||||
|
|
||||||
|
if (!entity) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPostExtended(post: BlogPost): BlogPostExtended {
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
|
||||||
|
bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeBlogImageFile(storageKey: string | null): Promise<void> {
|
||||||
|
if (!storageKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = resolveBlogImagePath(storageKey);
|
||||||
|
|
||||||
|
await removeFileFromDisk(path, BlogPostsService.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { BlogPost } from '../entities/BlogPost';
|
||||||
|
|
||||||
|
export type BlogPostExtended = BlogPost & {
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
bannerUrl: string | null;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -18,8 +18,8 @@ export class MoneroWalletController {
|
|||||||
|
|
||||||
@Post('/withdraw')
|
@Post('/withdraw')
|
||||||
@Throttle(throttleProfiles.walletWithdraw)
|
@Throttle(throttleProfiles.walletWithdraw)
|
||||||
withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) {
|
withdraw(@Body() { destinationAddress, priority, password }: MoneroWalletWithdrawDto) {
|
||||||
return this.walletAdminService.withdrawAll(destinationAddress, password);
|
return this.walletAdminService.withdrawAll(destinationAddress, priority, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/reveal-seed')
|
@Post('/reveal-seed')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
|
||||||
import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress';
|
import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress';
|
||||||
|
|
||||||
export class MoneroWalletWithdrawDto {
|
export class MoneroWalletWithdrawDto {
|
||||||
@@ -9,6 +10,10 @@ export class MoneroWalletWithdrawDto {
|
|||||||
@IsMoneroStandardAddress()
|
@IsMoneroStandardAddress()
|
||||||
destinationAddress: string;
|
destinationAddress: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(MoneroWithdrawPriority)
|
||||||
|
priority: MoneroWithdrawPriority;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { BadRequestException, ServiceUnavailableException } from '@nestjs/common
|
|||||||
import type { ConfigService } from '@nestjs/config';
|
import type { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AuthService } from '../../auth/services/AuthService';
|
import type { AuthService } from '../../auth/services/AuthService';
|
||||||
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
|
import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
|
||||||
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||||
import { MoneroWalletAdminService } from './MoneroWalletAdminService';
|
import { MoneroWalletAdminService } from './MoneroWalletAdminService';
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ describe('MoneroWalletAdminService', () => {
|
|||||||
rpcVersion: '0.18.3.1',
|
rpcVersion: '0.18.3.1',
|
||||||
walletHeight: 3_000_000,
|
walletHeight: 3_000_000,
|
||||||
daemonHeight: 3_000_000,
|
daemonHeight: 3_000_000,
|
||||||
syncStatus: MoneroWalletSyncStatus.Synced,
|
syncStatus: WalletSyncStatus.Synced,
|
||||||
balanceXmr: '2.00000000',
|
balanceXmr: '2.00000000',
|
||||||
unlockedBalanceXmr: '1.00000000'
|
unlockedBalanceXmr: '1.00000000'
|
||||||
})
|
})
|
||||||
@@ -99,9 +100,9 @@ describe('MoneroWalletAdminService', () => {
|
|||||||
unlockedBalanceAtomic: '0'
|
unlockedBalanceAtomic: '0'
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
|
await expect(
|
||||||
new BadRequestException('No unlocked balance to withdraw.')
|
service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
|
||||||
);
|
).rejects.toThrow(new BadRequestException('No unlocked balance to withdraw.'));
|
||||||
|
|
||||||
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||||
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
|
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
|
||||||
@@ -110,15 +111,22 @@ describe('MoneroWalletAdminService', () => {
|
|||||||
it('rejects withdrawals while the wallet is still syncing', async () => {
|
it('rejects withdrawals while the wallet is still syncing', async () => {
|
||||||
walletRpcClient.getHeight.mockResolvedValue(2_999_000);
|
walletRpcClient.getHeight.mockResolvedValue(2_999_000);
|
||||||
|
|
||||||
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
|
await expect(
|
||||||
new BadRequestException('Wallet is still syncing. Try again after sync completes.')
|
service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
|
||||||
);
|
).rejects.toThrow(new BadRequestException('Wallet is still syncing. Try again after sync completes.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sweeps unlocked funds when the wallet is synced', async () => {
|
it('sweeps unlocked funds when the wallet is synced', async () => {
|
||||||
const result = await service.withdrawAll('4DestinationAddressExample', 'password');
|
const result = await service.withdrawAll(
|
||||||
|
'4DestinationAddressExample',
|
||||||
|
MoneroWithdrawPriority.Fast,
|
||||||
|
'password'
|
||||||
|
);
|
||||||
|
|
||||||
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('4DestinationAddressExample');
|
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith(
|
||||||
|
'4DestinationAddressExample',
|
||||||
|
MoneroWithdrawPriority.Fast
|
||||||
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
txHashes: ['tx-hash-1'],
|
txHashes: ['tx-hash-1'],
|
||||||
amountXmr: '1.00000000'
|
amountXmr: '1.00000000'
|
||||||
@@ -137,7 +145,7 @@ describe('MoneroWalletAdminService', () => {
|
|||||||
|
|
||||||
const status = await service.getStatus();
|
const status = await service.getStatus();
|
||||||
|
|
||||||
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Unknown);
|
expect(status.syncStatus).toBe(WalletSyncStatus.Unknown);
|
||||||
expect(status.daemonHeight).toBeNull();
|
expect(status.daemonHeight).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +157,7 @@ describe('MoneroWalletAdminService', () => {
|
|||||||
|
|
||||||
const status = await service.getStatus();
|
const status = await service.getStatus();
|
||||||
|
|
||||||
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Synced);
|
expect(status.syncStatus).toBe(WalletSyncStatus.Synced);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when reveal seed RPC fails', async () => {
|
it('throws when reveal seed RPC fails', async () => {
|
||||||
@@ -163,7 +171,9 @@ describe('MoneroWalletAdminService', () => {
|
|||||||
it('throws when sweep all fails after prechecks pass', async () => {
|
it('throws when sweep all fails after prechecks pass', async () => {
|
||||||
walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed'));
|
walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed'));
|
||||||
|
|
||||||
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
|
await expect(
|
||||||
|
service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
|
||||||
|
).rejects.toThrow(
|
||||||
new ServiceUnavailableException(
|
new ServiceUnavailableException(
|
||||||
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
|
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type { Config } from '../../../types/Config';
|
|||||||
import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult';
|
import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult';
|
||||||
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
|
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
|
||||||
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView';
|
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView';
|
||||||
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
|
import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
|
||||||
import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult';
|
import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult';
|
||||||
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||||
|
|
||||||
@@ -49,7 +50,11 @@ export class MoneroWalletAdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async withdrawAll(destinationAddress: string, password: string): Promise<MoneroWalletWithdrawResult> {
|
async withdrawAll(
|
||||||
|
destinationAddress: string,
|
||||||
|
priority: MoneroWithdrawPriority,
|
||||||
|
password: string
|
||||||
|
): Promise<MoneroWalletWithdrawResult> {
|
||||||
this.authService.verifyPassword(password);
|
this.authService.verifyPassword(password);
|
||||||
|
|
||||||
await this.walletRpcClient.tryRefresh();
|
await this.walletRpcClient.tryRefresh();
|
||||||
@@ -59,11 +64,15 @@ export class MoneroWalletAdminService {
|
|||||||
let daemonHeight: number | null;
|
let daemonHeight: number | null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([
|
const [balanceResult, walletHeightResult, daemonHeightResult] = await Promise.all([
|
||||||
this.walletRpcClient.getBalance(),
|
this.walletRpcClient.getBalance(),
|
||||||
this.walletRpcClient.getHeight(),
|
this.walletRpcClient.getHeight(),
|
||||||
this.fetchDaemonHeight()
|
this.fetchDaemonHeight()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
unlockedBalanceAtomic = balanceResult.unlockedBalanceAtomic;
|
||||||
|
walletHeight = walletHeightResult;
|
||||||
|
daemonHeight = daemonHeightResult;
|
||||||
} catch {
|
} catch {
|
||||||
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
|
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
|
||||||
}
|
}
|
||||||
@@ -72,7 +81,7 @@ export class MoneroWalletAdminService {
|
|||||||
throw new BadRequestException('No unlocked balance to withdraw.');
|
throw new BadRequestException('No unlocked balance to withdraw.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.resolveSyncStatus(walletHeight, daemonHeight) !== MoneroWalletSyncStatus.Synced) {
|
if (this.resolveSyncStatus(walletHeight, daemonHeight) !== WalletSyncStatus.Synced) {
|
||||||
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
|
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +89,10 @@ export class MoneroWalletAdminService {
|
|||||||
let amountAtomic: string;
|
let amountAtomic: string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress));
|
const sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, priority);
|
||||||
|
|
||||||
|
txHashes = sweepResult.txHashes;
|
||||||
|
amountAtomic = sweepResult.amountAtomic;
|
||||||
} catch {
|
} catch {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
|
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
|
||||||
@@ -125,11 +137,11 @@ export class MoneroWalletAdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): MoneroWalletSyncStatus {
|
private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): WalletSyncStatus {
|
||||||
if (daemonHeight === null) {
|
if (daemonHeight === null) {
|
||||||
return MoneroWalletSyncStatus.Unknown;
|
return WalletSyncStatus.Unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
return walletHeight >= daemonHeight - 1 ? MoneroWalletSyncStatus.Synced : MoneroWalletSyncStatus.Syncing;
|
return walletHeight >= daemonHeight - 1 ? WalletSyncStatus.Synced : WalletSyncStatus.Syncing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGe
|
|||||||
import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult';
|
import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult';
|
||||||
import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult';
|
import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult';
|
||||||
import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult';
|
import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult';
|
||||||
|
import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
|
||||||
import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult';
|
import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult';
|
||||||
import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge';
|
import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge';
|
||||||
import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse';
|
import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse';
|
||||||
@@ -222,14 +223,17 @@ export class MoneroWalletRpcClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async sweepAll(destinationAddress: string): Promise<{ txHashes: string[]; amountAtomic: string }> {
|
async sweepAll(
|
||||||
|
destinationAddress: string,
|
||||||
|
priority: MoneroWithdrawPriority
|
||||||
|
): Promise<{ txHashes: string[]; amountAtomic: string }> {
|
||||||
const result = await this.call<MoneroWalletRpcSweepAllResult>(
|
const result = await this.call<MoneroWalletRpcSweepAllResult>(
|
||||||
'sweep_all',
|
'sweep_all',
|
||||||
{
|
{
|
||||||
address: destinationAddress,
|
address: destinationAddress,
|
||||||
account_index: this.accountIndex,
|
account_index: this.accountIndex,
|
||||||
subaddr_indices_all: true,
|
subaddr_indices_all: true,
|
||||||
priority: 1
|
priority
|
||||||
},
|
},
|
||||||
{ timeoutMs: 120_000 }
|
{ timeoutMs: 120_000 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { MoneroNetwork } from '../../../types/MoneroNetwork';
|
import { MoneroNetwork } from '../../../types/MoneroNetwork';
|
||||||
import { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus';
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
|
|
||||||
export interface MoneroWalletStatusView {
|
export interface MoneroWalletStatusView {
|
||||||
network: MoneroNetwork;
|
network: MoneroNetwork;
|
||||||
rpcVersion: string;
|
rpcVersion: string;
|
||||||
walletHeight: number;
|
walletHeight: number;
|
||||||
daemonHeight: number | null;
|
daemonHeight: number | null;
|
||||||
syncStatus: MoneroWalletSyncStatus;
|
syncStatus: WalletSyncStatus;
|
||||||
balanceXmr: string;
|
balanceXmr: string;
|
||||||
unlockedBalanceXmr: string;
|
unlockedBalanceXmr: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class OrderCreationService {
|
|||||||
.leftJoinAndSelect('session.discounts', 'discount')
|
.leftJoinAndSelect('session.discounts', 'discount')
|
||||||
.leftJoinAndSelect('session.invoice', 'invoice')
|
.leftJoinAndSelect('session.invoice', 'invoice')
|
||||||
.leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
|
.leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
|
||||||
|
.leftJoinAndSelect('invoice.btcDetails', 'btcDetails')
|
||||||
.leftJoinAndSelect('invoice.payments', 'payment')
|
.leftJoinAndSelect('invoice.payments', 'payment')
|
||||||
.leftJoin('session.order', 'order')
|
.leftJoin('session.order', 'order')
|
||||||
.where('session.id = :sessionId', { sessionId })
|
.where('session.id = :sessionId', { sessionId })
|
||||||
|
|||||||
@@ -210,11 +210,45 @@ describe('OrderService', () => {
|
|||||||
accessToken: 'plain-token',
|
accessToken: 'plain-token',
|
||||||
checkoutInvoice: expect.objectContaining({
|
checkoutInvoice: expect.objectContaining({
|
||||||
statusLabel: 'Payment confirmed',
|
statusLabel: 'Payment confirmed',
|
||||||
|
paymentLabel: 'XMR',
|
||||||
expectedTotalCrypto: '0.10000000'
|
expectedTotalCrypto: '0.10000000'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats bitcoin checkout invoice amounts for the admin order view', async () => {
|
||||||
|
const storedOrder = {
|
||||||
|
...buildStoredOrder(),
|
||||||
|
checkoutInvoice: {
|
||||||
|
id: 'invoice-1',
|
||||||
|
fiatCurrency: 'USD',
|
||||||
|
paymentMethod: PaymentMethod.Btc,
|
||||||
|
expectedTotalAtomic: '100000000',
|
||||||
|
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
|
||||||
|
btcDetails: { fiatPerBtcAtCreation: 60_000, requiredConfirmations: 1 },
|
||||||
|
moneroDetails: null,
|
||||||
|
payments: [{ id: 'pay-1', amountAtomic: '100000000', confirmations: 1, txHash: 'tx-1' }],
|
||||||
|
reason: InvoiceReason.Checkout
|
||||||
|
}
|
||||||
|
} as unknown as Order;
|
||||||
|
|
||||||
|
orderDetailQueryBuilder.getOne.mockResolvedValue(storedOrder);
|
||||||
|
|
||||||
|
const result = await service.findById('order-1');
|
||||||
|
|
||||||
|
expect(result.checkoutInvoice).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
paymentLabel: 'BTC',
|
||||||
|
expectedTotalCrypto: '1.00000000',
|
||||||
|
payments: [
|
||||||
|
expect.objectContaining({
|
||||||
|
amountCrypto: '1.00000000'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setDeliveryCost', () => {
|
describe('setDeliveryCost', () => {
|
||||||
@@ -222,7 +256,7 @@ describe('OrderService', () => {
|
|||||||
({
|
({
|
||||||
id: 'order-1',
|
id: 'order-1',
|
||||||
lines: [{ deliveryMode: DeliveryMode.Manual }],
|
lines: [{ deliveryMode: DeliveryMode.Manual }],
|
||||||
checkoutInvoice: { id: 'checkout-invoice-1', fiatCurrency: 'USD' }
|
checkoutInvoice: { id: 'checkout-invoice-1', fiatCurrency: 'USD', paymentMethod: PaymentMethod.Xmr }
|
||||||
}) as Order;
|
}) as Order;
|
||||||
|
|
||||||
it('throws when the order cannot be quoted', async () => {
|
it('throws when the order cannot be quoted', async () => {
|
||||||
@@ -300,6 +334,26 @@ describe('OrderService', () => {
|
|||||||
});
|
});
|
||||||
expect(result).toBe(orderExtended);
|
expect(result).toBe(orderExtended);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the checkout invoice payment method for shipping invoices', async () => {
|
||||||
|
orderRepo.findOne.mockResolvedValue({
|
||||||
|
...buildQuotableOrder(),
|
||||||
|
checkoutInvoice: {
|
||||||
|
id: 'checkout-invoice-1',
|
||||||
|
fiatCurrency: 'USD',
|
||||||
|
paymentMethod: PaymentMethod.Btc
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.setDeliveryCost('order-1', { deliveryCost: 12.5 });
|
||||||
|
|
||||||
|
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
|
||||||
|
paymentMethod: PaymentMethod.Btc,
|
||||||
|
reason: InvoiceReason.Shipping,
|
||||||
|
contextId: 'order-1',
|
||||||
|
amountFiat: 12.5
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fulfillManualLine', () => {
|
describe('fulfillManualLine', () => {
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ import { formatInvoicePaymentConfirmationStatus } from '../../../utils/invoice/f
|
|||||||
import { resolveInvoiceStatusMessage } from '../../../utils/invoice/resolveInvoiceStatusMessage';
|
import { resolveInvoiceStatusMessage } from '../../../utils/invoice/resolveInvoiceStatusMessage';
|
||||||
import { resolveInvoiceRequiredConfirmations } from '../../../utils/invoice/resolveInvoiceRequiredConfirmations';
|
import { resolveInvoiceRequiredConfirmations } from '../../../utils/invoice/resolveInvoiceRequiredConfirmations';
|
||||||
import type { InvoiceState } from '../../../utils/invoice/types/InvoiceState';
|
import type { InvoiceState } from '../../../utils/invoice/types/InvoiceState';
|
||||||
import { convertXmrAtomicToXmr } from '../../../utils/monero/convertXmrAtomicToXmr';
|
import type { CryptoAtomicConverter } from '../../../types/CryptoAtomicConverter';
|
||||||
|
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
|
||||||
|
import { resolveAtomicToCryptoConverter } from '../../../utils/payment/resolveAtomicToCryptoConverter';
|
||||||
import type { Invoice } from '../../payment/entities/Invoice';
|
import type { Invoice } from '../../payment/entities/Invoice';
|
||||||
import type { InvoicePayment } from '../../payment/entities/InvoicePayment';
|
import type { InvoicePayment } from '../../payment/entities/InvoicePayment';
|
||||||
import type { InvoiceExtended } from '../../payment/types/InvoiceExtended';
|
import type { InvoiceExtended } from '../../payment/types/InvoiceExtended';
|
||||||
import type { InvoicePaymentExtended } from '../../payment/types/InvoicePaymentExtended';
|
import type { InvoicePaymentExtended } from '../../payment/types/InvoicePaymentExtended';
|
||||||
import { InvoiceReason } from '../../payment/types/InvoiceReason';
|
import { InvoiceReason } from '../../payment/types/InvoiceReason';
|
||||||
import { InvoiceService } from '../../payment/services/InvoiceService';
|
import { InvoiceService } from '../../payment/services/InvoiceService';
|
||||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
|
||||||
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
||||||
import { SetDeliveryCostDto } from '../dto/SetDeliveryCostDto';
|
import { SetDeliveryCostDto } from '../dto/SetDeliveryCostDto';
|
||||||
import type { ListOrdersQueryDto } from '../dto/ListOrdersQueryDto';
|
import type { ListOrdersQueryDto } from '../dto/ListOrdersQueryDto';
|
||||||
@@ -99,9 +100,11 @@ export class OrderService {
|
|||||||
'checkoutInvoice',
|
'checkoutInvoice',
|
||||||
'checkoutInvoice.payments',
|
'checkoutInvoice.payments',
|
||||||
'checkoutInvoice.moneroDetails',
|
'checkoutInvoice.moneroDetails',
|
||||||
|
'checkoutInvoice.btcDetails',
|
||||||
'shippingInvoice',
|
'shippingInvoice',
|
||||||
'shippingInvoice.payments',
|
'shippingInvoice.payments',
|
||||||
'shippingInvoice.moneroDetails',
|
'shippingInvoice.moneroDetails',
|
||||||
|
'shippingInvoice.btcDetails',
|
||||||
'lines',
|
'lines',
|
||||||
'lines.manualFulfillment',
|
'lines.manualFulfillment',
|
||||||
'discounts',
|
'discounts',
|
||||||
@@ -181,24 +184,31 @@ export class OrderService {
|
|||||||
|
|
||||||
const statusLabel = invoiceState ? resolveInvoiceStatusMessage(invoiceState) : null;
|
const statusLabel = invoiceState ? resolveInvoiceStatusMessage(invoiceState) : null;
|
||||||
|
|
||||||
const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic);
|
const convertAtomicToCrypto = resolveAtomicToCryptoConverter(invoice.paymentMethod);
|
||||||
|
|
||||||
|
const expectedTotalCrypto = convertAtomicToCrypto(invoice.expectedTotalAtomic);
|
||||||
|
|
||||||
const payments = (invoice.payments ?? []).map(payment =>
|
const payments = (invoice.payments ?? []).map(payment =>
|
||||||
this.toInvoicePaymentExtended(payment, requiredConfirmations)
|
this.toInvoicePaymentExtended(payment, requiredConfirmations, convertAtomicToCrypto)
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...invoice,
|
...invoice,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
|
paymentLabel: paymentMethodLabel[invoice.paymentMethod],
|
||||||
expectedTotalCrypto,
|
expectedTotalCrypto,
|
||||||
payments
|
payments
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private toInvoicePaymentExtended(payment: InvoicePayment, requiredConfirmations: number): InvoicePaymentExtended {
|
private toInvoicePaymentExtended(
|
||||||
|
payment: InvoicePayment,
|
||||||
|
requiredConfirmations: number,
|
||||||
|
convertAtomicToCrypto: CryptoAtomicConverter
|
||||||
|
): InvoicePaymentExtended {
|
||||||
const isConfirmed = payment.confirmations >= requiredConfirmations;
|
const isConfirmed = payment.confirmations >= requiredConfirmations;
|
||||||
|
|
||||||
const amountCrypto = convertXmrAtomicToXmr(payment.amountAtomic);
|
const amountCrypto = convertAtomicToCrypto(payment.amountAtomic);
|
||||||
|
|
||||||
const confirmationsLabel = formatInvoicePaymentConfirmationStatus({
|
const confirmationsLabel = formatInvoicePaymentConfirmationStatus({
|
||||||
confirmations: payment.confirmations,
|
confirmations: payment.confirmations,
|
||||||
@@ -244,7 +254,7 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shippingInvoice = await this.invoiceService.issueInvoice({
|
const shippingInvoice = await this.invoiceService.issueInvoice({
|
||||||
paymentMethod: PaymentMethod.Xmr,
|
paymentMethod: order.checkoutInvoice.paymentMethod,
|
||||||
reason: InvoiceReason.Shipping,
|
reason: InvoiceReason.Shipping,
|
||||||
contextId: orderId,
|
contextId: orderId,
|
||||||
amountFiat: deliveryCost
|
amountFiat: deliveryCost
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BitcoinWalletModule } from '../bitcoinWallet/BitcoinWalletModule';
|
||||||
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 { InvoiceBtcDetails } from './entities/InvoiceBtcDetails';
|
||||||
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
|
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
|
||||||
import { InvoicePayment } from './entities/InvoicePayment';
|
import { InvoicePayment } from './entities/InvoicePayment';
|
||||||
import { InvoicePaymentService } from './services/InvoicePaymentService';
|
import { InvoicePaymentService } from './services/InvoicePaymentService';
|
||||||
@@ -10,9 +12,10 @@ import { InvoiceService } from './services/InvoiceService';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]),
|
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]),
|
||||||
MoneroWalletModule,
|
MoneroWalletModule,
|
||||||
XmrRateModule
|
BitcoinWalletModule,
|
||||||
|
ExchangeRateModule
|
||||||
],
|
],
|
||||||
providers: [InvoicePaymentService, InvoiceService],
|
providers: [InvoicePaymentService, InvoiceService],
|
||||||
exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])]
|
exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ColumnBigIntTransformer } from '../../../utils/ColumnBigIntTransformer'
|
|||||||
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
|
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
|
||||||
import { PaymentMethod } from '../types/PaymentMethod';
|
import { PaymentMethod } from '../types/PaymentMethod';
|
||||||
import { InvoiceReason } from '../types/InvoiceReason';
|
import { InvoiceReason } from '../types/InvoiceReason';
|
||||||
|
import { InvoiceBtcDetails } from './InvoiceBtcDetails';
|
||||||
import { InvoiceMoneroDetails } from './InvoiceMoneroDetails';
|
import { InvoiceMoneroDetails } from './InvoiceMoneroDetails';
|
||||||
import { InvoicePayment } from './InvoicePayment';
|
import { InvoicePayment } from './InvoicePayment';
|
||||||
|
|
||||||
@@ -43,6 +44,9 @@ export class Invoice {
|
|||||||
@OneToOne(() => InvoiceMoneroDetails, moneroDetails => moneroDetails.invoice, { cascade: true })
|
@OneToOne(() => InvoiceMoneroDetails, moneroDetails => moneroDetails.invoice, { cascade: true })
|
||||||
moneroDetails: InvoiceMoneroDetails | null;
|
moneroDetails: InvoiceMoneroDetails | null;
|
||||||
|
|
||||||
|
@OneToOne(() => InvoiceBtcDetails, btcDetails => btcDetails.invoice, { cascade: true })
|
||||||
|
btcDetails: InvoiceBtcDetails | null;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
|
||||||
|
import { Invoice } from './Invoice';
|
||||||
|
|
||||||
|
@Entity('invoice_btc_details')
|
||||||
|
export class InvoiceBtcDetails {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@OneToOne(() => Invoice, invoice => invoice.btcDetails, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn()
|
||||||
|
invoice: Invoice;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 12,
|
||||||
|
scale: 2,
|
||||||
|
transformer: new ColumnNumericTransformer()
|
||||||
|
})
|
||||||
|
fiatPerBtcAtCreation: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
requiredConfirmations: number;
|
||||||
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import type { ConfigService } from '@nestjs/config';
|
import type { ConfigService } from '@nestjs/config';
|
||||||
import type { DataSource, EntityManager, Repository } from 'typeorm';
|
import type { DataSource, EntityManager, Repository } from 'typeorm';
|
||||||
|
import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
|
||||||
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
||||||
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
|
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
|
||||||
import { Invoice } from '../entities/Invoice';
|
import { Invoice } from '../entities/Invoice';
|
||||||
import { InvoicePayment } from '../entities/InvoicePayment';
|
import { InvoicePayment } from '../entities/InvoicePayment';
|
||||||
|
import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer';
|
||||||
import { PaymentMethod } from '../types/PaymentMethod';
|
import { PaymentMethod } from '../types/PaymentMethod';
|
||||||
import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest';
|
import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest';
|
||||||
import { InvoicePaymentService } from './InvoicePaymentService';
|
import { InvoicePaymentService } from './InvoicePaymentService';
|
||||||
|
|
||||||
const minIncomingAtomic = '100000000';
|
const minXmrIncomingAtomic = '100000000';
|
||||||
|
const minBtcIncomingAtomic = '7';
|
||||||
|
const xmrBelowMinIncomingAtomic = String(BigInt(minXmrIncomingAtomic) - 1n);
|
||||||
|
const btcBelowMinIncomingAtomic = String(BigInt(minBtcIncomingAtomic) - 1n);
|
||||||
|
|
||||||
const buildTransfer = (
|
const buildXmrTransfer = (
|
||||||
overrides: Partial<MoneroWalletRpcIncomingTransfer> = {}
|
overrides: Partial<MoneroWalletRpcIncomingTransfer> = {}
|
||||||
): MoneroWalletRpcIncomingTransfer => ({
|
): MoneroWalletRpcIncomingTransfer => ({
|
||||||
txHash: 'tx-hash-1',
|
txHash: 'tx-hash-1',
|
||||||
@@ -21,7 +26,14 @@ const buildTransfer = (
|
|||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
|
const buildBtcTransfer = (overrides: Partial<InvoiceIncomingTransfer> = {}): InvoiceIncomingTransfer => ({
|
||||||
|
txHash: 'tx-hash-1',
|
||||||
|
amountAtomic: '200000000',
|
||||||
|
confirmations: 1,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildXmrInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
|
||||||
({
|
({
|
||||||
id: 'invoice-1',
|
id: 'invoice-1',
|
||||||
paymentMethod: PaymentMethod.Xmr,
|
paymentMethod: PaymentMethod.Xmr,
|
||||||
@@ -30,6 +42,16 @@ const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
|
|||||||
...overrides
|
...overrides
|
||||||
}) as Invoice;
|
}) as Invoice;
|
||||||
|
|
||||||
|
const buildBtcInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
|
||||||
|
({
|
||||||
|
id: 'invoice-btc-1',
|
||||||
|
paymentMethod: PaymentMethod.Btc,
|
||||||
|
paymentAddress: 'bc1qtest',
|
||||||
|
btcDetails: { requiredConfirmations: 1 },
|
||||||
|
payments: [],
|
||||||
|
...overrides
|
||||||
|
}) as Invoice;
|
||||||
|
|
||||||
describe('InvoicePaymentService', () => {
|
describe('InvoicePaymentService', () => {
|
||||||
let service: InvoicePaymentServiceTest;
|
let service: InvoicePaymentServiceTest;
|
||||||
let invoiceRepo: {
|
let invoiceRepo: {
|
||||||
@@ -41,7 +63,11 @@ describe('InvoicePaymentService', () => {
|
|||||||
andWhere: jest.Mock;
|
andWhere: jest.Mock;
|
||||||
getMany: jest.Mock;
|
getMany: jest.Mock;
|
||||||
};
|
};
|
||||||
let walletRpcClient: {
|
let moneroWalletRpcClient: {
|
||||||
|
getIncomingTransfers: jest.Mock;
|
||||||
|
};
|
||||||
|
let bitcoinWalletRpcClient: {
|
||||||
|
getInfo: jest.Mock;
|
||||||
getIncomingTransfers: jest.Mock;
|
getIncomingTransfers: jest.Mock;
|
||||||
};
|
};
|
||||||
let configService: {
|
let configService: {
|
||||||
@@ -61,6 +87,7 @@ describe('InvoicePaymentService', () => {
|
|||||||
};
|
};
|
||||||
let paymentRepo: {
|
let paymentRepo: {
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
|
delete: jest.Mock;
|
||||||
createQueryBuilder: jest.Mock;
|
createQueryBuilder: jest.Mock;
|
||||||
};
|
};
|
||||||
let insertQueryBuilder: {
|
let insertQueryBuilder: {
|
||||||
@@ -107,6 +134,7 @@ describe('InvoicePaymentService', () => {
|
|||||||
|
|
||||||
paymentRepo = {
|
paymentRepo = {
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
delete: jest.fn().mockResolvedValue(undefined),
|
||||||
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
|
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,14 +158,20 @@ describe('InvoicePaymentService', () => {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
walletRpcClient = {
|
moneroWalletRpcClient = {
|
||||||
|
getIncomingTransfers: jest.fn().mockResolvedValue([])
|
||||||
|
};
|
||||||
|
|
||||||
|
bitcoinWalletRpcClient = {
|
||||||
|
getInfo: jest.fn().mockResolvedValue({ blockchain_height: 900_000 }),
|
||||||
getIncomingTransfers: jest.fn().mockResolvedValue([])
|
getIncomingTransfers: jest.fn().mockResolvedValue([])
|
||||||
};
|
};
|
||||||
|
|
||||||
configService = {
|
configService = {
|
||||||
get: jest.fn().mockReturnValue({
|
get: jest.fn().mockReturnValue({
|
||||||
minByMethod: {
|
minByMethod: {
|
||||||
[PaymentMethod.Xmr]: minIncomingAtomic
|
[PaymentMethod.Xmr]: minXmrIncomingAtomic,
|
||||||
|
[PaymentMethod.Btc]: minBtcIncomingAtomic
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -145,7 +179,8 @@ describe('InvoicePaymentService', () => {
|
|||||||
service = new InvoicePaymentService(
|
service = new InvoicePaymentService(
|
||||||
invoiceRepo as unknown as Repository<Invoice>,
|
invoiceRepo as unknown as Repository<Invoice>,
|
||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
walletRpcClient as unknown as MoneroWalletRpcClient,
|
moneroWalletRpcClient as unknown as MoneroWalletRpcClient,
|
||||||
|
bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient,
|
||||||
configService as unknown as ConfigService
|
configService as unknown as ConfigService
|
||||||
) as unknown as InvoicePaymentServiceTest;
|
) as unknown as InvoicePaymentServiceTest;
|
||||||
|
|
||||||
@@ -157,87 +192,184 @@ describe('InvoicePaymentService', () => {
|
|||||||
errorLogSpy.mockRestore();
|
errorLogSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pollInvoices', () => {
|
describe('pollMoneroInvoices', () => {
|
||||||
it('returns early when there are no open invoices', async () => {
|
it('returns early when there are no open invoices', async () => {
|
||||||
pollQueryBuilder.getMany.mockResolvedValue([]);
|
pollQueryBuilder.getMany.mockResolvedValue([]);
|
||||||
|
|
||||||
await service.pollInvoices();
|
await service.pollMoneroInvoices();
|
||||||
|
|
||||||
expect(walletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
|
expect(moneroWalletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
|
||||||
expect(processInvoiceSpy).not.toHaveBeenCalled();
|
expect(processInvoiceSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns early when incoming transfers cannot be fetched', async () => {
|
it('returns early when incoming transfers cannot be fetched', async () => {
|
||||||
pollQueryBuilder.getMany.mockResolvedValue([buildInvoice()]);
|
pollQueryBuilder.getMany.mockResolvedValue([buildXmrInvoice()]);
|
||||||
walletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down'));
|
moneroWalletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down'));
|
||||||
|
|
||||||
await service.pollInvoices();
|
await service.pollMoneroInvoices();
|
||||||
|
|
||||||
expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
|
expect(moneroWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
|
||||||
expect(processInvoiceSpy).not.toHaveBeenCalled();
|
expect(processInvoiceSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes transfers to each invoice by subaddress index', async () => {
|
it('routes transfers to each invoice by subaddress index', async () => {
|
||||||
pollQueryBuilder.getMany.mockResolvedValue([
|
pollQueryBuilder.getMany.mockResolvedValue([
|
||||||
buildInvoice({
|
buildXmrInvoice({
|
||||||
id: 'invoice-1',
|
id: 'invoice-1',
|
||||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||||
}),
|
}),
|
||||||
buildInvoice({
|
buildXmrInvoice({
|
||||||
id: 'invoice-2',
|
id: 'invoice-2',
|
||||||
moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
walletRpcClient.getIncomingTransfers.mockResolvedValue([
|
moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([
|
||||||
buildTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
|
buildXmrTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
|
||||||
buildTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
|
buildXmrTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await service.pollInvoices();
|
await service.pollMoneroInvoices();
|
||||||
|
|
||||||
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [
|
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [
|
||||||
expect.objectContaining({ txHash: 'tx-a', subaddrIndex: 3 })
|
expect.objectContaining({ txHash: 'tx-a' })
|
||||||
]);
|
]);
|
||||||
expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-2', [
|
expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-2', [
|
||||||
expect.objectContaining({ txHash: 'tx-b', subaddrIndex: 7 })
|
expect.objectContaining({ txHash: 'tx-b' })
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('continues processing other invoices when one invoice fails', async () => {
|
it('continues processing other invoices when one invoice fails', async () => {
|
||||||
pollQueryBuilder.getMany.mockResolvedValue([
|
pollQueryBuilder.getMany.mockResolvedValue([
|
||||||
buildInvoice({ id: 'invoice-1' }),
|
buildXmrInvoice({ id: 'invoice-1' }),
|
||||||
buildInvoice({ id: 'invoice-2' })
|
buildXmrInvoice({ id: 'invoice-2' })
|
||||||
]);
|
]);
|
||||||
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
|
moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildXmrTransfer()]);
|
||||||
processInvoiceSpy.mockRestore();
|
processInvoiceSpy.mockRestore();
|
||||||
processInvoiceSpy = jest
|
processInvoiceSpy = jest
|
||||||
.spyOn(service, 'processInvoice')
|
.spyOn(service, 'processInvoice')
|
||||||
.mockRejectedValueOnce(new Error('invoice-1 failed'))
|
.mockRejectedValueOnce(new Error('invoice-1 failed'))
|
||||||
.mockResolvedValueOnce(undefined);
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
await service.pollInvoices();
|
await service.pollMoneroInvoices();
|
||||||
|
|
||||||
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
|
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
it('deduplicates subaddress indices when fetching incoming transfers', async () => {
|
it('deduplicates subaddress indices when fetching incoming transfers', async () => {
|
||||||
pollQueryBuilder.getMany.mockResolvedValue([
|
pollQueryBuilder.getMany.mockResolvedValue([
|
||||||
buildInvoice({
|
buildXmrInvoice({
|
||||||
id: 'invoice-1',
|
id: 'invoice-1',
|
||||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||||
}),
|
}),
|
||||||
buildInvoice({
|
buildXmrInvoice({
|
||||||
id: 'invoice-2',
|
id: 'invoice-2',
|
||||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
|
moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildXmrTransfer()]);
|
||||||
|
|
||||||
await service.pollInvoices();
|
await service.pollMoneroInvoices();
|
||||||
|
|
||||||
expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
|
expect(moneroWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
|
||||||
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
|
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('processes invoices with no matching transfers as an empty batch', async () => {
|
||||||
|
pollQueryBuilder.getMany.mockResolvedValue([buildXmrInvoice({ id: 'invoice-1' })]);
|
||||||
|
moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([
|
||||||
|
buildXmrTransfer({ subaddrIndex: 7, txHash: 'tx-other' })
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.pollMoneroInvoices();
|
||||||
|
|
||||||
|
expect(processInvoiceSpy).toHaveBeenCalledWith('invoice-1', []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pollBitcoinInvoices', () => {
|
||||||
|
it('returns early when there are no open invoices', async () => {
|
||||||
|
pollQueryBuilder.getMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.pollBitcoinInvoices();
|
||||||
|
|
||||||
|
expect(bitcoinWalletRpcClient.getInfo).not.toHaveBeenCalled();
|
||||||
|
expect(processInvoiceSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns early when wallet info cannot be fetched', async () => {
|
||||||
|
pollQueryBuilder.getMany.mockResolvedValue([buildBtcInvoice()]);
|
||||||
|
bitcoinWalletRpcClient.getInfo.mockRejectedValue(new Error('rpc down'));
|
||||||
|
|
||||||
|
await service.pollBitcoinInvoices();
|
||||||
|
|
||||||
|
expect(bitcoinWalletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
|
||||||
|
expect(processInvoiceSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches transfers per invoice using the current block height', async () => {
|
||||||
|
pollQueryBuilder.getMany.mockResolvedValue([
|
||||||
|
buildBtcInvoice({ id: 'invoice-btc-1', paymentAddress: 'bc1qone' }),
|
||||||
|
buildBtcInvoice({ id: 'invoice-btc-2', paymentAddress: 'bc1qtwo' })
|
||||||
|
]);
|
||||||
|
bitcoinWalletRpcClient.getIncomingTransfers
|
||||||
|
.mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-a' })])
|
||||||
|
.mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-b' })]);
|
||||||
|
|
||||||
|
await service.pollBitcoinInvoices();
|
||||||
|
|
||||||
|
expect(bitcoinWalletRpcClient.getInfo).toHaveBeenCalled();
|
||||||
|
expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenNthCalledWith(1, 'bc1qone', 900_000);
|
||||||
|
expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenNthCalledWith(2, 'bc1qtwo', 900_000);
|
||||||
|
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-btc-1', [
|
||||||
|
expect.objectContaining({ txHash: 'tx-a' })
|
||||||
|
]);
|
||||||
|
expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-btc-2', [
|
||||||
|
expect.objectContaining({ txHash: 'tx-b' })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues processing other invoices when one invoice fails', async () => {
|
||||||
|
pollQueryBuilder.getMany.mockResolvedValue([
|
||||||
|
buildBtcInvoice({ id: 'invoice-btc-1' }),
|
||||||
|
buildBtcInvoice({ id: 'invoice-btc-2' })
|
||||||
|
]);
|
||||||
|
bitcoinWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildBtcTransfer()]);
|
||||||
|
processInvoiceSpy.mockRestore();
|
||||||
|
processInvoiceSpy = jest
|
||||||
|
.spyOn(service, 'processInvoice')
|
||||||
|
.mockRejectedValueOnce(new Error('invoice-btc-1 failed'))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
await service.pollBitcoinInvoices();
|
||||||
|
|
||||||
|
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues processing other invoices when incoming transfers cannot be fetched for one invoice', async () => {
|
||||||
|
pollQueryBuilder.getMany.mockResolvedValue([
|
||||||
|
buildBtcInvoice({ id: 'invoice-btc-1', paymentAddress: 'bc1qone' }),
|
||||||
|
buildBtcInvoice({ id: 'invoice-btc-2', paymentAddress: 'bc1qtwo' })
|
||||||
|
]);
|
||||||
|
bitcoinWalletRpcClient.getIncomingTransfers
|
||||||
|
.mockRejectedValueOnce(new Error('rpc down'))
|
||||||
|
.mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-b' })]);
|
||||||
|
|
||||||
|
await service.pollBitcoinInvoices();
|
||||||
|
|
||||||
|
expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenCalledTimes(2);
|
||||||
|
expect(processInvoiceSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(processInvoiceSpy).toHaveBeenCalledWith('invoice-btc-2', [
|
||||||
|
expect.objectContaining({ txHash: 'tx-b' })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes null block height when wallet info has no blockchain height', async () => {
|
||||||
|
pollQueryBuilder.getMany.mockResolvedValue([buildBtcInvoice({ paymentAddress: 'bc1qtest' })]);
|
||||||
|
bitcoinWalletRpcClient.getInfo.mockResolvedValue({ server_height: 900_000 });
|
||||||
|
|
||||||
|
await service.pollBitcoinInvoices();
|
||||||
|
|
||||||
|
expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith('bc1qtest', null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('processInvoice', () => {
|
describe('processInvoice', () => {
|
||||||
@@ -248,39 +380,70 @@ describe('InvoicePaymentService', () => {
|
|||||||
it('does nothing when the invoice is missing inside the transaction', async () => {
|
it('does nothing when the invoice is missing inside the transaction', async () => {
|
||||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null);
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null);
|
||||||
|
|
||||||
await service.processInvoice('invoice-1', [buildTransfer()]);
|
await service.processInvoice('invoice-1', [buildXmrTransfer()]);
|
||||||
|
|
||||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
expect(paymentRepo.update).not.toHaveBeenCalled();
|
expect(paymentRepo.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not mutate payments when there are no transfers and no existing payments', async () => {
|
||||||
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
|
||||||
|
|
||||||
|
await service.processInvoice('invoice-1', []);
|
||||||
|
|
||||||
|
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
|
expect(paymentRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(paymentRepo.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes unconfirmed payments that are no longer reported when transfers are empty', async () => {
|
||||||
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||||
|
buildBtcInvoice({
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-ghost',
|
||||||
|
txHash: 'ghost',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 0
|
||||||
|
} as InvoicePayment
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.processInvoice('invoice-btc-1', []);
|
||||||
|
|
||||||
|
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-ghost');
|
||||||
|
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
|
expect(paymentRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('skips transfers below the configured minimum', async () => {
|
it('skips transfers below the configured minimum', async () => {
|
||||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
|
||||||
|
|
||||||
await service.processInvoice('invoice-1', [
|
await service.processInvoice('invoice-1', [
|
||||||
buildTransfer({ amountAtomic: '99999999', txHash: 'dust-tx' })
|
buildXmrTransfer({ amountAtomic: xmrBelowMinIncomingAtomic, txHash: 'dust-tx' })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('inserts a payment when the transfer amount equals the configured minimum', async () => {
|
it('inserts a payment when the transfer amount equals the configured minimum', async () => {
|
||||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
|
||||||
|
|
||||||
await service.processInvoice('invoice-1', [
|
await service.processInvoice('invoice-1', [
|
||||||
buildTransfer({ txHash: 'min-tx', amountAtomic: minIncomingAtomic, confirmations: 1 })
|
buildXmrTransfer({ txHash: 'min-tx', amountAtomic: minXmrIncomingAtomic, confirmations: 1 })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
|
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
|
||||||
invoice: { id: 'invoice-1' },
|
invoice: { id: 'invoice-1' },
|
||||||
txHash: 'min-tx',
|
txHash: 'min-tx',
|
||||||
amountAtomic: minIncomingAtomic,
|
amountAtomic: minXmrIncomingAtomic,
|
||||||
confirmations: 1
|
confirmations: 1
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('processes a mixed batch of dust, new, and existing transfers', async () => {
|
it('processes a mixed batch of dust, new, and existing transfers', async () => {
|
||||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||||
buildInvoice({
|
buildXmrInvoice({
|
||||||
payments: [
|
payments: [
|
||||||
{
|
{
|
||||||
id: 'payment-1',
|
id: 'payment-1',
|
||||||
@@ -293,9 +456,9 @@ describe('InvoicePaymentService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await service.processInvoice('invoice-1', [
|
await service.processInvoice('invoice-1', [
|
||||||
buildTransfer({ txHash: 'dust-tx', amountAtomic: '99999999' }),
|
buildXmrTransfer({ txHash: 'dust-tx', amountAtomic: xmrBelowMinIncomingAtomic }),
|
||||||
buildTransfer({ txHash: 'known-tx', confirmations: 4 }),
|
buildXmrTransfer({ txHash: 'known-tx', confirmations: 4 }),
|
||||||
buildTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
|
buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 });
|
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 });
|
||||||
@@ -310,8 +473,8 @@ describe('InvoicePaymentService', () => {
|
|||||||
|
|
||||||
|
|
||||||
it('inserts a new payment for transfers at or above the minimum', async () => {
|
it('inserts a new payment for transfers at or above the minimum', async () => {
|
||||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
|
||||||
const transfer = buildTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
|
const transfer = buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
|
||||||
|
|
||||||
await service.processInvoice('invoice-1', [transfer]);
|
await service.processInvoice('invoice-1', [transfer]);
|
||||||
|
|
||||||
@@ -327,7 +490,7 @@ describe('InvoicePaymentService', () => {
|
|||||||
|
|
||||||
it('updates confirmations for an existing payment when they change', async () => {
|
it('updates confirmations for an existing payment when they change', async () => {
|
||||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||||
buildInvoice({
|
buildXmrInvoice({
|
||||||
payments: [
|
payments: [
|
||||||
{
|
{
|
||||||
id: 'payment-1',
|
id: 'payment-1',
|
||||||
@@ -339,7 +502,7 @@ describe('InvoicePaymentService', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 5 })]);
|
await service.processInvoice('invoice-1', [buildXmrTransfer({ txHash: 'known-tx', confirmations: 5 })]);
|
||||||
|
|
||||||
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 });
|
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 });
|
||||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
@@ -347,7 +510,7 @@ describe('InvoicePaymentService', () => {
|
|||||||
|
|
||||||
it('does not update an existing payment when confirmations are unchanged', async () => {
|
it('does not update an existing payment when confirmations are unchanged', async () => {
|
||||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||||
buildInvoice({
|
buildXmrInvoice({
|
||||||
payments: [
|
payments: [
|
||||||
{
|
{
|
||||||
id: 'payment-1',
|
id: 'payment-1',
|
||||||
@@ -359,10 +522,64 @@ describe('InvoicePaymentService', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 3 })]);
|
await service.processInvoice('invoice-1', [buildXmrTransfer({ txHash: 'known-tx', confirmations: 3 })]);
|
||||||
|
|
||||||
expect(paymentRepo.update).not.toHaveBeenCalled();
|
expect(paymentRepo.update).not.toHaveBeenCalled();
|
||||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips transfers below the configured minimum for bitcoin invoices', async () => {
|
||||||
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildBtcInvoice());
|
||||||
|
|
||||||
|
await service.processInvoice('invoice-btc-1', [
|
||||||
|
buildBtcTransfer({ amountAtomic: btcBelowMinIncomingAtomic, txHash: 'dust-tx' })
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts a payment when the transfer amount equals the configured minimum for bitcoin invoices', async () => {
|
||||||
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildBtcInvoice());
|
||||||
|
|
||||||
|
await service.processInvoice('invoice-btc-1', [
|
||||||
|
buildBtcTransfer({ txHash: 'min-tx', amountAtomic: minBtcIncomingAtomic, confirmations: 1 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
|
||||||
|
invoice: { id: 'invoice-btc-1' },
|
||||||
|
txHash: 'min-tx',
|
||||||
|
amountAtomic: minBtcIncomingAtomic,
|
||||||
|
confirmations: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes unconfirmed payments that are no longer reported', async () => {
|
||||||
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||||
|
buildBtcInvoice({
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-original',
|
||||||
|
txHash: 'original',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 0
|
||||||
|
} as InvoicePayment,
|
||||||
|
{
|
||||||
|
id: 'payment-replacement',
|
||||||
|
txHash: 'replacement',
|
||||||
|
amountAtomic: '50000',
|
||||||
|
confirmations: 3
|
||||||
|
} as InvoicePayment
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.processInvoice('invoice-btc-1', [
|
||||||
|
buildBtcTransfer({ txHash: 'replacement', amountAtomic: '50000', confirmations: 3 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-original');
|
||||||
|
expect(paymentRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import type { Config } from '../../../types/Config';
|
|||||||
import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex';
|
import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex';
|
||||||
import { isAtomicGte } from '../../../utils/atomic/isAtomicGte';
|
import { isAtomicGte } from '../../../utils/atomic/isAtomicGte';
|
||||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||||
|
import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
|
||||||
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
||||||
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
|
|
||||||
import { Invoice } from '../entities/Invoice';
|
import { Invoice } from '../entities/Invoice';
|
||||||
import { InvoicePayment } from '../entities/InvoicePayment';
|
import { InvoicePayment } from '../entities/InvoicePayment';
|
||||||
|
import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer';
|
||||||
import { PaymentMethod } from '../types/PaymentMethod';
|
import { PaymentMethod } from '../types/PaymentMethod';
|
||||||
|
import { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InvoicePaymentService {
|
export class InvoicePaymentService {
|
||||||
@@ -21,12 +23,17 @@ export class InvoicePaymentService {
|
|||||||
@InjectRepository(Invoice)
|
@InjectRepository(Invoice)
|
||||||
private readonly invoiceRepo: Repository<Invoice>,
|
private readonly invoiceRepo: Repository<Invoice>,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly walletRpcClient: MoneroWalletRpcClient,
|
private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
|
||||||
|
private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
|
||||||
private readonly configService: ConfigService
|
private readonly configService: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_10_SECONDS)
|
@Cron(CronExpression.EVERY_10_SECONDS)
|
||||||
private async pollInvoices(): Promise<void> {
|
private async pollInvoices(): Promise<void> {
|
||||||
|
await Promise.all([this.pollMoneroInvoices(), this.pollBitcoinInvoices()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollMoneroInvoices(): Promise<void> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const invoices = await this.invoiceRepo
|
const invoices = await this.invoiceRepo
|
||||||
@@ -55,7 +62,7 @@ export class InvoicePaymentService {
|
|||||||
let transfers: MoneroWalletRpcIncomingTransfer[];
|
let transfers: MoneroWalletRpcIncomingTransfer[];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
transfers = await this.walletRpcClient.getIncomingTransfers(subaddrIndices);
|
transfers = await this.moneroWalletRpcClient.getIncomingTransfers(subaddrIndices);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to fetch incoming Monero transfers: ${getErrorMessage(error)}`);
|
this.logger.error(`Failed to fetch incoming Monero transfers: ${getErrorMessage(error)}`);
|
||||||
|
|
||||||
@@ -77,9 +84,57 @@ export class InvoicePaymentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processInvoice(invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]): Promise<void> {
|
private async pollBitcoinInvoices(): Promise<void> {
|
||||||
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
|
const now = new Date();
|
||||||
|
|
||||||
|
const invoices = await this.invoiceRepo
|
||||||
|
.createQueryBuilder('invoice')
|
||||||
|
.innerJoinAndSelect('invoice.btcDetails', 'btcDetails')
|
||||||
|
.where('invoice.paymentMethod = :paymentMethod', { paymentMethod: PaymentMethod.Btc })
|
||||||
|
.andWhere(
|
||||||
|
new Brackets(qb => {
|
||||||
|
qb.where('invoice.expiresAt > :now', { now }).orWhere(
|
||||||
|
`"btcDetails"."requiredConfirmations" > 0 AND EXISTS (
|
||||||
|
SELECT 1 FROM invoice_payments pollPayment
|
||||||
|
WHERE pollPayment."invoiceId" = invoice.id
|
||||||
|
AND pollPayment.confirmations < "btcDetails"."requiredConfirmations"
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (invoices.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let blockHeight: number | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await this.bitcoinWalletRpcClient.getInfo();
|
||||||
|
|
||||||
|
blockHeight = info.blockchain_height ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to fetch Bitcoin wallet info: ${getErrorMessage(error)}`);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
try {
|
||||||
|
const transfers = await this.bitcoinWalletRpcClient.getIncomingTransfers(
|
||||||
|
invoice.paymentAddress,
|
||||||
|
blockHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.processInvoice(invoice.id, transfers);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to process invoice ${invoice.id}: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> {
|
||||||
await this.dataSource.transaction(async manager => {
|
await this.dataSource.transaction(async manager => {
|
||||||
const invoiceRepo = manager.getRepository(Invoice);
|
const invoiceRepo = manager.getRepository(Invoice);
|
||||||
const paymentRepo = manager.getRepository(InvoicePayment);
|
const paymentRepo = manager.getRepository(InvoicePayment);
|
||||||
@@ -95,36 +150,62 @@ export class InvoicePaymentService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minIncomingAtomic = minByMethod[invoice.paymentMethod];
|
await this.upsertIncomingPayments(paymentRepo, invoice, transfers);
|
||||||
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
|
|
||||||
|
|
||||||
for (const transfer of transfers) {
|
await this.pruneAbsentUnconfirmedPayments(paymentRepo, invoice, transfers);
|
||||||
const existing = knownByTxHash.get(transfer.txHash);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
if (existing.confirmations !== transfer.confirmations) {
|
|
||||||
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await paymentRepo
|
|
||||||
.createQueryBuilder()
|
|
||||||
.insert()
|
|
||||||
.values({
|
|
||||||
invoice: { id: invoiceId },
|
|
||||||
txHash: transfer.txHash,
|
|
||||||
amountAtomic: transfer.amountAtomic,
|
|
||||||
confirmations: transfer.confirmations
|
|
||||||
})
|
|
||||||
.orIgnore()
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async upsertIncomingPayments(
|
||||||
|
paymentRepo: Repository<InvoicePayment>,
|
||||||
|
invoice: Invoice,
|
||||||
|
transfers: InvoiceIncomingTransfer[]
|
||||||
|
): Promise<void> {
|
||||||
|
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
|
||||||
|
|
||||||
|
const minIncomingAtomic = minByMethod[invoice.paymentMethod];
|
||||||
|
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
|
||||||
|
|
||||||
|
for (const transfer of transfers) {
|
||||||
|
const existing = knownByTxHash.get(transfer.txHash);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.confirmations !== transfer.confirmations) {
|
||||||
|
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await paymentRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.values({
|
||||||
|
invoice: { id: invoice.id },
|
||||||
|
txHash: transfer.txHash,
|
||||||
|
amountAtomic: transfer.amountAtomic,
|
||||||
|
confirmations: transfer.confirmations
|
||||||
|
})
|
||||||
|
.orIgnore()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pruneAbsentUnconfirmedPayments(
|
||||||
|
paymentRepo: Repository<InvoicePayment>,
|
||||||
|
invoice: Invoice,
|
||||||
|
transfers: InvoiceIncomingTransfer[]
|
||||||
|
): Promise<void> {
|
||||||
|
const activeTxHashes = new Set(transfers.map(transfer => transfer.txHash));
|
||||||
|
|
||||||
|
for (const payment of invoice.payments ?? []) {
|
||||||
|
if (payment.confirmations === 0 && !activeTxHashes.has(payment.txHash)) {
|
||||||
|
await paymentRepo.delete(payment.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Logger, InternalServerErrorException, 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 { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
|
||||||
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';
|
||||||
@@ -19,11 +20,14 @@ describe('InvoiceService', () => {
|
|||||||
let configService: {
|
let configService: {
|
||||||
get: jest.Mock;
|
get: jest.Mock;
|
||||||
};
|
};
|
||||||
let walletRpcClient: {
|
let moneroWalletRpcClient: {
|
||||||
createAddress: jest.Mock;
|
createAddress: jest.Mock;
|
||||||
};
|
};
|
||||||
let xmrRateService: {
|
let bitcoinWalletRpcClient: {
|
||||||
getLiveFiatPerXmr: jest.Mock;
|
createAddress: jest.Mock;
|
||||||
|
};
|
||||||
|
let exchangeRateService: {
|
||||||
|
getLiveFiatPerCrypto: jest.Mock;
|
||||||
};
|
};
|
||||||
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
||||||
|
|
||||||
@@ -45,6 +49,10 @@ describe('InvoiceService', () => {
|
|||||||
return { confirmationTiers };
|
return { confirmationTiers };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'shopSettings.bitcoin') {
|
||||||
|
return { confirmationTiers };
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'order') {
|
if (key === 'order') {
|
||||||
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
|
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
|
||||||
}
|
}
|
||||||
@@ -53,22 +61,27 @@ describe('InvoiceService', () => {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
walletRpcClient = {
|
moneroWalletRpcClient = {
|
||||||
createAddress: jest.fn().mockResolvedValue({
|
createAddress: jest.fn().mockResolvedValue({
|
||||||
address: '4MoneroPaymentAddressExample',
|
address: '4MoneroPaymentAddressExample',
|
||||||
address_index: 12
|
address_index: 12
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
xmrRateService = {
|
bitcoinWalletRpcClient = {
|
||||||
getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
|
createAddress: jest.fn().mockResolvedValue('bc1qtestpaymentaddress')
|
||||||
|
};
|
||||||
|
|
||||||
|
exchangeRateService = {
|
||||||
|
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,
|
moneroWalletRpcClient as unknown as MoneroWalletRpcClient,
|
||||||
xmrRateService as unknown as XmrRateService
|
bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient,
|
||||||
|
exchangeRateService as unknown as ExchangeRateService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,26 +89,26 @@ describe('InvoiceService', () => {
|
|||||||
errorLogSpy.mockRestore();
|
errorLogSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
const issueCheckoutInvoice = () =>
|
const issueCheckoutInvoice = (paymentMethod: PaymentMethod = PaymentMethod.Xmr) =>
|
||||||
service.issueInvoice({
|
service.issueInvoice({
|
||||||
paymentMethod: PaymentMethod.Xmr,
|
paymentMethod,
|
||||||
reason: InvoiceReason.Checkout,
|
reason: InvoiceReason.Checkout,
|
||||||
contextId: 'session-uuid',
|
contextId: 'session-uuid',
|
||||||
amountFiat: 15
|
amountFiat: 15
|
||||||
});
|
});
|
||||||
|
|
||||||
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.")
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(walletRpcClient.createAddress).not.toHaveBeenCalled();
|
expect(moneroWalletRpcClient.createAddress).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws and logs when wallet address allocation fails for checkout invoices', async () => {
|
it('throws and logs when wallet address allocation fails for checkout invoices', async () => {
|
||||||
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
|
moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
|
||||||
|
|
||||||
await expect(issueCheckoutInvoice()).rejects.toThrow(
|
await expect(issueCheckoutInvoice()).rejects.toThrow(
|
||||||
new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.")
|
new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.")
|
||||||
@@ -108,7 +121,7 @@ describe('InvoiceService', () => {
|
|||||||
it('creates a checkout invoice with converted totals and monero details', async () => {
|
it('creates a checkout invoice with converted totals and monero details', async () => {
|
||||||
const invoice = await issueCheckoutInvoice();
|
const invoice = await issueCheckoutInvoice();
|
||||||
|
|
||||||
expect(walletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
|
expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
|
||||||
expect(invoiceRepo.create).toHaveBeenCalledWith(
|
expect(invoiceRepo.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
reason: InvoiceReason.Checkout,
|
reason: InvoiceReason.Checkout,
|
||||||
@@ -145,6 +158,10 @@ describe('InvoiceService', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'shopSettings.bitcoin') {
|
||||||
|
return { confirmationTiers };
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'order') {
|
if (key === 'order') {
|
||||||
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
|
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
|
||||||
}
|
}
|
||||||
@@ -169,7 +186,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,8 +201,8 @@ describe('InvoiceService', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
xmrRateService.getLiveFiatPerXmr.mockReturnValue(150);
|
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
|
||||||
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
|
moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.issueInvoice({
|
service.issueInvoice({
|
||||||
@@ -200,7 +217,7 @@ describe('InvoiceService', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
walletRpcClient.createAddress.mockResolvedValue({
|
moneroWalletRpcClient.createAddress.mockResolvedValue({
|
||||||
address: '4ShippingPaymentAddressExample',
|
address: '4ShippingPaymentAddressExample',
|
||||||
address_index: 3
|
address_index: 3
|
||||||
});
|
});
|
||||||
@@ -212,6 +229,87 @@ describe('InvoiceService', () => {
|
|||||||
amountFiat: 5
|
amountFiat: 5
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(walletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1');
|
expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the live BTC rate is unavailable for checkout invoices', async () => {
|
||||||
|
exchangeRateService.getLiveFiatPerCrypto.mockImplementation(
|
||||||
|
(method: PaymentMethod) => (method === PaymentMethod.Btc ? null : 150)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(issueCheckoutInvoice(PaymentMethod.Btc)).rejects.toThrow(
|
||||||
|
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bitcoinWalletRpcClient.createAddress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws and logs when Bitcoin address allocation fails for checkout invoices', async () => {
|
||||||
|
exchangeRateService.getLiveFiatPerCrypto.mockImplementation(
|
||||||
|
(method: PaymentMethod) => (method === PaymentMethod.Btc ? 60_000 : 150)
|
||||||
|
);
|
||||||
|
bitcoinWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
|
||||||
|
|
||||||
|
await expect(issueCheckoutInvoice(PaymentMethod.Btc)).rejects.toThrow(
|
||||||
|
new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errorLogSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to allocate Bitcoin payment address'));
|
||||||
|
expect(invoiceRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a checkout invoice with converted totals and bitcoin details', async () => {
|
||||||
|
exchangeRateService.getLiveFiatPerCrypto.mockImplementation(
|
||||||
|
(method: PaymentMethod) => (method === PaymentMethod.Btc ? 60_000 : 150)
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoice = await issueCheckoutInvoice(PaymentMethod.Btc);
|
||||||
|
|
||||||
|
expect(bitcoinWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
|
||||||
|
expect(invoiceRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: InvoiceReason.Checkout,
|
||||||
|
paymentMethod: PaymentMethod.Btc,
|
||||||
|
amountFiat: 15,
|
||||||
|
fiatCurrency: 'USD',
|
||||||
|
paymentAddress: 'bc1qtestpaymentaddress',
|
||||||
|
expectedTotalAtomic: '25000',
|
||||||
|
expiresAt: expect.any(Date),
|
||||||
|
btcDetails: {
|
||||||
|
fiatPerBtcAtCreation: 60_000,
|
||||||
|
requiredConfirmations: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(invoiceRepo.save).toHaveBeenCalled();
|
||||||
|
expect(invoice).toEqual(expect.objectContaining({ id: 'invoice-1', amountFiat: 15 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses BTC-specific shipping rate messages', async () => {
|
||||||
|
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.issueInvoice({
|
||||||
|
paymentMethod: PaymentMethod.Btc,
|
||||||
|
reason: InvoiceReason.Shipping,
|
||||||
|
contextId: 'order-1',
|
||||||
|
amountFiat: 5
|
||||||
|
})
|
||||||
|
).rejects.toThrow(
|
||||||
|
new ServiceUnavailableException(
|
||||||
|
"We can't quote shipping in BTC right now. Please try again in a few minutes."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for unsupported payment methods', async () => {
|
||||||
|
await expect(
|
||||||
|
service.issueInvoice({
|
||||||
|
paymentMethod: 'eth' as PaymentMethod,
|
||||||
|
reason: InvoiceReason.Checkout,
|
||||||
|
contextId: 'session-uuid',
|
||||||
|
amountFiat: 15
|
||||||
|
})
|
||||||
|
).rejects.toThrow(new InternalServerErrorException('Unsupported payment method: eth'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import dayjs from '../../../plugins/dayjs';
|
import dayjs from '../../../plugins/dayjs';
|
||||||
import type { Config } from '../../../types/Config';
|
import type { Config } from '../../../types/Config';
|
||||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||||
|
import { convertFiatToBtc } from '../../../utils/bitcoin/convertFiatToBtc';
|
||||||
|
import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic';
|
||||||
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
|
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/confirmation/resolveMinConfirmations';
|
||||||
|
import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
|
||||||
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';
|
||||||
@@ -24,14 +27,19 @@ export class InvoiceService {
|
|||||||
@InjectRepository(Invoice)
|
@InjectRepository(Invoice)
|
||||||
private readonly invoiceRepo: Repository<Invoice>,
|
private readonly invoiceRepo: Repository<Invoice>,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly walletRpcClient: MoneroWalletRpcClient,
|
private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
|
||||||
private readonly xmrRateService: XmrRateService
|
private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
|
||||||
|
private readonly exchangeRateService: ExchangeRateService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async issueInvoice(data: IssueInvoiceData): Promise<Invoice> {
|
async issueInvoice(data: IssueInvoiceData): Promise<Invoice> {
|
||||||
switch (data.paymentMethod) {
|
switch (data.paymentMethod) {
|
||||||
case PaymentMethod.Xmr:
|
case PaymentMethod.Xmr:
|
||||||
return this.issueXmrInvoice(data);
|
return this.issueXmrInvoice(data);
|
||||||
|
case PaymentMethod.Btc:
|
||||||
|
return this.issueBtcInvoice(data);
|
||||||
|
default:
|
||||||
|
throw new InternalServerErrorException(`Unsupported payment method: ${String(data.paymentMethod)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +49,11 @@ export class InvoiceService {
|
|||||||
|
|
||||||
const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
|
const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
|
||||||
reason,
|
reason,
|
||||||
contextId
|
contextId,
|
||||||
|
PaymentMethod.Xmr
|
||||||
);
|
);
|
||||||
|
|
||||||
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);
|
||||||
@@ -54,7 +63,7 @@ export class InvoiceService {
|
|||||||
let paymentAddressIndex: number;
|
let paymentAddressIndex: number;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { address, address_index } = await this.walletRpcClient.createAddress(addressLabel);
|
const { address, address_index } = await this.moneroWalletRpcClient.createAddress(addressLabel);
|
||||||
|
|
||||||
paymentAddress = address;
|
paymentAddress = address;
|
||||||
paymentAddressIndex = address_index;
|
paymentAddressIndex = address_index;
|
||||||
@@ -89,8 +98,63 @@ export class InvoiceService {
|
|||||||
return this.invoiceRepo.save(invoice);
|
return this.invoiceRepo.save(invoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveReasonData(reason: InvoiceReason, contextId: string): InvoiceReasonData {
|
private async issueBtcInvoice({ reason, contextId, amountFiat }: IssueInvoiceData): Promise<Invoice> {
|
||||||
|
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
|
||||||
|
const { confirmationTiers } = this.configService.get('shopSettings.bitcoin') as Config['shopSettings']['bitcoin'];
|
||||||
|
|
||||||
|
const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
|
||||||
|
reason,
|
||||||
|
contextId,
|
||||||
|
PaymentMethod.Btc
|
||||||
|
);
|
||||||
|
|
||||||
|
const fiatPerBtc = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Btc);
|
||||||
|
|
||||||
|
if (fiatPerBtc === null) {
|
||||||
|
throw new ServiceUnavailableException(rateUnavailableMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
let paymentAddress: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
paymentAddress = await this.bitcoinWalletRpcClient.createAddress(addressLabel);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to allocate Bitcoin payment address: ${getErrorMessage(error)}`);
|
||||||
|
|
||||||
|
throw new ServiceUnavailableException(unavailableMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredConfirmations = resolveMinConfirmations(amountFiat, confirmationTiers);
|
||||||
|
|
||||||
|
const expiresAt = dayjs().add(validityMs, 'millisecond').toDate();
|
||||||
|
|
||||||
|
const expectedTotalBtc = convertFiatToBtc(amountFiat, fiatPerBtc);
|
||||||
|
const expectedTotalAtomic = convertBtcToBtcAtomic(expectedTotalBtc);
|
||||||
|
|
||||||
|
const invoice = this.invoiceRepo.create({
|
||||||
|
reason,
|
||||||
|
paymentMethod: PaymentMethod.Btc,
|
||||||
|
amountFiat,
|
||||||
|
fiatCurrency: shopFiatCurrency,
|
||||||
|
expiresAt,
|
||||||
|
paymentAddress,
|
||||||
|
expectedTotalAtomic,
|
||||||
|
btcDetails: {
|
||||||
|
fiatPerBtcAtCreation: fiatPerBtc,
|
||||||
|
requiredConfirmations
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.invoiceRepo.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveReasonData(
|
||||||
|
reason: InvoiceReason,
|
||||||
|
contextId: string,
|
||||||
|
paymentMethod: PaymentMethod
|
||||||
|
): InvoiceReasonData {
|
||||||
const { checkoutValidityMs, shippingPaymentValidityMs } = this.configService.get('order') as Config['order'];
|
const { checkoutValidityMs, shippingPaymentValidityMs } = this.configService.get('order') as Config['order'];
|
||||||
|
const cryptoLabel = paymentMethod === PaymentMethod.Btc ? 'BTC' : 'XMR';
|
||||||
|
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case InvoiceReason.Checkout:
|
case InvoiceReason.Checkout:
|
||||||
@@ -105,8 +169,7 @@ export class InvoiceService {
|
|||||||
addressLabel: `order-shipping - ${contextId}`,
|
addressLabel: `order-shipping - ${contextId}`,
|
||||||
validityMs: shippingPaymentValidityMs,
|
validityMs: shippingPaymentValidityMs,
|
||||||
unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.",
|
unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.",
|
||||||
rateUnavailableMessage:
|
rateUnavailableMessage: `We can't quote shipping in ${cryptoLabel} right now. Please try again in a few minutes.`
|
||||||
"We can't quote shipping in XMR right now. Please try again in a few minutes."
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceSta
|
|||||||
|
|
||||||
export type InvoiceExtended = Omit<Invoice, 'payments'> & {
|
export type InvoiceExtended = Omit<Invoice, 'payments'> & {
|
||||||
statusLabel: InvoiceStatusLabel | null;
|
statusLabel: InvoiceStatusLabel | null;
|
||||||
|
paymentLabel: string;
|
||||||
expectedTotalCrypto: string;
|
expectedTotalCrypto: string;
|
||||||
payments: InvoicePaymentExtended[];
|
payments: InvoicePaymentExtended[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type InvoiceIncomingTransfer = {
|
||||||
|
txHash: string;
|
||||||
|
amountAtomic: string;
|
||||||
|
confirmations: number;
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
|
import type { InvoiceIncomingTransfer } from './InvoiceIncomingTransfer';
|
||||||
|
|
||||||
export type InvoicePaymentServiceTest = {
|
export type InvoicePaymentServiceTest = {
|
||||||
pollInvoices: () => Promise<void>;
|
pollInvoices: () => Promise<void>;
|
||||||
processInvoice: (invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]) => Promise<void>;
|
pollMoneroInvoices: () => Promise<void>;
|
||||||
|
pollBitcoinInvoices: () => Promise<void>;
|
||||||
|
processInvoice: (invoiceId: string, transfers: InvoiceIncomingTransfer[]) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export enum PaymentMethod {
|
export enum PaymentMethod {
|
||||||
Xmr = 'xmr'
|
Xmr = 'xmr',
|
||||||
|
Btc = 'btc'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { JwtGuard } from '../../../guards/JwtGuard';
|
|||||||
import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload';
|
import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload';
|
||||||
import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload';
|
import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload';
|
||||||
import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto';
|
import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto';
|
||||||
|
import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto';
|
||||||
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
|
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
|
||||||
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
|
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
|
||||||
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
|
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
|
||||||
@@ -43,6 +44,11 @@ export class ShopSettingsController {
|
|||||||
return this.shopSettingsService.updateNotifications(dto);
|
return this.shopSettingsService.updateNotifications(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('/blog-enabled')
|
||||||
|
updateBlogEnabled(@Body() dto: UpdateBlogEnabledDto) {
|
||||||
|
return this.shopSettingsService.updateBlogEnabled(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('/simplex-connect')
|
@Post('/simplex-connect')
|
||||||
connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) {
|
connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) {
|
||||||
return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink);
|
return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsBoolean, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateBlogEnabledDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsBoolean()
|
||||||
|
blogEnabled: boolean;
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ export class ShopSettings {
|
|||||||
@Column({ type: 'integer', nullable: true })
|
@Column({ type: 'integer', nullable: true })
|
||||||
simplexNotificationContactId: number | null;
|
simplexNotificationContactId: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
blogEnabled: boolean;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe('ShopSettingsService', () => {
|
|||||||
notifyOnNewOrder: false,
|
notifyOnNewOrder: false,
|
||||||
notifyOnOrderMessage: false,
|
notifyOnOrderMessage: false,
|
||||||
simplexNotificationContactId: null,
|
simplexNotificationContactId: null,
|
||||||
|
blogEnabled: false,
|
||||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-01-01T00:00:00.000Z')
|
updatedAt: new Date('2026-01-01T00:00:00.000Z')
|
||||||
} as ShopSettings;
|
} as ShopSettings;
|
||||||
@@ -67,10 +68,27 @@ describe('ShopSettingsService', () => {
|
|||||||
logoUrl: '/uploads/public/shop/logo.png',
|
logoUrl: '/uploads/public/shop/logo.png',
|
||||||
faviconUrl: null,
|
faviconUrl: null,
|
||||||
simplexLink: 'https://simplex.example',
|
simplexLink: 'https://simplex.example',
|
||||||
shippingNote: 'Ships in 3 days'
|
shippingNote: 'Ships in 3 days',
|
||||||
|
blogEnabled: false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates blog enabled flag', async () => {
|
||||||
|
await service.updateBlogEnabled({ blogEnabled: true });
|
||||||
|
|
||||||
|
expect(shopSettingsRepo.update).toHaveBeenCalledWith('settings-1', { blogEnabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes blog enabled in shop settings view', async () => {
|
||||||
|
shopSettingsRepo.find.mockResolvedValue([{ ...settings, blogEnabled: true }]);
|
||||||
|
|
||||||
|
await expect(service.getView()).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
blogEnabled: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('trims simplex links on update', async () => {
|
it('trims simplex links on update', async () => {
|
||||||
await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' });
|
await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' });
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getShopBrandingPublicUrl, resolveShopBrandingPath } from '../../../conf
|
|||||||
import { Config } from '../../../types/Config';
|
import { Config } from '../../../types/Config';
|
||||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||||
import { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
|
import { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
|
||||||
|
import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto';
|
||||||
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
|
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
|
||||||
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
|
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
|
||||||
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
|
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
|
||||||
@@ -43,7 +44,8 @@ export class ShopSettingsService {
|
|||||||
logoUrl,
|
logoUrl,
|
||||||
faviconUrl,
|
faviconUrl,
|
||||||
simplexLink: settings.simplexLink,
|
simplexLink: settings.simplexLink,
|
||||||
shippingNote: settings.shippingNote
|
shippingNote: settings.shippingNote,
|
||||||
|
blogEnabled: settings.blogEnabled
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +95,14 @@ export class ShopSettingsService {
|
|||||||
return this.getView();
|
return this.getView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateBlogEnabled({ blogEnabled }: UpdateBlogEnabledDto): Promise<ShopSettingsView> {
|
||||||
|
const settings = await this.findSettings();
|
||||||
|
|
||||||
|
await this.shopSettingsRepo.update(settings.id, { blogEnabled });
|
||||||
|
|
||||||
|
return this.getView();
|
||||||
|
}
|
||||||
|
|
||||||
async uploadLogo(filename: string): Promise<ShopSettingsView> {
|
async uploadLogo(filename: string): Promise<ShopSettingsView> {
|
||||||
const settings = await this.findSettings();
|
const settings = await this.findSettings();
|
||||||
|
|
||||||
@@ -191,12 +201,15 @@ export class ShopSettingsService {
|
|||||||
notifyOnNewOrder,
|
notifyOnNewOrder,
|
||||||
notifyOnOrderMessage,
|
notifyOnOrderMessage,
|
||||||
simplexNotificationContactId,
|
simplexNotificationContactId,
|
||||||
|
blogEnabled,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt
|
updatedAt
|
||||||
}: ShopSettings): ShopSettingsView {
|
}: ShopSettings): ShopSettingsView {
|
||||||
const setupChecklist = this.buildSetupChecklist({ logoStorageKey, faviconStorageKey, simplexLink, shippingNote });
|
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 logoUrl = logoStorageKey ? getShopBrandingPublicUrl(logoStorageKey) : null;
|
||||||
const faviconUrl = faviconStorageKey ? getShopBrandingPublicUrl(faviconStorageKey) : null;
|
const faviconUrl = faviconStorageKey ? getShopBrandingPublicUrl(faviconStorageKey) : null;
|
||||||
@@ -208,6 +221,7 @@ export class ShopSettingsService {
|
|||||||
shopName,
|
shopName,
|
||||||
shopFiatCurrency,
|
shopFiatCurrency,
|
||||||
monero,
|
monero,
|
||||||
|
bitcoin,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
faviconUrl,
|
faviconUrl,
|
||||||
simplexLink,
|
simplexLink,
|
||||||
@@ -217,6 +231,7 @@ export class ShopSettingsService {
|
|||||||
notifyOnNewOrder,
|
notifyOnNewOrder,
|
||||||
notifyOnOrderMessage,
|
notifyOnOrderMessage,
|
||||||
simplexNotificationConnected: simplexNotificationContactId !== null,
|
simplexNotificationConnected: simplexNotificationContactId !== null,
|
||||||
|
blogEnabled,
|
||||||
isSetupComplete,
|
isSetupComplete,
|
||||||
setupChecklist,
|
setupChecklist,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ConfirmationTier } from '../../../types/ConfirmationTier';
|
||||||
|
|
||||||
|
export interface ShopSettingsBitcoinView {
|
||||||
|
confirmationTiers: ConfirmationTier[];
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MoneroConfirmationTier } from '../../../types/MoneroConfirmationTier';
|
import { ConfirmationTier } from '../../../types/ConfirmationTier';
|
||||||
|
|
||||||
export interface ShopSettingsMoneroView {
|
export interface ShopSettingsMoneroView {
|
||||||
confirmationTiers: MoneroConfirmationTier[];
|
confirmationTiers: ConfirmationTier[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
|
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
|
||||||
import { SetupChecklist } from './SetupChecklist';
|
import { SetupChecklist } from './SetupChecklist';
|
||||||
import { ShopSettingsMoneroView } from './ShopSettingsMoneroView';
|
import { ShopSettingsMoneroView } from './ShopSettingsMoneroView';
|
||||||
|
import { ShopSettingsBitcoinView } from './ShopSettingsBitcoinView';
|
||||||
|
|
||||||
export interface ShopSettingsView {
|
export interface ShopSettingsView {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
shopName: string;
|
shopName: string;
|
||||||
shopFiatCurrency: ShopFiatCurrency;
|
shopFiatCurrency: ShopFiatCurrency;
|
||||||
monero: ShopSettingsMoneroView;
|
monero: ShopSettingsMoneroView;
|
||||||
|
bitcoin: ShopSettingsBitcoinView;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
simplexLink: string | null;
|
simplexLink: string | null;
|
||||||
@@ -16,6 +18,7 @@ export interface ShopSettingsView {
|
|||||||
notifyOnNewOrder: boolean;
|
notifyOnNewOrder: boolean;
|
||||||
notifyOnOrderMessage: boolean;
|
notifyOnOrderMessage: boolean;
|
||||||
simplexNotificationConnected: boolean;
|
simplexNotificationConnected: boolean;
|
||||||
|
blogEnabled: boolean;
|
||||||
isSetupComplete: boolean;
|
isSetupComplete: boolean;
|
||||||
setupChecklist: SetupChecklist;
|
setupChecklist: SetupChecklist;
|
||||||
createdAt: Date | null;
|
createdAt: Date | null;
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export interface StorefrontBranding {
|
|||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
simplexLink: string | null;
|
simplexLink: string | null;
|
||||||
shippingNote: string | null;
|
shippingNote: string | null;
|
||||||
|
blogEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BlogModule } from '../blog/BlogModule';
|
||||||
|
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
|
||||||
|
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
|
||||||
|
import { StorefrontBlogController } from './controllers/StorefrontBlogController';
|
||||||
|
import { StorefrontBlogPostsService } from './services/StorefrontBlogPostsService';
|
||||||
|
import { StorefrontBlogViewService } from './services/StorefrontBlogViewService';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [BlogModule, ShopSettingsModule, StorefrontCoreModule],
|
||||||
|
controllers: [StorefrontBlogController],
|
||||||
|
providers: [StorefrontBlogPostsService, StorefrontBlogViewService]
|
||||||
|
})
|
||||||
|
export class StorefrontBlogModule {}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Req, Res, UseFilters } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
||||||
|
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
|
||||||
|
import { StorefrontShopViewService } from '../../storefrontCore/services/StorefrontShopViewService';
|
||||||
|
import { StorefrontBlogPostsService } from '../services/StorefrontBlogPostsService';
|
||||||
|
import { StorefrontBlogViewService } from '../services/StorefrontBlogViewService';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@UseFilters(StorefrontExceptionFilter)
|
||||||
|
export class StorefrontBlogController {
|
||||||
|
constructor(
|
||||||
|
private readonly blogPostsService: StorefrontBlogPostsService,
|
||||||
|
private readonly blogViewService: StorefrontBlogViewService,
|
||||||
|
private readonly shopViewService: StorefrontShopViewService,
|
||||||
|
private readonly shopSettingsService: ShopSettingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('shop/blog')
|
||||||
|
async blogIndex(@Req() req: Request, @Res() res: Response) {
|
||||||
|
const { blogEnabled } = await this.shopSettingsService.getStorefrontBranding();
|
||||||
|
|
||||||
|
if (!blogEnabled) {
|
||||||
|
throw new NotFoundException('We could not find that page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [posts, shopLocals] = await Promise.all([
|
||||||
|
this.blogPostsService.listLivePosts(),
|
||||||
|
this.shopViewService.buildShopRenderLocals(req, res, {
|
||||||
|
title: 'Blog',
|
||||||
|
metaDescription: 'News and updates from {shopName}.'
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const postsView = this.blogViewService.toPostsIndexView(posts);
|
||||||
|
|
||||||
|
return res.render('blog-index', {
|
||||||
|
posts: postsView,
|
||||||
|
...shopLocals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('shop/blog/:id')
|
||||||
|
async blogDetail(@Param('id', ParseUUIDPipe) id: string, @Req() req: Request, @Res() res: Response) {
|
||||||
|
const { blogEnabled } = await this.shopSettingsService.getStorefrontBranding();
|
||||||
|
|
||||||
|
if (!blogEnabled) {
|
||||||
|
throw new NotFoundException('We could not find that page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await this.blogPostsService.getLivePost(id);
|
||||||
|
|
||||||
|
const postView = this.blogViewService.toPostDetailView(post);
|
||||||
|
|
||||||
|
const shopLocals = await this.shopViewService.buildShopRenderLocals(req, res, {
|
||||||
|
title: post.title,
|
||||||
|
metaDescription: `Read ${post.title} at {shopName}.`,
|
||||||
|
ogType: 'article',
|
||||||
|
ogImageUrl: postView.bannerUrl ?? postView.thumbnailUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.render('blog-detail', {
|
||||||
|
post: postView,
|
||||||
|
backHref: '/shop/blog',
|
||||||
|
backLabel: 'Blog',
|
||||||
|
...shopLocals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import type { Repository } from 'typeorm';
|
||||||
|
import { BlogPost } from '../../blog/entities/BlogPost';
|
||||||
|
import { StorefrontBlogPostsService } from './StorefrontBlogPostsService';
|
||||||
|
|
||||||
|
describe('StorefrontBlogPostsService', () => {
|
||||||
|
let service: StorefrontBlogPostsService;
|
||||||
|
let blogPostRepo: {
|
||||||
|
find: jest.Mock;
|
||||||
|
findOne: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
blogPostRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn().mockResolvedValue(null)
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new StorefrontBlogPostsService(blogPostRepo as unknown as Repository<BlogPost>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists live posts ordered by createdAt descending', async () => {
|
||||||
|
await service.listLivePosts();
|
||||||
|
|
||||||
|
expect(blogPostRepo.find).toHaveBeenCalledWith({
|
||||||
|
where: { isDraft: false },
|
||||||
|
order: { createdAt: 'DESC' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a live post by id', async () => {
|
||||||
|
const post = { id: 'post-1', title: 'Hello', isDraft: false } as BlogPost;
|
||||||
|
|
||||||
|
blogPostRepo.findOne.mockResolvedValue(post);
|
||||||
|
|
||||||
|
await expect(service.getLivePost('post-1')).resolves.toBe(post);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when a draft or missing post is requested', async () => {
|
||||||
|
await expect(service.getLivePost('missing-id')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BlogPost } from '../../blog/entities/BlogPost';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorefrontBlogPostsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BlogPost)
|
||||||
|
private readonly blogPostRepo: Repository<BlogPost>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listLivePosts(): Promise<BlogPost[]> {
|
||||||
|
return this.blogPostRepo.find({
|
||||||
|
where: { isDraft: false },
|
||||||
|
order: { createdAt: 'DESC' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLivePost(id: string): Promise<BlogPost> {
|
||||||
|
const post = await this.blogPostRepo.findOne({
|
||||||
|
where: { id, isDraft: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
throw new NotFoundException('We could not find that blog post.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { BlogPost } from '../../blog/entities/BlogPost';
|
||||||
|
import { StorefrontBlogViewService } from './StorefrontBlogViewService';
|
||||||
|
|
||||||
|
describe('StorefrontBlogViewService', () => {
|
||||||
|
let service: StorefrontBlogViewService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new StorefrontBlogViewService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps posts to card views with image urls', () => {
|
||||||
|
const views = service.toPostsIndexView([
|
||||||
|
{
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Hello',
|
||||||
|
thumbnailStorageKey: 'thumb.webp',
|
||||||
|
bannerStorageKey: null,
|
||||||
|
contentHtml: '',
|
||||||
|
isDraft: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
} as BlogPost
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(views).toEqual([
|
||||||
|
{
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Hello',
|
||||||
|
thumbnailUrl: '/uploads/public/blog/images/thumb.webp',
|
||||||
|
detailHref: '/shop/blog/post-1'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { getBlogImagePublicUrl } from '../../../config/uploadPaths';
|
||||||
|
import { BlogPost } from '../../blog/entities/BlogPost';
|
||||||
|
import type { StorefrontBlogPostCardView } from '../types/StorefrontBlogPostCardView';
|
||||||
|
import type { StorefrontBlogPostDetailView } from '../types/StorefrontBlogPostDetailView';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorefrontBlogViewService {
|
||||||
|
toPostsIndexView(posts: BlogPost[]): StorefrontBlogPostCardView[] {
|
||||||
|
return posts.map(post => ({
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
|
||||||
|
detailHref: `/shop/blog/${post.id}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
toPostDetailView(post: BlogPost): StorefrontBlogPostDetailView {
|
||||||
|
return {
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null,
|
||||||
|
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
|
||||||
|
contentHtml: post.contentHtml
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface StorefrontBlogPostCardView {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
detailHref: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface StorefrontBlogPostDetailView {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
bannerUrl: string | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
contentHtml: string;
|
||||||
|
}
|
||||||
@@ -3,14 +3,13 @@ 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 { 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],
|
||||||
controllers: [StorefrontCartController],
|
controllers: [StorefrontCartController],
|
||||||
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
|
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
|
||||||
exports: [StorefrontCartService]
|
exports: [StorefrontCartService]
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { throttleProfiles } from '../../../config/throttleProfiles';
|
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||||
|
import type { Config } from '../../../types/Config';
|
||||||
|
import { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
|
||||||
|
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
|
||||||
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
|
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
|
||||||
import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService';
|
import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService';
|
||||||
import { StorefrontCheckoutSessionCookieService } from '../../storefrontCore/services/StorefrontCheckoutSessionCookieService';
|
import { StorefrontCheckoutSessionCookieService } from '../../storefrontCore/services/StorefrontCheckoutSessionCookieService';
|
||||||
@@ -33,7 +37,8 @@ export class StorefrontCartController {
|
|||||||
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
|
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
|
||||||
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService,
|
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService,
|
||||||
private readonly captchaService: StorefrontCaptchaService,
|
private readonly captchaService: StorefrontCaptchaService,
|
||||||
private readonly captchaCookieService: StorefrontCaptchaCookieService
|
private readonly captchaCookieService: StorefrontCaptchaCookieService,
|
||||||
|
private readonly configService: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('shop/cart')
|
@Get('shop/cart')
|
||||||
@@ -62,9 +67,18 @@ export class StorefrontCartController {
|
|||||||
|
|
||||||
this.captchaCookieService.setAnswer(req, res, encryptedAnswer);
|
this.captchaCookieService.setAnswer(req, res, encryptedAnswer);
|
||||||
|
|
||||||
|
const { enabledPaymentMethods } = this.configService.get('shopSettings') as Config['shopSettings'];
|
||||||
|
|
||||||
|
const paymentMethods = enabledPaymentMethods.map(paymentMethod => ({
|
||||||
|
paymentMethod,
|
||||||
|
paymentMethodLabel: paymentMethodLabel[paymentMethod],
|
||||||
|
iconUrl: paymentMethodIconUrl[paymentMethod]
|
||||||
|
}));
|
||||||
|
|
||||||
return res.render('cart-summary', {
|
return res.render('cart-summary', {
|
||||||
...summary,
|
...summary,
|
||||||
...shopLocals,
|
...shopLocals,
|
||||||
|
paymentMethods,
|
||||||
captchaSvg
|
captchaSvg
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -75,6 +89,10 @@ export class StorefrontCartController {
|
|||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@Body() { variantId, qty }: AddToCartDto
|
@Body() { variantId, qty }: AddToCartDto
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
|
||||||
|
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
|
||||||
|
}
|
||||||
|
|
||||||
const cart = this.cartCookieService.getCart(req, res);
|
const cart = this.cartCookieService.getCart(req, res);
|
||||||
|
|
||||||
const cartMutation = await this.cartService.addToCart(cart, variantId, qty);
|
const cartMutation = await this.cartService.addToCart(cart, variantId, qty);
|
||||||
@@ -86,7 +104,7 @@ export class StorefrontCartController {
|
|||||||
text: 'Added to cart.'
|
text: 'Added to cart.'
|
||||||
});
|
});
|
||||||
|
|
||||||
res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req));
|
res.redirect(HttpStatus.FOUND, this.buildAddToCartRedirectPath(req, variantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('shop/cart/product/update')
|
@Post('shop/cart/product/update')
|
||||||
@@ -95,6 +113,10 @@ export class StorefrontCartController {
|
|||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@Body() { variantId, qty }: UpdateCartQtyDto
|
@Body() { variantId, qty }: UpdateCartQtyDto
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
|
||||||
|
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
|
||||||
|
}
|
||||||
|
|
||||||
const cart = this.cartCookieService.getCart(req, res);
|
const cart = this.cartCookieService.getCart(req, res);
|
||||||
|
|
||||||
const cartMutation = await this.cartService.updateCartQty(cart, variantId, qty);
|
const cartMutation = await this.cartService.updateCartQty(cart, variantId, qty);
|
||||||
@@ -111,6 +133,10 @@ export class StorefrontCartController {
|
|||||||
|
|
||||||
@Post('shop/cart/product/remove')
|
@Post('shop/cart/product/remove')
|
||||||
removeFromCart(@Req() req: Request, @Res() res: Response, @Body() { variantId }: RemoveFromCartDto): void {
|
removeFromCart(@Req() req: Request, @Res() res: Response, @Body() { variantId }: RemoveFromCartDto): void {
|
||||||
|
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
|
||||||
|
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
|
||||||
|
}
|
||||||
|
|
||||||
const cart = this.cartCookieService.getCart(req, res);
|
const cart = this.cartCookieService.getCart(req, res);
|
||||||
|
|
||||||
const cartMutation = this.cartService.removeFromCart(cart, variantId);
|
const cartMutation = this.cartService.removeFromCart(cart, variantId);
|
||||||
@@ -129,6 +155,10 @@ export class StorefrontCartController {
|
|||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@Body() { code }: ApplyDiscountCodeDto
|
@Body() { code }: ApplyDiscountCodeDto
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
|
||||||
|
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
|
||||||
|
}
|
||||||
|
|
||||||
const cart = this.cartCookieService.getCart(req, res);
|
const cart = this.cartCookieService.getCart(req, res);
|
||||||
const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
|
const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
|
||||||
|
|
||||||
@@ -146,6 +176,10 @@ export class StorefrontCartController {
|
|||||||
|
|
||||||
@Post('shop/cart/discount/remove')
|
@Post('shop/cart/discount/remove')
|
||||||
removeDiscount(@Req() req: Request, @Res() res: Response, @Body() { code }: RemoveDiscountCodeDto): void {
|
removeDiscount(@Req() req: Request, @Res() res: Response, @Body() { code }: RemoveDiscountCodeDto): void {
|
||||||
|
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
|
||||||
|
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
|
||||||
|
}
|
||||||
|
|
||||||
const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
|
const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
|
||||||
const discountMutation = this.discountService.removeDiscountCode(discountCodes, code);
|
const discountMutation = this.discountService.removeDiscountCode(discountCodes, code);
|
||||||
|
|
||||||
@@ -175,4 +209,18 @@ export class StorefrontCartController {
|
|||||||
this.cartCookieService.setCart(req, res, cartMutation);
|
this.cartCookieService.setCart(req, res, cartMutation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildAddToCartRedirectPath(req: Request, variantId: string): string {
|
||||||
|
const redirectPath = safeInternalShopRedirectPath(req);
|
||||||
|
const pathname = redirectPath.split('?')[0] ?? redirectPath;
|
||||||
|
const isProductsIndexPath = pathname === '/' || pathname.startsWith('/shop/categories/');
|
||||||
|
|
||||||
|
if (!isProductsIndexPath) {
|
||||||
|
return redirectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = `product-card-${variantId}`;
|
||||||
|
|
||||||
|
return `${redirectPath}#${anchor}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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 { 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,9 +28,6 @@ describe('StorefrontCartService', () => {
|
|||||||
getStorefrontVariantsByIds: jest.Mock;
|
getStorefrontVariantsByIds: jest.Mock;
|
||||||
getStorefrontVariant: jest.Mock;
|
getStorefrontVariant: jest.Mock;
|
||||||
};
|
};
|
||||||
let xmrRateService: {
|
|
||||||
getLiveFiatPerXmr: jest.Mock;
|
|
||||||
};
|
|
||||||
let discountService: {
|
let discountService: {
|
||||||
getDiscountStateForCart: jest.Mock;
|
getDiscountStateForCart: jest.Mock;
|
||||||
applyDiscountCode: jest.Mock;
|
applyDiscountCode: jest.Mock;
|
||||||
@@ -43,10 +39,6 @@ describe('StorefrontCartService', () => {
|
|||||||
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
|
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
|
||||||
};
|
};
|
||||||
|
|
||||||
xmrRateService = {
|
|
||||||
getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
|
|
||||||
};
|
|
||||||
|
|
||||||
discountService = {
|
discountService = {
|
||||||
getDiscountStateForCart: jest.fn().mockResolvedValue({
|
getDiscountStateForCart: jest.fn().mockResolvedValue({
|
||||||
discounts: [],
|
discounts: [],
|
||||||
@@ -58,7 +50,6 @@ describe('StorefrontCartService', () => {
|
|||||||
|
|
||||||
service = new StorefrontCartService(
|
service = new StorefrontCartService(
|
||||||
productsService as unknown as StorefrontProductsService,
|
productsService as unknown as StorefrontProductsService,
|
||||||
xmrRateService as unknown as XmrRateService,
|
|
||||||
discountService as unknown as StorefrontDiscountService
|
discountService as unknown as StorefrontDiscountService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -98,8 +89,6 @@ describe('StorefrontCartService', () => {
|
|||||||
discounts: [],
|
discounts: [],
|
||||||
cartDiscountTotal: 0,
|
cartDiscountTotal: 0,
|
||||||
cartTotalPrice: 0,
|
cartTotalPrice: 0,
|
||||||
cartTotalXmr: null,
|
|
||||||
fiatPerXmr: null,
|
|
||||||
hasManualLines: false,
|
hasManualLines: false,
|
||||||
hasAutoLines: false,
|
hasAutoLines: false,
|
||||||
cartTotalIssueMessage: null,
|
cartTotalIssueMessage: null,
|
||||||
@@ -107,23 +96,13 @@ describe('StorefrontCartService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts the discounted total to XMR when a live rate is available', async () => {
|
it('returns cart totals and delivery flags', async () => {
|
||||||
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
|
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
|
||||||
|
|
||||||
expect(summary.cartSubtotal).toBe(10);
|
expect(summary.cartSubtotal).toBe(10);
|
||||||
expect(summary.cartTotalPrice).toBe(10);
|
expect(summary.cartTotalPrice).toBe(10);
|
||||||
expect(summary.fiatPerXmr).toBe(150);
|
|
||||||
expect(summary.cartTotalXmr).toBe('0.06666667');
|
|
||||||
expect(summary.hasAutoLines).toBe(true);
|
expect(summary.hasAutoLines).toBe(true);
|
||||||
});
|
expect(summary.hasIssues).toBe(false);
|
||||||
|
|
||||||
it('leaves cartTotalXmr null when no live rate is available', async () => {
|
|
||||||
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
|
|
||||||
|
|
||||||
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
|
|
||||||
|
|
||||||
expect(summary.cartTotalXmr).toBeNull();
|
|
||||||
expect(summary.fiatPerXmr).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flags manual and auto delivery lines separately', async () => {
|
it('flags manual and auto delivery lines separately', async () => {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ 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 { 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';
|
||||||
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
|
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
|
||||||
@@ -22,7 +20,6 @@ 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 discountService: StorefrontDiscountService
|
private readonly discountService: StorefrontDiscountService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -66,8 +63,6 @@ export class StorefrontCartService {
|
|||||||
discounts: [],
|
discounts: [],
|
||||||
cartDiscountTotal: 0,
|
cartDiscountTotal: 0,
|
||||||
cartTotalPrice: 0,
|
cartTotalPrice: 0,
|
||||||
cartTotalXmr: null,
|
|
||||||
fiatPerXmr: null,
|
|
||||||
hasManualLines: false,
|
hasManualLines: false,
|
||||||
hasAutoLines: false,
|
hasAutoLines: false,
|
||||||
cartTotalIssueMessage: null,
|
cartTotalIssueMessage: null,
|
||||||
@@ -77,8 +72,6 @@ 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 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);
|
||||||
const cartTotalIssueMessage = getZeroCartTotalIssue(discountState.cartTotalPrice, cartExtended.length > 0);
|
const cartTotalIssueMessage = getZeroCartTotalIssue(discountState.cartTotalPrice, cartExtended.length > 0);
|
||||||
@@ -91,8 +84,6 @@ export class StorefrontCartService {
|
|||||||
cartExtended,
|
cartExtended,
|
||||||
cartSubtotal,
|
cartSubtotal,
|
||||||
...discountState,
|
...discountState,
|
||||||
cartTotalXmr,
|
|
||||||
fiatPerXmr,
|
|
||||||
hasManualLines,
|
hasManualLines,
|
||||||
hasAutoLines,
|
hasAutoLines,
|
||||||
cartTotalIssueMessage,
|
cartTotalIssueMessage,
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import type { CookieCartExtended } from './CookieCartExtended';
|
|||||||
export type CookieCartSummary = {
|
export type CookieCartSummary = {
|
||||||
cartExtended: CookieCartExtended;
|
cartExtended: CookieCartExtended;
|
||||||
cartSubtotal: number;
|
cartSubtotal: number;
|
||||||
cartTotalXmr: string | null;
|
|
||||||
fiatPerXmr: number | null;
|
|
||||||
hasManualLines: boolean;
|
hasManualLines: boolean;
|
||||||
hasAutoLines: boolean;
|
hasAutoLines: boolean;
|
||||||
cartTotalIssueMessage: string | null;
|
cartTotalIssueMessage: string | null;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class StorefrontCheckoutController {
|
|||||||
|
|
||||||
@Post('shop/checkout/pay')
|
@Post('shop/checkout/pay')
|
||||||
@Throttle(throttleProfiles.checkoutPay)
|
@Throttle(throttleProfiles.checkoutPay)
|
||||||
async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha }: PayCheckoutDto): Promise<void> {
|
async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha, paymentMethod }: PayCheckoutDto): Promise<void> {
|
||||||
const sessionId = this.checkoutSessionCookieService.getSessionId(req, res);
|
const sessionId = this.checkoutSessionCookieService.getSessionId(req, res);
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@@ -64,7 +64,7 @@ export class StorefrontCheckoutController {
|
|||||||
|
|
||||||
const summary = await this.cartService.getCartSummary(cart, discountCodes);
|
const summary = await this.cartService.getCartSummary(cart, discountCodes);
|
||||||
|
|
||||||
const session = await this.checkoutSessionService.createFromCartSummary(summary);
|
const session = await this.checkoutSessionService.createFromCartSummary(summary, paymentMethod);
|
||||||
|
|
||||||
this.checkoutSessionCookieService.setSessionId(req, res, session.id);
|
this.checkoutSessionCookieService.setSessionId(req, res, session.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { IsNotEmpty, IsString, Length } from 'class-validator';
|
import { IsEnum, IsIn, IsNotEmpty, IsString, Length } from 'class-validator';
|
||||||
import { getAppConfig } from '../../../config';
|
import { getAppConfig, getShopSettingsConfig } from '../../../config';
|
||||||
|
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
captcha: { length: captchaLength }
|
captcha: { length: captchaLength }
|
||||||
} = getAppConfig();
|
} = getAppConfig();
|
||||||
|
|
||||||
|
const { enabledPaymentMethods } = getShopSettingsConfig();
|
||||||
|
|
||||||
export class PayCheckoutDto {
|
export class PayCheckoutDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -12,4 +15,9 @@ export class PayCheckoutDto {
|
|||||||
message: `Captcha should be ${captchaLength} characters long`
|
message: `Captcha should be ${captchaLength} characters long`
|
||||||
})
|
})
|
||||||
captcha: string;
|
captcha: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(PaymentMethod)
|
||||||
|
@IsIn(enabledPaymentMethods, { message: 'Select a valid payment method' })
|
||||||
|
paymentMethod: PaymentMethod;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user