Compare commits
70
Commits
7b62bc41ad
..
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 |
+14
-4
@@ -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
|
||||||
|
|
||||||
@@ -89,7 +95,7 @@ BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate
|
|||||||
|
|
||||||
PAYMENT_METHODS_ENABLED=xmr,btc
|
PAYMENT_METHODS_ENABLED=xmr,btc
|
||||||
|
|
||||||
MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]'
|
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
|
||||||
@@ -103,7 +109,7 @@ 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)
|
MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment)
|
||||||
|
|
||||||
BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":1},{"upToTotalFiat":"300","minConfirmations":3},{"minConfirmations":6}]'
|
BITCOIN_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)
|
BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment)
|
||||||
|
|
||||||
ELECTRUM_VERSION=4.8.1
|
ELECTRUM_VERSION=4.8.1
|
||||||
@@ -132,13 +138,16 @@ 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
|
||||||
@@ -146,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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} 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';
|
||||||
@@ -37,6 +38,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron
|
|||||||
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
|
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
|
||||||
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
|
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
|
||||||
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
|
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
|
||||||
|
import { StorefrontBlogModule } from './modules/storefrontBlog/StorefrontBlogModule';
|
||||||
import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
|
import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
|
||||||
import { Config } from './types/Config';
|
import { Config } from './types/Config';
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ import { Config } from './types/Config';
|
|||||||
HealthCheckModule,
|
HealthCheckModule,
|
||||||
EncryptionModule,
|
EncryptionModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
BlogModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
ShopSettingsModule,
|
ShopSettingsModule,
|
||||||
SimplexModule,
|
SimplexModule,
|
||||||
@@ -99,7 +102,8 @@ import { Config } from './types/Config';
|
|||||||
StorefrontProductModule,
|
StorefrontProductModule,
|
||||||
StorefrontCartModule,
|
StorefrontCartModule,
|
||||||
StorefrontCheckoutModule,
|
StorefrontCheckoutModule,
|
||||||
StorefrontOrderModule
|
StorefrontOrderModule,
|
||||||
|
StorefrontBlogModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -97,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')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -126,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(',')
|
||||||
@@ -218,6 +231,7 @@ export const getElectrumWalletConfig = (): ElectrumWalletConfig => ({
|
|||||||
rpcUrl: `http://${env('ELECTRUM_DAEMON_HOST')}:${envInt('ELECTRUM_DAEMON_PORT')}`,
|
rpcUrl: `http://${env('ELECTRUM_DAEMON_HOST')}:${envInt('ELECTRUM_DAEMON_PORT')}`,
|
||||||
username: env('ELECTRUM_DAEMON_RPC_USER'),
|
username: env('ELECTRUM_DAEMON_RPC_USER'),
|
||||||
password: env('ELECTRUM_DAEMON_RPC_PASSWORD'),
|
password: env('ELECTRUM_DAEMON_RPC_PASSWORD'),
|
||||||
|
walletPassword: env('ELECTRUM_WALLET_PASSWORD'),
|
||||||
rpcTimeoutMs: envInt('ELECTRUM_DAEMON_RPC_TIMEOUT_MS')
|
rpcTimeoutMs: envInt('ELECTRUM_DAEMON_RPC_TIMEOUT_MS')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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/';
|
||||||
|
|||||||
@@ -75,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;
|
||||||
@@ -101,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)
|
||||||
@@ -153,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)
|
||||||
@@ -374,6 +396,10 @@ class EnvironmentVariables {
|
|||||||
@Min(1000)
|
@Min(1000)
|
||||||
ELECTRUM_DAEMON_RPC_TIMEOUT_MS: number;
|
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,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,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 },
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/AuthModule';
|
||||||
import { BitcoinWalletController } from './controllers/BitcoinWalletController';
|
import { BitcoinWalletController } from './controllers/BitcoinWalletController';
|
||||||
import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService';
|
import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService';
|
||||||
import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient';
|
import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient';
|
||||||
import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService';
|
import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
controllers: [BitcoinWalletController],
|
controllers: [BitcoinWalletController],
|
||||||
providers: [ElectrumWalletRpcClient, ElectrumWalletRpcConnectionService, BitcoinWalletAdminService],
|
providers: [ElectrumWalletRpcClient, ElectrumWalletRpcConnectionService, BitcoinWalletAdminService],
|
||||||
exports: [ElectrumWalletRpcClient]
|
exports: [ElectrumWalletRpcClient]
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
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';
|
import { BitcoinWalletAdminService } from '../services/BitcoinWalletAdminService';
|
||||||
|
|
||||||
@Controller('bitcoin-wallet')
|
@Controller('bitcoin-wallet')
|
||||||
@@ -11,4 +15,16 @@ export class BitcoinWalletController {
|
|||||||
getStatus() {
|
getStatus() {
|
||||||
return this.walletAdminService.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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import type { ConfigService } from '@nestjs/config';
|
import type { ConfigService } from '@nestjs/config';
|
||||||
import { BitcoinWalletSyncStatus } from '../types/BitcoinWalletSyncStatus';
|
import type { AuthService } from '../../auth/services/AuthService';
|
||||||
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||||
import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
|
import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
|
||||||
|
|
||||||
@@ -11,6 +12,11 @@ describe('BitcoinWalletAdminService', () => {
|
|||||||
isSynchronized: jest.Mock;
|
isSynchronized: jest.Mock;
|
||||||
getInfo: jest.Mock;
|
getInfo: jest.Mock;
|
||||||
getBalance: jest.Mock;
|
getBalance: jest.Mock;
|
||||||
|
sweepAll: jest.Mock;
|
||||||
|
getSeed: jest.Mock;
|
||||||
|
};
|
||||||
|
let authService: {
|
||||||
|
verifyPassword: jest.Mock;
|
||||||
};
|
};
|
||||||
let configService: {
|
let configService: {
|
||||||
get: jest.Mock;
|
get: jest.Mock;
|
||||||
@@ -27,7 +33,16 @@ describe('BitcoinWalletAdminService', () => {
|
|||||||
getBalance: jest.fn().mockResolvedValue({
|
getBalance: jest.fn().mockResolvedValue({
|
||||||
balanceAtomic: '150000',
|
balanceAtomic: '150000',
|
||||||
confirmedBalanceAtomic: '150000'
|
confirmedBalanceAtomic: '150000'
|
||||||
})
|
}),
|
||||||
|
sweepAll: jest.fn().mockResolvedValue({
|
||||||
|
txHash: 'tx-hash-1',
|
||||||
|
amountAtomic: '140000'
|
||||||
|
}),
|
||||||
|
getSeed: jest.fn().mockResolvedValue('seed words')
|
||||||
|
};
|
||||||
|
|
||||||
|
authService = {
|
||||||
|
verifyPassword: jest.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
configService = {
|
configService = {
|
||||||
@@ -38,6 +53,7 @@ describe('BitcoinWalletAdminService', () => {
|
|||||||
|
|
||||||
service = new BitcoinWalletAdminService(
|
service = new BitcoinWalletAdminService(
|
||||||
walletRpcClient as unknown as ElectrumWalletRpcClient,
|
walletRpcClient as unknown as ElectrumWalletRpcClient,
|
||||||
|
authService as unknown as AuthService,
|
||||||
configService as unknown as ConfigService
|
configService as unknown as ConfigService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -51,7 +67,7 @@ describe('BitcoinWalletAdminService', () => {
|
|||||||
rpcVersion: '4.8.1',
|
rpcVersion: '4.8.1',
|
||||||
blockHeight: 900_000,
|
blockHeight: 900_000,
|
||||||
serverHeight: 900_000,
|
serverHeight: 900_000,
|
||||||
syncStatus: BitcoinWalletSyncStatus.Synced,
|
syncStatus: WalletSyncStatus.Synced,
|
||||||
balanceBtc: '0.00150000',
|
balanceBtc: '0.00150000',
|
||||||
confirmedBalanceBtc: '0.00150000'
|
confirmedBalanceBtc: '0.00150000'
|
||||||
})
|
})
|
||||||
@@ -63,7 +79,7 @@ describe('BitcoinWalletAdminService', () => {
|
|||||||
|
|
||||||
const status = await service.getStatus();
|
const status = await service.getStatus();
|
||||||
|
|
||||||
expect(status.syncStatus).toBe(BitcoinWalletSyncStatus.Syncing);
|
expect(status.syncStatus).toBe(WalletSyncStatus.Syncing);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when RPC calls fail', async () => {
|
it('throws when RPC calls fail', async () => {
|
||||||
@@ -71,4 +87,61 @@ describe('BitcoinWalletAdminService', () => {
|
|||||||
|
|
||||||
await expect(service.getStatus()).rejects.toBeInstanceOf(ServiceUnavailableException);
|
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.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from '../../auth/services/AuthService';
|
||||||
import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc';
|
import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc';
|
||||||
import type { Config } from '../../../types/Config';
|
import type { Config } from '../../../types/Config';
|
||||||
import { BitcoinWalletSyncStatus } from '../types/BitcoinWalletSyncStatus';
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
|
import type { BitcoinWalletRevealSeedResult } from '../types/BitcoinWalletRevealSeedResult';
|
||||||
import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView';
|
import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView';
|
||||||
|
import type { BitcoinWalletWithdrawResult } from '../types/BitcoinWalletWithdrawResult';
|
||||||
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BitcoinWalletAdminService {
|
export class BitcoinWalletAdminService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly walletRpcClient: ElectrumWalletRpcClient,
|
private readonly walletRpcClient: ElectrumWalletRpcClient,
|
||||||
|
private readonly authService: AuthService,
|
||||||
private readonly configService: ConfigService
|
private readonly configService: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -43,19 +47,77 @@ export class BitcoinWalletAdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
private resolveSyncStatus(
|
||||||
isSynchronized: boolean,
|
isSynchronized: boolean,
|
||||||
blockHeight: number | null,
|
blockHeight: number | null,
|
||||||
serverHeight: number | null
|
serverHeight: number | null
|
||||||
): BitcoinWalletSyncStatus {
|
): WalletSyncStatus {
|
||||||
if (isSynchronized) {
|
if (isSynchronized) {
|
||||||
return BitcoinWalletSyncStatus.Synced;
|
return WalletSyncStatus.Synced;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blockHeight === null || serverHeight === null) {
|
if (blockHeight === null || serverHeight === null) {
|
||||||
return BitcoinWalletSyncStatus.Unknown;
|
return WalletSyncStatus.Unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BitcoinWalletSyncStatus.Syncing;
|
return WalletSyncStatus.Syncing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
rpcUrl: 'http://electrum.test:7777',
|
rpcUrl: 'http://electrum.test:7777',
|
||||||
username: 'electrum',
|
username: 'electrum',
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
|
walletPassword: 'wallet-secret',
|
||||||
rpcTimeoutMs: 5000
|
rpcTimeoutMs: 5000
|
||||||
})
|
})
|
||||||
} as unknown as ConfigService);
|
} as unknown as ConfigService);
|
||||||
@@ -33,13 +34,17 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
tx_hash: 'abc123',
|
tx_hash: 'abc123',
|
||||||
height: 800_000
|
height: 800_000
|
||||||
},
|
},
|
||||||
'50000',
|
{
|
||||||
|
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
||||||
|
},
|
||||||
|
'bc1qtest',
|
||||||
800_002
|
800_002
|
||||||
)
|
)
|
||||||
).toEqual({
|
).toEqual({
|
||||||
txHash: 'abc123',
|
txHash: 'abc123',
|
||||||
amountAtomic: '50000',
|
amountAtomic: '50000',
|
||||||
confirmations: 3
|
confirmations: 3,
|
||||||
|
inputOutpoints: []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,13 +55,17 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
tx_hash: 'abc123',
|
tx_hash: 'abc123',
|
||||||
height: 0
|
height: 0
|
||||||
},
|
},
|
||||||
'50000',
|
{
|
||||||
|
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
|
||||||
|
},
|
||||||
|
'bc1qtest',
|
||||||
800_002
|
800_002
|
||||||
)
|
)
|
||||||
).toEqual({
|
).toEqual({
|
||||||
txHash: 'abc123',
|
txHash: 'abc123',
|
||||||
amountAtomic: '50000',
|
amountAtomic: '50000',
|
||||||
confirmations: 0
|
confirmations: 0,
|
||||||
|
inputOutpoints: []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,17 +76,67 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
tx_hash: 'abc123',
|
tx_hash: 'abc123',
|
||||||
height: 800_000
|
height: 800_000
|
||||||
},
|
},
|
||||||
'0',
|
{
|
||||||
|
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
|
||||||
|
},
|
||||||
|
'bc1qtest',
|
||||||
800_002
|
800_002
|
||||||
)
|
)
|
||||||
).toBeNull();
|
).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('sumIncomingOutputValueAtomic', () => {
|
describe('sumOutputValueAtomic', () => {
|
||||||
it('sums outputs paying to the target address', () => {
|
it('sums outputs paying to the target address', () => {
|
||||||
expect(
|
expect(
|
||||||
clientTest.sumIncomingOutputValueAtomic(
|
clientTest.sumOutputValueAtomic(
|
||||||
{
|
{
|
||||||
outputs: [
|
outputs: [
|
||||||
{ address: 'bc1qother', value_sats: 10_000 },
|
{ address: 'bc1qother', value_sats: 10_000 },
|
||||||
@@ -92,7 +151,7 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
|
|
||||||
it('returns zero when no outputs match the address', () => {
|
it('returns zero when no outputs match the address', () => {
|
||||||
expect(
|
expect(
|
||||||
clientTest.sumIncomingOutputValueAtomic(
|
clientTest.sumOutputValueAtomic(
|
||||||
{
|
{
|
||||||
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
|
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
|
||||||
},
|
},
|
||||||
@@ -124,6 +183,7 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: 'nullcart',
|
id: 'nullcart',
|
||||||
result: {
|
result: {
|
||||||
|
inputs: [{ prevout_hash: 'input123', prevout_n: 0 }],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ address: 'bc1qother', value_sats: 10_000 },
|
{ address: 'bc1qother', value_sats: 10_000 },
|
||||||
{ address: 'bc1qtest', value_sats: 50_000 }
|
{ address: 'bc1qtest', value_sats: 50_000 }
|
||||||
@@ -136,7 +196,8 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
{
|
{
|
||||||
txHash: 'abc123',
|
txHash: 'abc123',
|
||||||
amountAtomic: '50000',
|
amountAtomic: '50000',
|
||||||
confirmations: 3
|
confirmations: 3,
|
||||||
|
inputOutpoints: ['input123:0']
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -163,6 +224,105 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
expect.any(Object)
|
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', () => {
|
describe('createAddress', () => {
|
||||||
@@ -208,4 +368,80 @@ describe('ElectrumWalletRpcClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,6 +88,85 @@ export class ElectrumWalletRpcClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
async createAddress(label?: string): Promise<string> {
|
||||||
const address = await this.call<string>('createnewaddress');
|
const address = await this.call<string>('createnewaddress');
|
||||||
|
|
||||||
@@ -120,40 +199,27 @@ export class ElectrumWalletRpcClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
|
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
|
||||||
|
const transaction = await this.deserializeTransaction(serializedTransaction);
|
||||||
|
|
||||||
const transaction = await this.call<ElectrumWalletDeserializedTransaction>('deserialize', {
|
return this.mapIncomingTransfer(entry, transaction, address, blockHeight);
|
||||||
tx: serializedTransaction
|
|
||||||
});
|
|
||||||
|
|
||||||
const amountAtomic = this.sumIncomingOutputValueAtomic(transaction, address);
|
|
||||||
|
|
||||||
return this.mapIncomingTransfer(entry, amountAtomic, blockHeight);
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null);
|
const resolvedTransfers = transfers.filter(
|
||||||
}
|
(transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null
|
||||||
|
);
|
||||||
|
|
||||||
private sumIncomingOutputValueAtomic(transaction: ElectrumWalletDeserializedTransaction, address: string): string {
|
return this.filterSupersededBitcoinTransfers(resolvedTransfers);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapIncomingTransfer(
|
private mapIncomingTransfer(
|
||||||
entry: ElectrumWalletAddressHistoryEntry,
|
entry: ElectrumWalletAddressHistoryEntry,
|
||||||
amountAtomic: string,
|
transaction: ElectrumWalletDeserializedTransaction,
|
||||||
|
address: string,
|
||||||
blockHeight: number | null
|
blockHeight: number | null
|
||||||
): ElectrumWalletIncomingTransfer | null {
|
): ElectrumWalletIncomingTransfer | null {
|
||||||
const txHash = entry.tx_hash;
|
const txHash = entry.tx_hash;
|
||||||
|
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
|
||||||
|
|
||||||
if (!txHash || amountAtomic === '0') {
|
if (!txHash || amountAtomic === '0') {
|
||||||
return null;
|
return null;
|
||||||
@@ -165,7 +231,60 @@ export class ElectrumWalletRpcClient {
|
|||||||
return {
|
return {
|
||||||
txHash,
|
txHash,
|
||||||
amountAtomic,
|
amountAtomic,
|
||||||
confirmations
|
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,3 @@
|
|||||||
|
export interface BitcoinWalletRevealSeedResult {
|
||||||
|
mnemonic: string;
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ElectrumNetwork } from '../../../types/ElectrumNetwork';
|
import { ElectrumNetwork } from '../../../types/ElectrumNetwork';
|
||||||
import { BitcoinWalletSyncStatus } from './BitcoinWalletSyncStatus';
|
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||||
|
|
||||||
export interface BitcoinWalletStatusView {
|
export interface BitcoinWalletStatusView {
|
||||||
network: ElectrumNetwork;
|
network: ElectrumNetwork;
|
||||||
rpcVersion: string;
|
rpcVersion: string;
|
||||||
blockHeight: number | null;
|
blockHeight: number | null;
|
||||||
serverHeight: number | null;
|
serverHeight: number | null;
|
||||||
syncStatus: BitcoinWalletSyncStatus;
|
syncStatus: WalletSyncStatus;
|
||||||
balanceBtc: string;
|
balanceBtc: string;
|
||||||
confirmedBalanceBtc: string;
|
confirmedBalanceBtc: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export enum BitcoinWalletSyncStatus {
|
|
||||||
Synced = 'synced',
|
|
||||||
Syncing = 'syncing',
|
|
||||||
Unknown = 'unknown'
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface BitcoinWalletWithdrawResult {
|
||||||
|
txHash: string;
|
||||||
|
amountBtc: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
export type ElectrumWalletDeserializedOutput = {
|
import type { ElectrumWalletDeserializedInput } from './ElectrumWalletDeserializedInput';
|
||||||
address?: string;
|
import type { ElectrumWalletDeserializedOutput } from './ElectrumWalletDeserializedOutput';
|
||||||
value_sats: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ElectrumWalletDeserializedTransaction = {
|
export type ElectrumWalletDeserializedTransaction = {
|
||||||
|
inputs?: ElectrumWalletDeserializedInput[];
|
||||||
outputs: ElectrumWalletDeserializedOutput[];
|
outputs: ElectrumWalletDeserializedOutput[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export type ElectrumWalletIncomingTransfer = {
|
|||||||
txHash: string;
|
txHash: string;
|
||||||
amountAtomic: string;
|
amountAtomic: string;
|
||||||
confirmations: number;
|
confirmations: number;
|
||||||
|
inputOutpoints: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTra
|
|||||||
export type ElectrumWalletRpcClientTest = {
|
export type ElectrumWalletRpcClientTest = {
|
||||||
mapIncomingTransfer: (
|
mapIncomingTransfer: (
|
||||||
entry: ElectrumWalletAddressHistoryEntry,
|
entry: ElectrumWalletAddressHistoryEntry,
|
||||||
amountAtomic: string,
|
transaction: ElectrumWalletDeserializedTransaction,
|
||||||
|
address: string,
|
||||||
blockHeight: number | null
|
blockHeight: number | null
|
||||||
) => ElectrumWalletIncomingTransfer | null;
|
) => ElectrumWalletIncomingTransfer | null;
|
||||||
sumIncomingOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
|
filterSupersededBitcoinTransfers: (
|
||||||
|
transfers: ElectrumWalletIncomingTransfer[]
|
||||||
|
) => ElectrumWalletIncomingTransfer[];
|
||||||
|
sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => 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;
|
||||||
|
};
|
||||||
@@ -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,16 +254,12 @@ 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
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!shippingInvoice) {
|
|
||||||
throw new InternalServerErrorException('Failed to issue shipping invoice');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.orderRepo.update(orderId, {
|
await this.orderRepo.update(orderId, {
|
||||||
shippingInvoice: { id: shippingInvoice.id },
|
shippingInvoice: { id: shippingInvoice.id },
|
||||||
quotedAt
|
quotedAt
|
||||||
|
|||||||
@@ -87,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: {
|
||||||
@@ -133,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)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -384,13 +386,35 @@ describe('InvoicePaymentService', () => {
|
|||||||
expect(paymentRepo.update).not.toHaveBeenCalled();
|
expect(paymentRepo.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing when there are no transfers to process', async () => {
|
it('does not mutate payments when there are no transfers and no existing payments', async () => {
|
||||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
|
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
|
||||||
|
|
||||||
await service.processInvoice('invoice-1', []);
|
await service.processInvoice('invoice-1', []);
|
||||||
|
|
||||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
expect(paymentRepo.update).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 () => {
|
||||||
@@ -528,5 +552,34 @@ describe('InvoicePaymentService', () => {
|
|||||||
confirmations: 1
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,8 +135,6 @@ export class InvoicePaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> {
|
private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> {
|
||||||
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -152,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,4 +1,4 @@
|
|||||||
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 { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
|
||||||
@@ -301,4 +301,15 @@ describe('InvoiceService', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,4 +1,4 @@
|
|||||||
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';
|
||||||
@@ -32,12 +32,14 @@ export class InvoiceService {
|
|||||||
private readonly exchangeRateService: ExchangeRateService
|
private readonly exchangeRateService: ExchangeRateService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async issueInvoice(data: IssueInvoiceData): Promise<Invoice | undefined> {
|
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:
|
case PaymentMethod.Btc:
|
||||||
return this.issueBtcInvoice(data);
|
return this.issueBtcInvoice(data);
|
||||||
|
default:
|
||||||
|
throw new InternalServerErrorException(`Unsupported payment method: ${String(data.paymentMethod)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,6 +201,7 @@ export class ShopSettingsService {
|
|||||||
notifyOnNewOrder,
|
notifyOnNewOrder,
|
||||||
notifyOnOrderMessage,
|
notifyOnOrderMessage,
|
||||||
simplexNotificationContactId,
|
simplexNotificationContactId,
|
||||||
|
blogEnabled,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt
|
updatedAt
|
||||||
}: ShopSettings): ShopSettingsView {
|
}: ShopSettings): ShopSettingsView {
|
||||||
@@ -220,6 +231,7 @@ export class ShopSettingsService {
|
|||||||
notifyOnNewOrder,
|
notifyOnNewOrder,
|
||||||
notifyOnOrderMessage,
|
notifyOnOrderMessage,
|
||||||
simplexNotificationConnected: simplexNotificationContactId !== null,
|
simplexNotificationConnected: simplexNotificationContactId !== null,
|
||||||
|
blogEnabled,
|
||||||
isSetupComplete,
|
isSetupComplete,
|
||||||
setupChecklist,
|
setupChecklist,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
|||||||
@@ -18,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 { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
|
|
||||||
import { StorefrontCartController } from './controllers/StorefrontCartController';
|
import { StorefrontCartController } from './controllers/StorefrontCartController';
|
||||||
import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver';
|
import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver';
|
||||||
import { StorefrontCartService } from './services/StorefrontCartService';
|
import { StorefrontCartService } from './services/StorefrontCartService';
|
||||||
import { StorefrontDiscountService } from './services/StorefrontDiscountService';
|
import { StorefrontDiscountService } from './services/StorefrontDiscountService';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, ExchangeRateModule],
|
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,8 +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 { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
|
||||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
|
||||||
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
|
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
|
||||||
import type { StorefrontDiscountService } from './StorefrontDiscountService';
|
import type { StorefrontDiscountService } from './StorefrontDiscountService';
|
||||||
import { StorefrontCartService } from './StorefrontCartService';
|
import { StorefrontCartService } from './StorefrontCartService';
|
||||||
@@ -30,9 +28,6 @@ describe('StorefrontCartService', () => {
|
|||||||
getStorefrontVariantsByIds: jest.Mock;
|
getStorefrontVariantsByIds: jest.Mock;
|
||||||
getStorefrontVariant: jest.Mock;
|
getStorefrontVariant: jest.Mock;
|
||||||
};
|
};
|
||||||
let exchangeRateService: {
|
|
||||||
getLiveFiatPerCrypto: jest.Mock;
|
|
||||||
};
|
|
||||||
let discountService: {
|
let discountService: {
|
||||||
getDiscountStateForCart: jest.Mock;
|
getDiscountStateForCart: jest.Mock;
|
||||||
applyDiscountCode: jest.Mock;
|
applyDiscountCode: jest.Mock;
|
||||||
@@ -44,10 +39,6 @@ describe('StorefrontCartService', () => {
|
|||||||
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
|
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
|
||||||
};
|
};
|
||||||
|
|
||||||
exchangeRateService = {
|
|
||||||
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
|
|
||||||
};
|
|
||||||
|
|
||||||
discountService = {
|
discountService = {
|
||||||
getDiscountStateForCart: jest.fn().mockResolvedValue({
|
getDiscountStateForCart: jest.fn().mockResolvedValue({
|
||||||
discounts: [],
|
discounts: [],
|
||||||
@@ -59,7 +50,6 @@ describe('StorefrontCartService', () => {
|
|||||||
|
|
||||||
service = new StorefrontCartService(
|
service = new StorefrontCartService(
|
||||||
productsService as unknown as StorefrontProductsService,
|
productsService as unknown as StorefrontProductsService,
|
||||||
exchangeRateService as unknown as ExchangeRateService,
|
|
||||||
discountService as unknown as StorefrontDiscountService
|
discountService as unknown as StorefrontDiscountService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -99,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,
|
||||||
@@ -108,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 () => {
|
|
||||||
exchangeRateService.getLiveFiatPerCrypto.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,9 +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 { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
|
||||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
|
||||||
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';
|
||||||
@@ -23,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 exchangeRateService: ExchangeRateService,
|
|
||||||
private readonly discountService: StorefrontDiscountService
|
private readonly discountService: StorefrontDiscountService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -67,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,
|
||||||
@@ -78,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.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
|
|
||||||
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);
|
||||||
@@ -92,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class CheckoutPaymentPollerService {
|
|||||||
.createQueryBuilder('session')
|
.createQueryBuilder('session')
|
||||||
.innerJoinAndSelect('session.invoice', 'invoice')
|
.innerJoinAndSelect('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.cancelledAt IS NULL')
|
.where('session.cancelledAt IS NULL')
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ const buildSummary = (overrides: Partial<CookieCartSummary> = {}): CookieCartSum
|
|||||||
discounts: [{ code: 'SAVE1', amount: 1, issueMessage: null }],
|
discounts: [{ code: 'SAVE1', amount: 1, issueMessage: null }],
|
||||||
cartDiscountTotal: 1,
|
cartDiscountTotal: 1,
|
||||||
cartTotalPrice: 9,
|
cartTotalPrice: 9,
|
||||||
cartTotalXmr: '0.06000000',
|
|
||||||
fiatPerXmr: 150,
|
|
||||||
hasManualLines: false,
|
hasManualLines: false,
|
||||||
hasAutoLines: true,
|
hasAutoLines: true,
|
||||||
cartTotalIssueMessage: null,
|
cartTotalIssueMessage: null,
|
||||||
@@ -115,15 +113,15 @@ describe('CheckoutSessionService', () => {
|
|||||||
|
|
||||||
describe('createFromCartSummary', () => {
|
describe('createFromCartSummary', () => {
|
||||||
it('rejects an empty cart', async () => {
|
it('rejects an empty cart', async () => {
|
||||||
await expect(service.createFromCartSummary(buildSummary({ cartExtended: [] }))).rejects.toThrow(
|
await expect(
|
||||||
new BadRequestException('Your cart is empty')
|
service.createFromCartSummary(buildSummary({ cartExtended: [] }), PaymentMethod.Xmr)
|
||||||
);
|
).rejects.toThrow(new BadRequestException('Your cart is empty'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects carts that still have unresolved issues', async () => {
|
it('rejects carts that still have unresolved issues', async () => {
|
||||||
await expect(service.createFromCartSummary(buildSummary({ hasIssues: true }))).rejects.toThrow(
|
await expect(
|
||||||
new BadRequestException('Resolve cart issues before paying')
|
service.createFromCartSummary(buildSummary({ hasIssues: true }), PaymentMethod.Xmr)
|
||||||
);
|
).rejects.toThrow(new BadRequestException('Resolve cart issues before paying'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a session, invoice, lines, and valid discounts from the cart summary', async () => {
|
it('creates a session, invoice, lines, and valid discounts from the cart summary', async () => {
|
||||||
@@ -135,7 +133,7 @@ describe('CheckoutSessionService', () => {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await service.createFromCartSummary(summary);
|
const session = await service.createFromCartSummary(summary, PaymentMethod.Xmr);
|
||||||
|
|
||||||
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
|
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
|
||||||
paymentMethod: PaymentMethod.Xmr,
|
paymentMethod: PaymentMethod.Xmr,
|
||||||
@@ -180,7 +178,8 @@ describe('CheckoutSessionService', () => {
|
|||||||
await service.createFromCartSummary(
|
await service.createFromCartSummary(
|
||||||
buildSummary({
|
buildSummary({
|
||||||
discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }]
|
discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }]
|
||||||
})
|
}),
|
||||||
|
PaymentMethod.Xmr
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(discountRepo.create).not.toHaveBeenCalled();
|
expect(discountRepo.create).not.toHaveBeenCalled();
|
||||||
@@ -190,6 +189,17 @@ describe('CheckoutSessionService', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('issues a bitcoin checkout invoice when that payment method is selected', async () => {
|
||||||
|
await service.createFromCartSummary(buildSummary(), PaymentMethod.Btc);
|
||||||
|
|
||||||
|
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
|
||||||
|
paymentMethod: PaymentMethod.Btc,
|
||||||
|
reason: InvoiceReason.Checkout,
|
||||||
|
contextId: 'session-uuid',
|
||||||
|
amountFiat: 9
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findById', () => {
|
describe('findById', () => {
|
||||||
@@ -200,7 +210,14 @@ describe('CheckoutSessionService', () => {
|
|||||||
await expect(service.findById('session-1')).resolves.toBe(session);
|
await expect(service.findById('session-1')).resolves.toBe(session);
|
||||||
expect(sessionRepo.findOne).toHaveBeenCalledWith({
|
expect(sessionRepo.findOne).toHaveBeenCalledWith({
|
||||||
where: { id: 'session-1' },
|
where: { id: 'session-1' },
|
||||||
relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments']
|
relations: [
|
||||||
|
'lines',
|
||||||
|
'discounts',
|
||||||
|
'invoice',
|
||||||
|
'invoice.moneroDetails',
|
||||||
|
'invoice.btcDetails',
|
||||||
|
'invoice.payments'
|
||||||
|
]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { CookieCartSummary } from '../../storefrontCart/types/CookieCartSum
|
|||||||
import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState';
|
import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState';
|
||||||
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 type { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||||
import { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount';
|
import { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount';
|
||||||
import { CheckoutSessionLine } from '../entities/CheckoutSessionLine';
|
import { CheckoutSessionLine } from '../entities/CheckoutSessionLine';
|
||||||
import { CheckoutSession } from '../entities/CheckoutSession';
|
import { CheckoutSession } from '../entities/CheckoutSession';
|
||||||
@@ -26,11 +26,21 @@ export class CheckoutSessionService {
|
|||||||
async findById(id: string): Promise<CheckoutSession | null> {
|
async findById(id: string): Promise<CheckoutSession | null> {
|
||||||
return this.sessionRepo.findOne({
|
return this.sessionRepo.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments']
|
relations: [
|
||||||
|
'lines',
|
||||||
|
'discounts',
|
||||||
|
'invoice',
|
||||||
|
'invoice.moneroDetails',
|
||||||
|
'invoice.btcDetails',
|
||||||
|
'invoice.payments'
|
||||||
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFromCartSummary(summary: CookieCartSummary): Promise<CheckoutSession> {
|
async createFromCartSummary(
|
||||||
|
summary: CookieCartSummary,
|
||||||
|
requestedPaymentMethod: PaymentMethod
|
||||||
|
): Promise<CheckoutSession> {
|
||||||
if (summary.cartExtended.length === 0) {
|
if (summary.cartExtended.length === 0) {
|
||||||
throw new BadRequestException('Your cart is empty');
|
throw new BadRequestException('Your cart is empty');
|
||||||
}
|
}
|
||||||
@@ -42,7 +52,7 @@ export class CheckoutSessionService {
|
|||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
|
|
||||||
const invoice = await this.invoiceService.issueInvoice({
|
const invoice = await this.invoiceService.issueInvoice({
|
||||||
paymentMethod: PaymentMethod.Xmr,
|
paymentMethod: requestedPaymentMethod,
|
||||||
reason: InvoiceReason.Checkout,
|
reason: InvoiceReason.Checkout,
|
||||||
contextId: sessionId,
|
contextId: sessionId,
|
||||||
amountFiat: summary.cartTotalPrice
|
amountFiat: summary.cartTotalPrice
|
||||||
@@ -84,7 +94,7 @@ export class CheckoutSessionService {
|
|||||||
async cancelSession(sessionId: string): Promise<void> {
|
async cancelSession(sessionId: string): Promise<void> {
|
||||||
const session = await this.sessionRepo.findOne({
|
const session = await this.sessionRepo.findOne({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
relations: ['invoice', 'invoice.moneroDetails', 'invoice.payments']
|
relations: ['invoice', 'invoice.moneroDetails', 'invoice.btcDetails', 'invoice.payments']
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ describe('StorefrontCheckoutViewService', () => {
|
|||||||
let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>;
|
let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>;
|
||||||
|
|
||||||
const checkoutInvoiceView = {
|
const checkoutInvoiceView = {
|
||||||
cryptoCurrency: 'XMR',
|
paymentLabel: 'XMR',
|
||||||
amountFiat: 9
|
amountFiat: 9
|
||||||
} as unknown as StorefrontInvoiceView;
|
} as unknown as StorefrontInvoiceView;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { IsIn, IsNotEmpty } from 'class-validator';
|
import { IsIn, IsNotEmpty } from 'class-validator';
|
||||||
|
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
|
||||||
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
|
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
|
||||||
|
|
||||||
export class SetThemePreferenceDto {
|
export class SetThemePreferenceDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsIn(['light', 'dark'])
|
@IsIn(STOREFRONT_THEME_PREFERENCES)
|
||||||
theme: StorefrontThemePreference;
|
theme: StorefrontThemePreference;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
:root {
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
--sf-font-sans:
|
--sf-font-sans:
|
||||||
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
--sf-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
--sf-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
@@ -48,10 +49,14 @@
|
|||||||
--sf-split-sidebar: minmax(220px, 320px);
|
--sf-split-sidebar: minmax(220px, 320px);
|
||||||
--sf-product-card-min: 11rem;
|
--sf-product-card-min: 11rem;
|
||||||
--sf-product-card-max: 22rem;
|
--sf-product-card-max: 22rem;
|
||||||
|
--sf-blog-card-min: 17rem;
|
||||||
|
--sf-blog-card-max: 28rem;
|
||||||
|
--sf-sticky-header-scroll-padding: 9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme='light']) {
|
:root:not([data-theme='light']) {
|
||||||
|
color-scheme: dark;
|
||||||
--sf-bg: #0a0a0a;
|
--sf-bg: #0a0a0a;
|
||||||
--sf-surface: #141414;
|
--sf-surface: #141414;
|
||||||
--sf-surface-muted: #262727;
|
--sf-surface-muted: #262727;
|
||||||
@@ -85,6 +90,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
:root[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
--sf-bg: #0a0a0a;
|
--sf-bg: #0a0a0a;
|
||||||
--sf-surface: #141414;
|
--sf-surface: #141414;
|
||||||
--sf-surface-muted: #262727;
|
--sf-surface-muted: #262727;
|
||||||
@@ -117,6 +123,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='light'] {
|
:root[data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
--sf-bg: #ffffff;
|
--sf-bg: #ffffff;
|
||||||
--sf-surface: #ffffff;
|
--sf-surface: #ffffff;
|
||||||
--sf-surface-muted: #f5f7fa;
|
--sf-surface-muted: #f5f7fa;
|
||||||
@@ -154,8 +161,28 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sf-js-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js .sf-js-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js .sf-js-only.sf-copy-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-nojs-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js .sf-nojs-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
color-scheme: light dark;
|
scroll-padding-top: var(--sf-sticky-header-scroll-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.sf-body {
|
body.sf-body {
|
||||||
@@ -288,9 +315,13 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sf-header {
|
.sf-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
margin-bottom: var(--sf-space-4);
|
margin-bottom: var(--sf-space-4);
|
||||||
padding: var(--sf-space-3) 0;
|
padding: var(--sf-space-3) 0;
|
||||||
border-bottom: 1px solid var(--sf-border);
|
border-bottom: 1px solid var(--sf-border);
|
||||||
|
background: var(--sf-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sf-header__inner {
|
.sf-header__inner {
|
||||||
@@ -355,9 +386,51 @@ a:hover {
|
|||||||
gap: var(--sf-space-1);
|
gap: var(--sf-space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sf-rates {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sf-space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.sf-rate {
|
.sf-rate {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sf-space-1);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--sf-text-muted);
|
color: var(--sf-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sf-header__inner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-header__brand-group {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-nav,
|
||||||
|
.sf-rates {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-crypto-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-btn--pay {
|
||||||
|
gap: var(--sf-space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sf-category-nav {
|
.sf-category-nav {
|
||||||
@@ -488,6 +561,70 @@ a:hover {
|
|||||||
border-color: var(--sf-warning-border);
|
border-color: var(--sf-warning-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sf-alert--toast {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--sf-space-5);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 20;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sf-space-2);
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(24rem, calc(100% - 2 * var(--sf-space-4)));
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.6875rem 0.9375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-alert--toast.sf-alert--success {
|
||||||
|
animation: sf-toast-dismiss 3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-alert--toast.sf-alert--error {
|
||||||
|
animation: sf-toast-dismiss 6s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-alert--toast::before {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: var(--sf-radius-full);
|
||||||
|
color: var(--sf-on-accent);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-alert--toast.sf-alert--success::before {
|
||||||
|
content: '✓';
|
||||||
|
background: var(--sf-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-alert--toast.sf-alert--error::before {
|
||||||
|
content: '×';
|
||||||
|
background: var(--sf-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sf-toast-dismiss {
|
||||||
|
0%,
|
||||||
|
70% {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sf-btn {
|
.sf-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -508,6 +645,10 @@ a:hover {
|
|||||||
border-color 0.1s;
|
border-color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sf-btn.sf-js-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sf-btn:hover {
|
.sf-btn:hover {
|
||||||
color: var(--sf-accent);
|
color: var(--sf-accent);
|
||||||
background: var(--sf-accent-subtle);
|
background: var(--sf-accent-subtle);
|
||||||
@@ -767,7 +908,7 @@ a:hover {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sf-line-item__body > .sf-discount-error {
|
.sf-line-item__body > .sf-cart-issue {
|
||||||
margin: 0.125rem 0 0;
|
margin: 0.125rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,8 +985,8 @@ a:hover {
|
|||||||
color: var(--sf-text-muted);
|
color: var(--sf-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sf-discount-error {
|
.sf-cart-issue {
|
||||||
margin: 0 0 var(--sf-space-2);
|
margin: var(--sf-space-2) 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--sf-error);
|
color: var(--sf-error);
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
@@ -1142,7 +1283,7 @@ a:hover {
|
|||||||
|
|
||||||
.sf-qr-wrap {
|
.sf-qr-wrap {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: var(--sf-space-4);
|
margin: var(--sf-space-4) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sf-tx-list {
|
.sf-tx-list {
|
||||||
@@ -1181,6 +1322,64 @@ a:hover {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sf-copy-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sf-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-copy-row .sf-payment-amount {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-copy-row--inline {
|
||||||
|
display: inline-flex;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-copy-row--address {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--sf-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-copy-row__value {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-copy-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 1.375rem;
|
||||||
|
min-height: 1.375rem;
|
||||||
|
padding: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-copy-btn__icon {
|
||||||
|
display: block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-copy-btn__icon svg {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-copy-btn__icon--success {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-copy-btn--copied .sf-copy-btn__icon--copy {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-copy-btn--copied .sf-copy-btn__icon--success {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.sf-order-status {
|
.sf-order-status {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
@@ -1401,3 +1600,75 @@ a:hover {
|
|||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
margin: 0 0 var(--sf-space-2);
|
margin: 0 0 var(--sf-space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sf-blog-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--sf-space-3);
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 36rem) {
|
||||||
|
.sf-blog-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--sf-blog-card-min)), 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--sf-blog-card-max);
|
||||||
|
min-width: 0;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card__link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sf-space-2);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card__link:hover .sf-blog-card__title {
|
||||||
|
color: var(--sf-link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card__media {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: var(--sf-surface-muted);
|
||||||
|
border-radius: var(--sf-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card__title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-detail__banner {
|
||||||
|
margin-bottom: var(--sf-space-4);
|
||||||
|
background: var(--sf-surface-muted);
|
||||||
|
border-radius: var(--sf-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-detail__banner-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 24rem;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-detail__title {
|
||||||
|
margin-bottom: var(--sf-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-detail__content {
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
(() => {
|
||||||
|
const copiedAriaLabel = 'Copied';
|
||||||
|
const resetMs = 2000;
|
||||||
|
|
||||||
|
const copyText = async text => {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyClick = async event => {
|
||||||
|
const button = event.currentTarget;
|
||||||
|
const text = button.dataset.copyText;
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAriaLabel = button.dataset.copyAriaLabel ?? 'Copy';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyText(text);
|
||||||
|
|
||||||
|
button.classList.add('sf-copy-btn--copied');
|
||||||
|
button.setAttribute('aria-label', copiedAriaLabel);
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
button.classList.remove('sf-copy-btn--copied');
|
||||||
|
button.setAttribute('aria-label', defaultAriaLabel);
|
||||||
|
}, resetMs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Copy to clipboard failed', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const button of document.querySelectorAll('[data-action="copy"]')) {
|
||||||
|
button.addEventListener('click', handleCopyClick);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
document.documentElement.classList.replace('no-js', 'js');
|
||||||
@@ -7,51 +7,45 @@ import { StorefrontCartCookieService } from './StorefrontCartCookieService';
|
|||||||
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
|
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
|
||||||
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
|
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
|
||||||
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
|
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
|
||||||
|
import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService';
|
||||||
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
|
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
|
||||||
|
import type { StorefrontShopViewServiceOverrides } from '../types/StorefrontShopViewServiceTestTypes';
|
||||||
|
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||||
|
|
||||||
describe('StorefrontShopViewService', () => {
|
describe('StorefrontShopViewService', () => {
|
||||||
type ServiceOverrides = {
|
|
||||||
cart?: { variantId: string; qty: number }[];
|
|
||||||
feedback?: { type: 'success'; text: string };
|
|
||||||
authorizedOrderIds?: string[];
|
|
||||||
theme?: 'light' | 'dark';
|
|
||||||
branding?: {
|
|
||||||
logoUrl: string | null;
|
|
||||||
faviconUrl: string | null;
|
|
||||||
simplexLink: string | null;
|
|
||||||
shippingNote: string | null;
|
|
||||||
};
|
|
||||||
shopSettings?: { shopName: string; shopFiatCurrency: string };
|
|
||||||
fiatPerXmr?: number;
|
|
||||||
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
|
|
||||||
host?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultPage: StorefrontPageMetaInput = {
|
const defaultPage: StorefrontPageMetaInput = {
|
||||||
title: 'Cart',
|
title: 'Cart',
|
||||||
metaDescription: 'Review your cart.'
|
metaDescription: 'Review your cart.'
|
||||||
};
|
};
|
||||||
|
|
||||||
const createService = (overrides: ServiceOverrides = {}) => {
|
const createService = (overrides: StorefrontShopViewServiceOverrides = {}) => {
|
||||||
const {
|
const {
|
||||||
cart = [],
|
cart = [],
|
||||||
feedback,
|
feedback,
|
||||||
authorizedOrderIds = [],
|
authorizedOrderIds = [],
|
||||||
theme,
|
checkoutSessionId,
|
||||||
|
theme = 'system',
|
||||||
branding = {
|
branding = {
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
faviconUrl: null,
|
faviconUrl: null,
|
||||||
simplexLink: null,
|
simplexLink: null,
|
||||||
shippingNote: null
|
shippingNote: null,
|
||||||
|
blogEnabled: false
|
||||||
|
},
|
||||||
|
shopSettings = {
|
||||||
|
shopName: 'Demo Shop',
|
||||||
|
shopFiatCurrency: 'USD',
|
||||||
|
enabledPaymentMethods: [PaymentMethod.Xmr, PaymentMethod.Btc]
|
||||||
},
|
},
|
||||||
shopSettings = { shopName: 'Demo Shop', shopFiatCurrency: 'USD' },
|
|
||||||
fiatPerXmr = 150,
|
fiatPerXmr = 150,
|
||||||
|
fiatPerBtc = 60_000,
|
||||||
req: reqOverrides = {}
|
req: reqOverrides = {}
|
||||||
} = overrides;
|
} = overrides;
|
||||||
|
|
||||||
const exchangeRateService = {
|
const exchangeRateService = {
|
||||||
getLiveFiatPerCrypto: jest.fn().mockReturnValue(fiatPerXmr)
|
getLiveFiatPerCrypto: jest.fn((method: PaymentMethod) =>
|
||||||
|
method === PaymentMethod.Btc ? fiatPerBtc : fiatPerXmr
|
||||||
|
)
|
||||||
} as unknown as ExchangeRateService;
|
} as unknown as ExchangeRateService;
|
||||||
const configService = {
|
const configService = {
|
||||||
get: jest.fn().mockReturnValue(shopSettings)
|
get: jest.fn().mockReturnValue(shopSettings)
|
||||||
@@ -71,6 +65,9 @@ describe('StorefrontShopViewService', () => {
|
|||||||
const themeCookieService = {
|
const themeCookieService = {
|
||||||
getTheme: jest.fn().mockReturnValue(theme)
|
getTheme: jest.fn().mockReturnValue(theme)
|
||||||
} as unknown as StorefrontThemeCookieService;
|
} as unknown as StorefrontThemeCookieService;
|
||||||
|
const checkoutSessionCookieService = {
|
||||||
|
getSessionId: jest.fn().mockReturnValue(checkoutSessionId)
|
||||||
|
} as unknown as StorefrontCheckoutSessionCookieService;
|
||||||
|
|
||||||
const service = new StorefrontShopViewService(
|
const service = new StorefrontShopViewService(
|
||||||
exchangeRateService,
|
exchangeRateService,
|
||||||
@@ -79,7 +76,8 @@ describe('StorefrontShopViewService', () => {
|
|||||||
cartCookieService,
|
cartCookieService,
|
||||||
feedbackCookieService,
|
feedbackCookieService,
|
||||||
orderAuthCookieService,
|
orderAuthCookieService,
|
||||||
themeCookieService
|
themeCookieService,
|
||||||
|
checkoutSessionCookieService
|
||||||
);
|
);
|
||||||
|
|
||||||
const req = {
|
const req = {
|
||||||
@@ -108,6 +106,23 @@ describe('StorefrontShopViewService', () => {
|
|||||||
ogImageUrl: null,
|
ogImageUrl: null,
|
||||||
productJsonLd: null
|
productJsonLd: null
|
||||||
});
|
});
|
||||||
|
expect(locals.themePreference).toBe('system');
|
||||||
|
expect(locals.hasActiveCheckout).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes active checkout state from the checkout session cookie', async () => {
|
||||||
|
const { service, req, res } = createService({
|
||||||
|
checkoutSessionId: 'session-1',
|
||||||
|
req: { path: '/shop/checkout', originalUrl: '/shop/checkout' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const locals = await service.buildShopRenderLocals(req, res, {
|
||||||
|
title: 'Checkout',
|
||||||
|
metaDescription: 'Complete your purchase at {shopName}.'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(locals.hasActiveCheckout).toBe(true);
|
||||||
|
expect(locals.shopNavActive).toEqual({ activeShopNav: 'checkout', activeOrderId: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes shop settings, cart qty, feedback, branding, theme, and request context', async () => {
|
it('exposes shop settings, cart qty, feedback, branding, theme, and request context', async () => {
|
||||||
@@ -122,9 +137,11 @@ describe('StorefrontShopViewService', () => {
|
|||||||
logoUrl: '/uploads/logo.png',
|
logoUrl: '/uploads/logo.png',
|
||||||
faviconUrl: '/uploads/favicon.ico',
|
faviconUrl: '/uploads/favicon.ico',
|
||||||
simplexLink: 'https://simplex.example',
|
simplexLink: 'https://simplex.example',
|
||||||
shippingNote: 'Ships in 3 days'
|
shippingNote: 'Ships in 3 days',
|
||||||
|
blogEnabled: true
|
||||||
},
|
},
|
||||||
fiatPerXmr: 200
|
fiatPerXmr: 200,
|
||||||
|
fiatPerBtc: 80_000
|
||||||
});
|
});
|
||||||
|
|
||||||
const locals = await service.buildShopRenderLocals(req, res, defaultPage);
|
const locals = await service.buildShopRenderLocals(req, res, defaultPage);
|
||||||
@@ -134,11 +151,15 @@ describe('StorefrontShopViewService', () => {
|
|||||||
cartTotalQty: 3,
|
cartTotalQty: 3,
|
||||||
feedback: { type: 'success', text: 'Added to cart' },
|
feedback: { type: 'success', text: 'Added to cart' },
|
||||||
shopFiatCurrency: 'USD',
|
shopFiatCurrency: 'USD',
|
||||||
fiatPerXmr: 200,
|
cryptoRates: [
|
||||||
|
{ paymentLabel: 'XMR', fiatPerCrypto: 200, iconUrl: '/shop/assets/img/xmr.png' },
|
||||||
|
{ paymentLabel: 'BTC', fiatPerCrypto: 80_000, iconUrl: '/shop/assets/img/btc.png' }
|
||||||
|
],
|
||||||
logoUrl: '/uploads/logo.png',
|
logoUrl: '/uploads/logo.png',
|
||||||
faviconUrl: '/uploads/favicon.ico',
|
faviconUrl: '/uploads/favicon.ico',
|
||||||
simplexLink: 'https://simplex.example',
|
simplexLink: 'https://simplex.example',
|
||||||
shippingNote: 'Ships in 3 days',
|
shippingNote: 'Ships in 3 days',
|
||||||
|
blogEnabled: true,
|
||||||
themePreference: 'dark'
|
themePreference: 'dark'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -264,7 +285,7 @@ describe('StorefrontShopViewService', () => {
|
|||||||
|
|
||||||
it('uses shop fiat currency in product json-ld', async () => {
|
it('uses shop fiat currency in product json-ld', async () => {
|
||||||
const { service, req, res } = createService({
|
const { service, req, res } = createService({
|
||||||
shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR' },
|
shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR', enabledPaymentMethods: [PaymentMethod.Xmr] },
|
||||||
req: {
|
req: {
|
||||||
path: '/shop/products/1/variants/2',
|
path: '/shop/products/1/variants/2',
|
||||||
originalUrl: '/shop/products/1/variants/2'
|
originalUrl: '/shop/products/1/variants/2'
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
|
|||||||
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
|
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
|
||||||
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
||||||
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
||||||
|
import { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
|
||||||
|
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
|
||||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||||
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
|
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
|
||||||
import type { ShopRenderLocals } from '../types/ShopRenderLocals';
|
import type { ShopRenderLocals } from '../types/ShopRenderLocals';
|
||||||
|
import type { StorefrontCryptoRate } from '../types/StorefrontCryptoRate';
|
||||||
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
|
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
|
||||||
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
|
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
|
||||||
import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput';
|
import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput';
|
||||||
import { resolveShopNavActive } from '../utils/resolveShopNavActive';
|
import { resolveShopNavActive } from '../utils/resolveShopNavActive';
|
||||||
import { StorefrontCartCookieService } from './StorefrontCartCookieService';
|
import { StorefrontCartCookieService } from './StorefrontCartCookieService';
|
||||||
|
import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService';
|
||||||
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
|
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
|
||||||
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
|
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
|
||||||
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
|
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
|
||||||
@@ -29,7 +33,8 @@ export class StorefrontShopViewService {
|
|||||||
private readonly cartCookieService: StorefrontCartCookieService,
|
private readonly cartCookieService: StorefrontCartCookieService,
|
||||||
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
|
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
|
||||||
private readonly orderAuthCookieService: StorefrontOrderAuthCookieService,
|
private readonly orderAuthCookieService: StorefrontOrderAuthCookieService,
|
||||||
private readonly themeCookieService: StorefrontThemeCookieService
|
private readonly themeCookieService: StorefrontThemeCookieService,
|
||||||
|
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async buildShopRenderLocals(req: Request, res: Response, page: StorefrontPageMetaInput): Promise<ShopRenderLocals> {
|
async buildShopRenderLocals(req: Request, res: Response, page: StorefrontPageMetaInput): Promise<ShopRenderLocals> {
|
||||||
@@ -38,12 +43,15 @@ export class StorefrontShopViewService {
|
|||||||
const themePreference = this.themeCookieService.getTheme(req, res);
|
const themePreference = this.themeCookieService.getTheme(req, res);
|
||||||
|
|
||||||
const cartTotalQty = getTotalCartQtyFromCart(cart);
|
const cartTotalQty = getTotalCartQtyFromCart(cart);
|
||||||
|
const hasActiveCheckout = Boolean(this.checkoutSessionCookieService.getSessionId(req, res));
|
||||||
|
|
||||||
const { shopName, shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
|
const { shopName, shopFiatCurrency, enabledPaymentMethods } = this.configService.get(
|
||||||
|
'shopSettings'
|
||||||
|
) as Config['shopSettings'];
|
||||||
|
|
||||||
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
|
const cryptoRates = this.buildCryptoRates(enabledPaymentMethods);
|
||||||
|
|
||||||
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
|
const { logoUrl, faviconUrl, simplexLink, shippingNote, blogEnabled } =
|
||||||
await this.shopSettingsService.getStorefrontBranding();
|
await this.shopSettingsService.getStorefrontBranding();
|
||||||
|
|
||||||
const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res);
|
const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res);
|
||||||
@@ -57,15 +65,17 @@ export class StorefrontShopViewService {
|
|||||||
title: page.title,
|
title: page.title,
|
||||||
shopName,
|
shopName,
|
||||||
cartTotalQty,
|
cartTotalQty,
|
||||||
|
hasActiveCheckout,
|
||||||
feedback,
|
feedback,
|
||||||
authorizedOrders,
|
authorizedOrders,
|
||||||
shopNavActive,
|
shopNavActive,
|
||||||
shopFiatCurrency,
|
shopFiatCurrency,
|
||||||
fiatPerXmr,
|
cryptoRates,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
faviconUrl,
|
faviconUrl,
|
||||||
simplexLink,
|
simplexLink,
|
||||||
shippingNote,
|
shippingNote,
|
||||||
|
blogEnabled,
|
||||||
themePreference,
|
themePreference,
|
||||||
pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page)
|
pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page)
|
||||||
};
|
};
|
||||||
@@ -130,4 +140,22 @@ export class StorefrontShopViewService {
|
|||||||
|
|
||||||
return JSON.stringify(data);
|
return JSON.stringify(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildCryptoRates(enabledPaymentMethods: PaymentMethod[]): StorefrontCryptoRate[] {
|
||||||
|
return enabledPaymentMethods.flatMap(paymentMethod => {
|
||||||
|
const fiatPerCrypto = this.exchangeRateService.getLiveFiatPerCrypto(paymentMethod);
|
||||||
|
|
||||||
|
if (fiatPerCrypto === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
paymentLabel: paymentMethodLabel[paymentMethod],
|
||||||
|
fiatPerCrypto,
|
||||||
|
iconUrl: paymentMethodIconUrl[paymentMethod]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ describe('StorefrontThemeCookieService', () => {
|
|||||||
service = new StorefrontThemeCookieService(signedCookies as unknown as StorefrontSignedCookieService);
|
service = new StorefrontThemeCookieService(signedCookies as unknown as StorefrontSignedCookieService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for invalid theme values', () => {
|
it('returns system when no cookie is set', () => {
|
||||||
signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' });
|
signedCookies.getSignedCookie.mockReturnValue(undefined);
|
||||||
|
|
||||||
expect(service.getTheme({} as never, {} as never)).toBeUndefined();
|
expect(service.getTheme({} as never, {} as never)).toBe('system');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns system for invalid theme values', () => {
|
||||||
|
signedCookies.getSignedCookie.mockReturnValue({ theme: 'sepia' });
|
||||||
|
|
||||||
|
expect(service.getTheme({} as never, {} as never)).toBe('system');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a supported theme preference', () => {
|
it('returns a supported theme preference', () => {
|
||||||
@@ -25,4 +31,10 @@ describe('StorefrontThemeCookieService', () => {
|
|||||||
|
|
||||||
expect(service.getTheme({} as never, {} as never)).toBe('dark');
|
expect(service.getTheme({} as never, {} as never)).toBe('dark');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns system when stored in the cookie', () => {
|
||||||
|
signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' });
|
||||||
|
|
||||||
|
expect(service.getTheme({} as never, {} as never)).toBe('system');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
|
||||||
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
|
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
|
||||||
import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
|
import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
|
||||||
|
|
||||||
@@ -7,15 +8,15 @@ import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
|
|||||||
export class StorefrontThemeCookieService {
|
export class StorefrontThemeCookieService {
|
||||||
constructor(private readonly signedCookies: StorefrontSignedCookieService) {}
|
constructor(private readonly signedCookies: StorefrontSignedCookieService) {}
|
||||||
|
|
||||||
getTheme(req: Request, res: Response): StorefrontThemePreference | undefined {
|
getTheme(req: Request, res: Response): StorefrontThemePreference {
|
||||||
const payload = this.signedCookies.getSignedCookie<{ theme: string }>(req, res, 'theme');
|
const payload = this.signedCookies.getSignedCookie<{ theme: StorefrontThemePreference }>(req, res, 'theme');
|
||||||
const theme = payload?.theme;
|
const theme = payload?.theme;
|
||||||
|
|
||||||
if (theme === 'light' || theme === 'dark') {
|
if (theme && STOREFRONT_THEME_PREFERENCES.includes(theme)) {
|
||||||
return theme;
|
return theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(req: Request, res: Response, theme: StorefrontThemePreference): void {
|
setTheme(req: Request, res: Response, theme: StorefrontThemePreference): void {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export type ShopNavKey = 'shop' | 'cart' | 'check-order';
|
export type ShopNavKey = 'shop' | 'blog' | 'cart' | 'checkout' | 'check-order';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
|
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
|
||||||
import type { AuthorizedOrderNavItem } from './AuthorizedOrderNavItem';
|
import type { AuthorizedOrderNavItem } from './AuthorizedOrderNavItem';
|
||||||
import type { ShopNavActive } from './ShopNavActive';
|
import type { ShopNavActive } from './ShopNavActive';
|
||||||
|
import type { StorefrontCryptoRate } from './StorefrontCryptoRate';
|
||||||
import type { StorefrontFeedback } from './StorefrontFeedback';
|
import type { StorefrontFeedback } from './StorefrontFeedback';
|
||||||
import type { StorefrontPageMeta } from './StorefrontPageMeta';
|
import type { StorefrontPageMeta } from './StorefrontPageMeta';
|
||||||
import type { StorefrontThemePreference } from './StorefrontThemePreference';
|
import type { StorefrontThemePreference } from './StorefrontThemePreference';
|
||||||
@@ -9,15 +10,17 @@ export type ShopRenderLocals = {
|
|||||||
title: string;
|
title: string;
|
||||||
shopName: string;
|
shopName: string;
|
||||||
cartTotalQty: number;
|
cartTotalQty: number;
|
||||||
|
hasActiveCheckout: boolean;
|
||||||
feedback: StorefrontFeedback | undefined;
|
feedback: StorefrontFeedback | undefined;
|
||||||
authorizedOrders: AuthorizedOrderNavItem[];
|
authorizedOrders: AuthorizedOrderNavItem[];
|
||||||
shopNavActive: ShopNavActive;
|
shopNavActive: ShopNavActive;
|
||||||
shopFiatCurrency: ShopFiatCurrency;
|
shopFiatCurrency: ShopFiatCurrency;
|
||||||
fiatPerXmr: number | null;
|
cryptoRates: StorefrontCryptoRate[];
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
simplexLink: string | null;
|
simplexLink: string | null;
|
||||||
shippingNote: string | null;
|
shippingNote: string | null;
|
||||||
themePreference: StorefrontThemePreference | undefined;
|
blogEnabled: boolean;
|
||||||
|
themePreference: StorefrontThemePreference;
|
||||||
pageMeta: StorefrontPageMeta;
|
pageMeta: StorefrontPageMeta;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type StorefrontCryptoRate = {
|
||||||
|
paymentLabel: string;
|
||||||
|
fiatPerCrypto: number;
|
||||||
|
iconUrl: string;
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceSta
|
|||||||
import type { InvoiceStatusVariant } from '../../../utils/invoice/types/InvoiceStatusVariant';
|
import type { InvoiceStatusVariant } from '../../../utils/invoice/types/InvoiceStatusVariant';
|
||||||
|
|
||||||
export type StorefrontInvoiceView = {
|
export type StorefrontInvoiceView = {
|
||||||
cryptoCurrency: string;
|
paymentLabel: string;
|
||||||
expectedTotalCrypto: string;
|
expectedTotalCrypto: string;
|
||||||
receivedTotalCrypto: string | null;
|
receivedTotalCrypto: string | null;
|
||||||
paymentAddress: string;
|
paymentAddress: string;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
import type { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||||
|
import type { StorefrontThemePreference } from './StorefrontThemePreference';
|
||||||
|
|
||||||
|
export type StorefrontShopViewServiceOverrides = {
|
||||||
|
cart?: { variantId: string; qty: number }[];
|
||||||
|
feedback?: { type: 'success'; text: string };
|
||||||
|
authorizedOrderIds?: string[];
|
||||||
|
checkoutSessionId?: string;
|
||||||
|
theme?: StorefrontThemePreference;
|
||||||
|
branding?: {
|
||||||
|
logoUrl: string | null;
|
||||||
|
faviconUrl: string | null;
|
||||||
|
simplexLink: string | null;
|
||||||
|
shippingNote: string | null;
|
||||||
|
blogEnabled: boolean;
|
||||||
|
};
|
||||||
|
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
|
||||||
|
fiatPerXmr?: number;
|
||||||
|
fiatPerBtc?: number | null;
|
||||||
|
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
|
||||||
|
host?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1 +1,3 @@
|
|||||||
export type StorefrontThemePreference = 'light' | 'dark';
|
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
|
||||||
|
|
||||||
|
export type StorefrontThemePreference = (typeof STOREFRONT_THEME_PREFERENCES)[number];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const STOREFRONT_PARTIALS = [
|
|||||||
{ name: 'cart-line-item', file: 'cart-line-item.hbs' },
|
{ name: 'cart-line-item', file: 'cart-line-item.hbs' },
|
||||||
{ name: 'checkout-line-item', file: 'checkout-line-item.hbs' },
|
{ name: 'checkout-line-item', file: 'checkout-line-item.hbs' },
|
||||||
{ name: 'confirm-action-details', file: 'confirm-action-details.hbs' },
|
{ name: 'confirm-action-details', file: 'confirm-action-details.hbs' },
|
||||||
|
{ name: 'copy-button', file: 'copy-button.hbs' },
|
||||||
{ name: 'dismiss-button', file: 'dismiss-button.hbs' },
|
{ name: 'dismiss-button', file: 'dismiss-button.hbs' },
|
||||||
{ name: 'order-line-item', file: 'order-line-item.hbs' },
|
{ name: 'order-line-item', file: 'order-line-item.hbs' },
|
||||||
{ name: 'order-digital-delivery', file: 'order-digital-delivery.hbs' },
|
{ name: 'order-digital-delivery', file: 'order-digital-delivery.hbs' },
|
||||||
@@ -29,8 +30,10 @@ const STOREFRONT_PARTIALS = [
|
|||||||
{ name: 'shop-footer', file: 'shop-footer.hbs' },
|
{ name: 'shop-footer', file: 'shop-footer.hbs' },
|
||||||
{ name: 'storefront-image', file: 'storefront-image.hbs' },
|
{ name: 'storefront-image', file: 'storefront-image.hbs' },
|
||||||
{ name: 'theme-switcher', file: 'theme-switcher.hbs' },
|
{ name: 'theme-switcher', file: 'theme-switcher.hbs' },
|
||||||
|
{ name: 'html-theme-attributes', file: 'html-theme-attributes.hbs' },
|
||||||
{ name: 'page-back-link', file: 'page-back-link.hbs' },
|
{ name: 'page-back-link', file: 'page-back-link.hbs' },
|
||||||
{ name: 'product-card', file: 'product-card.hbs' },
|
{ name: 'product-card', file: 'product-card.hbs' },
|
||||||
|
{ name: 'blog-card', file: 'blog-card.hbs' },
|
||||||
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
|
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
|
||||||
{ name: 'category-nav', file: 'category-nav.hbs' },
|
{ name: 'category-nav', file: 'category-nav.hbs' },
|
||||||
{ name: 'product-delivery-note', file: 'product-delivery-note.hbs' },
|
{ name: 'product-delivery-note', file: 'product-delivery-note.hbs' },
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ describe('resolveShopNavActive', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('highlights cart and check-order pages', () => {
|
it('highlights cart, checkout, and check-order pages', () => {
|
||||||
expect(resolveShopNavActive('/shop/cart')).toEqual({ activeShopNav: 'cart', activeOrderId: null });
|
expect(resolveShopNavActive('/shop/cart')).toEqual({ activeShopNav: 'cart', activeOrderId: null });
|
||||||
|
expect(resolveShopNavActive('/shop/checkout')).toEqual({ activeShopNav: 'checkout', activeOrderId: null });
|
||||||
expect(resolveShopNavActive('/shop/check-order')).toEqual({
|
expect(resolveShopNavActive('/shop/check-order')).toEqual({
|
||||||
activeShopNav: 'check-order',
|
activeShopNav: 'check-order',
|
||||||
activeOrderId: null
|
activeOrderId: null
|
||||||
@@ -28,7 +29,12 @@ describe('resolveShopNavActive', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('highlights blog pages', () => {
|
||||||
|
expect(resolveShopNavActive('/shop/blog')).toEqual({ activeShopNav: 'blog', activeOrderId: null });
|
||||||
|
expect(resolveShopNavActive('/shop/blog/post-1')).toEqual({ activeShopNav: 'blog', activeOrderId: null });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not highlight nav items on unrelated pages', () => {
|
it('does not highlight nav items on unrelated pages', () => {
|
||||||
expect(resolveShopNavActive('/shop/checkout')).toEqual({ activeShopNav: null, activeOrderId: null });
|
expect(resolveShopNavActive('/shop/theme')).toEqual({ activeShopNav: null, activeOrderId: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export const resolveShopNavActive = (path: string): ShopNavActive => {
|
|||||||
return { activeShopNav: 'cart', activeOrderId: null };
|
return { activeShopNav: 'cart', activeOrderId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (first === 'shop' && second === 'checkout') {
|
||||||
|
return { activeShopNav: 'checkout', activeOrderId: null };
|
||||||
|
}
|
||||||
|
|
||||||
if (first === 'shop' && second === 'check-order') {
|
if (first === 'shop' && second === 'check-order') {
|
||||||
return { activeShopNav: 'check-order', activeOrderId: null };
|
return { activeShopNav: 'check-order', activeOrderId: null };
|
||||||
}
|
}
|
||||||
@@ -19,6 +23,10 @@ export const resolveShopNavActive = (path: string): ShopNavActive => {
|
|||||||
return { activeShopNav: null, activeOrderId: third };
|
return { activeShopNav: null, activeOrderId: third };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (first === 'shop' && second === 'blog') {
|
||||||
|
return { activeShopNav: 'blog', activeOrderId: null };
|
||||||
|
}
|
||||||
|
|
||||||
if (first === 'shop' && (second === 'categories' || second === 'products')) {
|
if (first === 'shop' && (second === 'categories' || second === 'products')) {
|
||||||
return { activeShopNav: 'shop', activeOrderId: null };
|
return { activeShopNav: 'shop', activeOrderId: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{{> page-back-link href=backHref label=backLabel}}
|
||||||
|
|
||||||
|
{{#if post.bannerUrl}}
|
||||||
|
<div class='sf-blog-detail__banner'>
|
||||||
|
{{> storefront-image
|
||||||
|
class='sf-blog-detail__banner-image'
|
||||||
|
src=post.bannerUrl
|
||||||
|
width='1600'
|
||||||
|
height='600'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<h1 class='sf-page-title sf-blog-detail__title'>{{post.title}}</h1>
|
||||||
|
|
||||||
|
<div class='sf-prose sf-blog-detail__content'>{{{post.contentHtml}}}</div>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<h1 class='sf-page-title'>{{title}}</h1>
|
||||||
|
|
||||||
|
{{#if posts.length}}
|
||||||
|
<ul class='sf-list-reset sf-blog-grid'>
|
||||||
|
{{#each posts}}
|
||||||
|
{{> blog-card}}
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class='sf-text-muted'>No posts yet.</p>
|
||||||
|
{{/if}}
|
||||||
@@ -23,14 +23,14 @@
|
|||||||
<p class='sf-payment-status sf-payment-status--confirmed'>Payment received</p>
|
<p class='sf-payment-status sf-payment-status--confirmed'>Payment received</p>
|
||||||
<p class='sf-payment-detail'>
|
<p class='sf-payment-detail'>
|
||||||
Received:
|
Received:
|
||||||
<strong>{{checkout.checkoutInvoice.receivedTotalCrypto}} {{checkout.checkoutInvoice.cryptoCurrency}}</strong>
|
<strong>{{checkout.checkoutInvoice.receivedTotalCrypto}} {{checkout.checkoutInvoice.paymentLabel}}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p class='sf-payment-detail'>
|
<p class='sf-payment-detail'>
|
||||||
Your order is being prepared. This page will redirect automatically once it is ready.
|
Your order is being prepared. This page will redirect automatically once it is ready.
|
||||||
</p>
|
</p>
|
||||||
{{> refresh-link href=checkout.refreshHref showAutoRefreshNote=true}}
|
{{> refresh-link href=checkout.refreshHref showAutoRefreshNote=true}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2 class='sf-section-title'>Pay with {{checkout.checkoutInvoice.cryptoCurrency}}</h2>
|
<h2 class='sf-section-title'>Pay with {{checkout.checkoutInvoice.paymentLabel}}</h2>
|
||||||
{{#with checkout.checkoutInvoice}}
|
{{#with checkout.checkoutInvoice}}
|
||||||
{{> invoice-payment refreshHref=../checkout.refreshHref showAutoRefreshNote=true}}
|
{{> invoice-payment refreshHref=../checkout.refreshHref showAutoRefreshNote=true}}
|
||||||
{{/with}}
|
{{/with}}
|
||||||
@@ -45,3 +45,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src='/shop/assets/js/copy.js' defer></script>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html
|
<html
|
||||||
lang='en'
|
lang='en'
|
||||||
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
|
class='no-js'
|
||||||
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
|
{{> html-theme-attributes}}
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
{{> shop-head}}
|
{{> shop-head}}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user