Compare commits
47
Commits
7b62bc41ad
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+5
-2
@@ -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
|
||||||
@@ -53,6 +54,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 +91,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 +105,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":"30","minConfirmations":1},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"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
|
||||||
@@ -146,4 +148,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
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ export const getAppConfig = (): AppConfig => {
|
|||||||
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')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -218,6 +219,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,12 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const storefrontImgUrlPrefix = '/shop/assets/img';
|
||||||
|
|
||||||
|
export const storefrontCssUrlPrefix = '/shop/assets/css';
|
||||||
|
|
||||||
|
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');
|
||||||
@@ -153,6 +153,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 +380,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;
|
||||||
+14
-4
@@ -5,6 +5,12 @@ 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,
|
||||||
|
storefrontCssUrlPrefix,
|
||||||
|
storefrontImgUrlPrefix
|
||||||
|
} 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 +32,14 @@ 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.use(cookieParser());
|
app.use(cookieParser());
|
||||||
@@ -61,7 +71,7 @@ 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/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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { 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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -86,7 +100,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')
|
||||||
@@ -175,4 +189,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,12 @@
|
|||||||
--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-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 +88,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 +121,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;
|
||||||
@@ -155,7 +160,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
color-scheme: light dark;
|
scroll-padding-top: var(--sf-sticky-header-scroll-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.sf-body {
|
body.sf-body {
|
||||||
@@ -288,9 +293,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 +364,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 +539,76 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sf-alert--toast {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sf-btn {
|
.sf-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -767,7 +888,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 +965,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;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -7,51 +7,44 @@ 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
|
||||||
},
|
},
|
||||||
shopSettings = { shopName: 'Demo Shop', shopFiatCurrency: 'USD' },
|
shopSettings = {
|
||||||
|
shopName: 'Demo Shop',
|
||||||
|
shopFiatCurrency: 'USD',
|
||||||
|
enabledPaymentMethods: [PaymentMethod.Xmr, PaymentMethod.Btc]
|
||||||
|
},
|
||||||
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 +64,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 +75,8 @@ describe('StorefrontShopViewService', () => {
|
|||||||
cartCookieService,
|
cartCookieService,
|
||||||
feedbackCookieService,
|
feedbackCookieService,
|
||||||
orderAuthCookieService,
|
orderAuthCookieService,
|
||||||
themeCookieService
|
themeCookieService,
|
||||||
|
checkoutSessionCookieService
|
||||||
);
|
);
|
||||||
|
|
||||||
const req = {
|
const req = {
|
||||||
@@ -108,6 +105,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 () => {
|
||||||
@@ -124,7 +138,8 @@ describe('StorefrontShopViewService', () => {
|
|||||||
simplexLink: 'https://simplex.example',
|
simplexLink: 'https://simplex.example',
|
||||||
shippingNote: 'Ships in 3 days'
|
shippingNote: 'Ships in 3 days'
|
||||||
},
|
},
|
||||||
fiatPerXmr: 200
|
fiatPerXmr: 200,
|
||||||
|
fiatPerBtc: 80_000
|
||||||
});
|
});
|
||||||
|
|
||||||
const locals = await service.buildShopRenderLocals(req, res, defaultPage);
|
const locals = await service.buildShopRenderLocals(req, res, defaultPage);
|
||||||
@@ -134,7 +149,10 @@ 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',
|
||||||
@@ -264,7 +282,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,10 +43,13 @@ 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 } =
|
||||||
await this.shopSettingsService.getStorefrontBranding();
|
await this.shopSettingsService.getStorefrontBranding();
|
||||||
@@ -57,11 +65,12 @@ 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,
|
||||||
@@ -130,4 +139,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' | '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,16 @@ 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;
|
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,23 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
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];
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
|
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.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
|
||||||
@@ -29,6 +30,6 @@ describe('resolveShopNavActive', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html
|
<html
|
||||||
lang='en'
|
lang='en'
|
||||||
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
|
{{> html-theme-attributes}}
|
||||||
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
|
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
{{> shop-head}}
|
{{> shop-head}}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html
|
<html
|
||||||
lang='en'
|
lang='en'
|
||||||
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
|
{{> html-theme-attributes}}
|
||||||
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
|
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
{{> shop-head}}
|
{{> shop-head}}
|
||||||
@@ -12,10 +11,10 @@
|
|||||||
{{> shop-nav}}
|
{{> shop-nav}}
|
||||||
{{> category-nav}}
|
{{> category-nav}}
|
||||||
<main class='sf-main'>
|
<main class='sf-main'>
|
||||||
{{> feedback}}
|
|
||||||
{{{body}}}
|
{{{body}}}
|
||||||
</main>
|
</main>
|
||||||
{{> shop-footer}}
|
{{> shop-footer}}
|
||||||
</div>
|
</div>
|
||||||
|
{{> feedback}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
{{@root.shopFiatCurrency}}</div>
|
{{@root.shopFiatCurrency}}</div>
|
||||||
<div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div>
|
<div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div>
|
||||||
{{#if stockIssueMessage}}
|
{{#if stockIssueMessage}}
|
||||||
<p class='sf-discount-error'>{{stockIssueMessage}}</p>
|
<p class='sf-cart-issue'>{{stockIssueMessage}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#unless stockForSession}}
|
{{#unless stockForSession}}
|
||||||
<p class='sf-text-subtle'>No more in stock beside your order</p>
|
<p class='sf-text-subtle'>No more in stock beside your order</p>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
{{#each @root.discounts}}
|
{{#each @root.discounts}}
|
||||||
{{#if (includes ineligibleVariantIds ../id)}}
|
{{#if (includes ineligibleVariantIds ../id)}}
|
||||||
<p class='sf-discount-error'>Not eligible for code {{code}}</p>
|
<p class='sf-cart-issue'>Not eligible for code {{code}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
<form class='sf-form-row' method='post' action='/shop/cart/product/update'>
|
<form class='sf-form-row' method='post' action='/shop/cart/product/update'>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
}}
|
}}
|
||||||
<span class='sf-discount-row__code sf-text-error'>{{code}}</span>
|
<span class='sf-discount-row__code sf-text-error'>{{code}}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class='sf-discount-error'>{{issueMessage}}</p>
|
<p class='sf-cart-issue'>{{issueMessage}}</p>
|
||||||
{{else if amount}}
|
{{else if amount}}
|
||||||
<div class='sf-discount-row sf-discount-item'>
|
<div class='sf-discount-row sf-discount-item'>
|
||||||
{{> dismiss-button
|
{{> dismiss-button
|
||||||
@@ -52,13 +52,6 @@
|
|||||||
{{cartTotalPrice}}
|
{{cartTotalPrice}}
|
||||||
{{shopFiatCurrency}}
|
{{shopFiatCurrency}}
|
||||||
</p>
|
</p>
|
||||||
{{#if cartTotalXmr}}
|
|
||||||
<p class='sf-text-muted'>= {{cartTotalXmr}} XMR</p>
|
|
||||||
{{else}}
|
|
||||||
<p class='sf-text-muted'>
|
|
||||||
We couldn't determine the XMR rate right now. Please try again later.
|
|
||||||
</p>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
<form class='sf-form-row' method='post' action='/shop/cart/discount'>
|
<form class='sf-form-row' method='post' action='/shop/cart/discount'>
|
||||||
<label class='sf-field'>
|
<label class='sf-field'>
|
||||||
@@ -69,26 +62,41 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{#if cartTotalIssueMessage}}
|
{{#if cartTotalIssueMessage}}
|
||||||
<p class='sf-discount-error'>{{cartTotalIssueMessage}}</p>
|
<p class='sf-cart-issue'>{{cartTotalIssueMessage}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div class='sf-stack sf-mt-4'>
|
<div class='sf-stack sf-mt-4'>
|
||||||
{{#if cartTotalXmr}}
|
{{#unless hasIssues}}
|
||||||
{{#unless hasIssues}}
|
<form class='sf-form-col' method='post' action='/shop/checkout/pay'>
|
||||||
<form class='sf-form-col' method='post' action='/shop/checkout/pay'>
|
<div>{{{captchaSvg}}}</div>
|
||||||
<div>{{{captchaSvg}}}</div>
|
<label class='sf-field'>
|
||||||
<label class='sf-field'>
|
<span class='sf-field__label'>Enter the code above</span>
|
||||||
<span class='sf-field__label'>Enter the code above</span>
|
<input class='sf-input' type='text' name='captcha' autocomplete='off' required />
|
||||||
<input class='sf-input' type='text' name='captcha' autocomplete='off' required />
|
</label>
|
||||||
</label>
|
|
||||||
<button type='submit' class='sf-btn sf-btn--primary'>Pay with XMR</button>
|
<div class='sf-stack'>
|
||||||
</form>
|
{{#each paymentMethods}}
|
||||||
{{else}}
|
<button
|
||||||
<button type='button' class='sf-btn sf-btn--primary' disabled title='Resolve cart issues before paying'>Pay with XMR</button>
|
type='submit'
|
||||||
{{/unless}}
|
name='paymentMethod'
|
||||||
|
value='{{paymentMethod}}'
|
||||||
|
class='sf-btn sf-btn--primary sf-btn--pay'
|
||||||
|
>
|
||||||
|
Pay with {{paymentMethodLabel}}
|
||||||
|
{{> storefront-image
|
||||||
|
class='sf-crypto-icon'
|
||||||
|
src=iconUrl
|
||||||
|
alt=''
|
||||||
|
width='20'
|
||||||
|
height='20'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
<button type='button' class='sf-btn sf-btn--primary' disabled title='XMR rate unavailable'>Pay with XMR</button>
|
<button type='button' class='sf-btn sf-btn--primary' disabled title='Resolve cart issues before paying'>Continue to payment</button>
|
||||||
{{/if}}
|
{{/unless}}
|
||||||
|
|
||||||
{{> manual-shipping-info}}
|
{{> manual-shipping-info}}
|
||||||
{{> auto-delivery-info}}
|
{{> auto-delivery-info}}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{#if feedback}}
|
{{#if feedback}}
|
||||||
<p class='sf-alert sf-alert--{{feedback.type}}' role='status'>
|
<p class='sf-alert sf-alert--{{feedback.type}} sf-alert--toast' role='status'>
|
||||||
{{feedback.text}}
|
{{feedback.text}}
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
|
||||||
|
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
|
||||||
@@ -3,27 +3,27 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if showProminentAmount}}
|
{{#if showProminentAmount}}
|
||||||
<p class='sf-payment-amount'>{{expectedTotalCrypto}} {{cryptoCurrency}}</p>
|
<p class='sf-payment-amount'>{{expectedTotalCrypto}} {{paymentLabel}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if showReceivedTotal}}
|
{{#if showReceivedTotal}}
|
||||||
<p class='sf-payment-detail'>
|
<p class='sf-payment-detail'>
|
||||||
Received:
|
Received:
|
||||||
<strong>{{receivedTotalCrypto}} {{cryptoCurrency}}</strong>
|
<strong>{{receivedTotalCrypto}} {{paymentLabel}}</strong>
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if showExpectedTotal}}
|
{{#if showExpectedTotal}}
|
||||||
<p class='sf-payment-detail'>
|
<p class='sf-payment-detail'>
|
||||||
Expected total:
|
Expected total:
|
||||||
<strong>{{expectedTotalCrypto}} {{cryptoCurrency}}</strong>
|
<strong>{{expectedTotalCrypto}} {{paymentLabel}}</strong>
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if showInstruction}}
|
{{#if showInstruction}}
|
||||||
<p class='sf-payment-detail'>
|
<p class='sf-payment-detail'>
|
||||||
{{instructionPrefix}}
|
{{instructionPrefix}}
|
||||||
<strong>{{instructionAmountCrypto}} {{cryptoCurrency}}</strong>
|
<strong>{{instructionAmountCrypto}} {{paymentLabel}}</strong>
|
||||||
{{instructionSuffix}}
|
{{instructionSuffix}}
|
||||||
{{#if showExpiry}}
|
{{#if showExpiry}}
|
||||||
Payment expires in
|
Payment expires in
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
{{#if showQr}}
|
{{#if showQr}}
|
||||||
<div class='sf-qr-wrap'>
|
<div class='sf-qr-wrap'>
|
||||||
<img src='{{qrCodeUrl}}' width='220' height='220' alt='{{cryptoCurrency}} payment QR code' />
|
<img src='{{qrCodeUrl}}' width='220' height='220' alt='{{paymentLabel}} payment QR code' />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<ul class='sf-list-reset sf-tx-list'>
|
<ul class='sf-list-reset sf-tx-list'>
|
||||||
{{#each payments}}
|
{{#each payments}}
|
||||||
<li class='sf-tx-item'>
|
<li class='sf-tx-item'>
|
||||||
<div><strong>{{amountCrypto}} {{../cryptoCurrency}}</strong></div>
|
<div><strong>{{amountCrypto}} {{../paymentLabel}}</strong></div>
|
||||||
<div class='sf-mono sf-text-subtle'>{{txHash}}</div>
|
<div class='sf-mono sf-text-subtle'>{{txHash}}</div>
|
||||||
<div class='sf-tx-status sf-tx-status--{{confirmationStatusVariant}}'>{{confirmationStatus}}</div>
|
<div class='sf-tx-status sf-tx-status--{{confirmationStatusVariant}}'>{{confirmationStatus}}</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<li class='sf-card sf-product-card'>
|
<li class='sf-card sf-product-card' id='product-card-{{selectedVariant.id}}'>
|
||||||
{{#with selectedVariant}}
|
{{#with selectedVariant}}
|
||||||
{{#if thumbnailUrl}}
|
{{#if thumbnailUrl}}
|
||||||
<a class='sf-product-card__media-link' href='{{detailHref}}'>
|
<a class='sf-product-card__media-link' href='{{detailHref}}'>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class='sf-footer__row'>
|
<div class='sf-footer__row'>
|
||||||
<div class='sf-footer__start'>
|
<div class='sf-footer__start'>
|
||||||
{{#if simplexLink}}
|
{{#if simplexLink}}
|
||||||
<a href='{{simplexLink}}' target='_blank' rel='noopener noreferrer'>Contact via Simplex</a>
|
<a href='{{simplexLink}}' target='_blank' rel='noopener noreferrer'>Contact via SimpleX</a>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a>
|
<a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
|
|
||||||
<nav class='sf-nav' aria-label='Shop navigation'>
|
<nav class='sf-nav' aria-label='Shop navigation'>
|
||||||
{{> nav-link active=shopNavActive.activeShopNav key='shop' href='/' label='Shop'}}
|
{{> nav-link active=shopNavActive.activeShopNav key='shop' href='/' label='Shop'}}
|
||||||
{{> nav-link active=shopNavActive.activeShopNav key='cart' href='/shop/cart' label='Cart' count=cartTotalQty}}
|
{{#if hasActiveCheckout}}
|
||||||
|
{{> nav-link active=shopNavActive.activeShopNav key='checkout' href='/shop/checkout' label='Checkout' count=cartTotalQty}}
|
||||||
|
{{else}}
|
||||||
|
{{> nav-link active=shopNavActive.activeShopNav key='cart' href='/shop/cart' label='Cart' count=cartTotalQty}}
|
||||||
|
{{/if}}
|
||||||
{{> nav-link active=shopNavActive.activeShopNav key='check-order' href='/shop/check-order' label='Check order'}}
|
{{> nav-link active=shopNavActive.activeShopNav key='check-order' href='/shop/check-order' label='Check order'}}
|
||||||
|
|
||||||
{{#each authorizedOrders}}
|
{{#each authorizedOrders}}
|
||||||
@@ -29,8 +33,21 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if fiatPerXmr}}
|
{{#if cryptoRates.length}}
|
||||||
<div class='sf-rate'>1 XMR = {{fiatPerXmr}} {{shopFiatCurrency}}</div>
|
<div class='sf-rates'>
|
||||||
|
{{#each cryptoRates}}
|
||||||
|
<span class='sf-rate'>
|
||||||
|
{{> storefront-image
|
||||||
|
class='sf-crypto-icon'
|
||||||
|
src=iconUrl
|
||||||
|
alt=paymentLabel
|
||||||
|
width='20'
|
||||||
|
height='20'
|
||||||
|
}}
|
||||||
|
<span>{{fiatPerCrypto}} {{../shopFiatCurrency}}</span>
|
||||||
|
</span>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -15,5 +15,12 @@
|
|||||||
class='sf-btn sf-btn--sm{{#ifeq themePreference 'dark'}} sf-btn--primary{{/ifeq}}'
|
class='sf-btn sf-btn--sm{{#ifeq themePreference 'dark'}} sf-btn--primary{{/ifeq}}'
|
||||||
{{#ifeq themePreference 'dark'}}aria-current='true'{{/ifeq}}
|
{{#ifeq themePreference 'dark'}}aria-current='true'{{/ifeq}}
|
||||||
>Dark</button>
|
>Dark</button>
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
name='theme'
|
||||||
|
value='system'
|
||||||
|
class='sf-btn sf-btn--sm{{#ifeq themePreference 'system'}} sf-btn--primary{{/ifeq}}'
|
||||||
|
{{#ifeq themePreference 'system'}}aria-current='true'{{/ifeq}}
|
||||||
|
>System</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export class StorefrontOrderService {
|
|||||||
.createQueryBuilder('order')
|
.createQueryBuilder('order')
|
||||||
.leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice')
|
.leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice')
|
||||||
.leftJoinAndSelect('checkoutInvoice.moneroDetails', 'moneroDetails')
|
.leftJoinAndSelect('checkoutInvoice.moneroDetails', 'moneroDetails')
|
||||||
|
.leftJoinAndSelect('checkoutInvoice.btcDetails', 'btcDetails')
|
||||||
.leftJoinAndSelect('checkoutInvoice.payments', 'payment')
|
.leftJoinAndSelect('checkoutInvoice.payments', 'payment')
|
||||||
.where('order.id = :orderId', { orderId })
|
.where('order.id = :orderId', { orderId })
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ describe('StorefrontOrderViewService', () => {
|
|||||||
expect(view.totals.shippingCostFiat).toBe(5);
|
expect(view.totals.shippingCostFiat).toBe(5);
|
||||||
expect(view.totals.grandTotalFiat).toBe(15);
|
expect(view.totals.grandTotalFiat).toBe(15);
|
||||||
expect(view.shippingInvoice).toMatchObject({
|
expect(view.shippingInvoice).toMatchObject({
|
||||||
cryptoCurrency: 'XMR',
|
paymentLabel: 'XMR',
|
||||||
paymentAddress: '4shipping'
|
paymentAddress: '4shipping'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface AppConfig {
|
|||||||
shippingNoteMinLength: number;
|
shippingNoteMinLength: number;
|
||||||
shippingNoteMaxLength: number;
|
shippingNoteMaxLength: number;
|
||||||
orderMessageMaxLength: number;
|
orderMessageMaxLength: number;
|
||||||
|
bitcoinWithdrawMaxFeeRateSatVbyte: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export type CryptoAtomicConverter = (amountAtomic: string, rounding?: Decimal.Rounding) => string;
|
||||||
@@ -6,5 +6,6 @@ export interface ElectrumWalletConfig {
|
|||||||
rpcUrl: string;
|
rpcUrl: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
walletPassword: string;
|
||||||
rpcTimeoutMs: number;
|
rpcTimeoutMs: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Maps to monero-wallet-rpc `sweep_all` / `transfer` priority (0–4).
|
||||||
|
*
|
||||||
|
* Absolute fee multipliers (fee algorithm 4, current mainnet) are 1, 5, 25, 1000 for priorities 1–4.
|
||||||
|
* Official GUI labels are scaled relative to Normal (priority 2): 0.2×, 1×, 5×, 200×.
|
||||||
|
*
|
||||||
|
* @see https://www.getmonero.org/resources/developer-guides/wallet-rpc.html#sweep_all
|
||||||
|
*/
|
||||||
|
export enum MoneroWithdrawPriority {
|
||||||
|
Automatic = 0,
|
||||||
|
Slow = 1,
|
||||||
|
Normal = 2,
|
||||||
|
Fast = 3,
|
||||||
|
Fastest = 4
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
export enum MoneroWalletSyncStatus {
|
export enum WalletSyncStatus {
|
||||||
Synced = 'synced',
|
Synced = 'synced',
|
||||||
Syncing = 'syncing',
|
Syncing = 'syncing',
|
||||||
Unknown = 'unknown'
|
Unknown = 'unknown'
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { resolveMinConfirmations } from './resolveMinConfirmations';
|
import { resolveMinConfirmations } from './resolveMinConfirmations';
|
||||||
|
|
||||||
const tiers = [
|
const tiers = [
|
||||||
{ upToTotalFiat: '25', minConfirmations: 0 },
|
{ upToTotalFiat: '25', minConfirmations: 1 },
|
||||||
{ upToTotalFiat: '250', minConfirmations: 5 },
|
{ upToTotalFiat: '250', minConfirmations: 5 },
|
||||||
{ minConfirmations: 10 }
|
{ minConfirmations: 10 }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
describe('resolveMinConfirmations', () => {
|
describe('resolveMinConfirmations', () => {
|
||||||
it('returns 0 for small orders (tx-detected tier)', () => {
|
it('returns the first tier for small orders', () => {
|
||||||
expect(resolveMinConfirmations(10, [...tiers])).toBe(0);
|
expect(resolveMinConfirmations(10, [...tiers])).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the middle tier for medium orders', () => {
|
it('returns the middle tier for medium orders', () => {
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ describe('formatInvoicePaymentConfirmationStatus', () => {
|
|||||||
).toBe('2/10');
|
).toBe('2/10');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats zero-confirmation tiers as confirmed', () => {
|
it('returns compact progress at zero confirmations', () => {
|
||||||
expect(
|
expect(
|
||||||
formatInvoicePaymentConfirmationStatus({
|
formatInvoicePaymentConfirmationStatus({
|
||||||
confirmations: 0,
|
confirmations: 0,
|
||||||
requiredConfirmations: 0,
|
requiredConfirmations: 3,
|
||||||
format: 'compact'
|
format: 'compact'
|
||||||
})
|
})
|
||||||
).toBe('Confirmed');
|
).toBe('0/3');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo';
|
import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo';
|
||||||
import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat';
|
import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat';
|
||||||
|
|
||||||
const formatRequiredConfirmationsLabel = (requiredConfirmations: number): string =>
|
|
||||||
requiredConfirmations === 0 ? '0 (tx-detected)' : String(requiredConfirmations);
|
|
||||||
|
|
||||||
export const formatInvoicePaymentConfirmationStatus = ({
|
export const formatInvoicePaymentConfirmationStatus = ({
|
||||||
confirmations,
|
confirmations,
|
||||||
requiredConfirmations,
|
requiredConfirmations,
|
||||||
@@ -29,5 +26,5 @@ export const formatInvoicePaymentConfirmationStatus = ({
|
|||||||
return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`;
|
return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${confirmations}/${formatRequiredConfirmationsLabel(requiredConfirmations)}`;
|
return `${confirmations}/${requiredConfirmations}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr';
|
import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr';
|
||||||
|
import { BTC_ATOMIC_PER_BTC } from '../../consts/btcAtomicPerBtc';
|
||||||
import type { Invoice } from '../../modules/payment/entities/Invoice';
|
import type { Invoice } from '../../modules/payment/entities/Invoice';
|
||||||
|
import type { InvoiceBtcDetails } from '../../modules/payment/entities/InvoiceBtcDetails';
|
||||||
import type { InvoiceMoneroDetails } from '../../modules/payment/entities/InvoiceMoneroDetails';
|
import type { InvoiceMoneroDetails } from '../../modules/payment/entities/InvoiceMoneroDetails';
|
||||||
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
|
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
|
||||||
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
|
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
|
||||||
import { InvoiceReason } from '../../modules/payment/types/InvoiceReason';
|
import { InvoiceReason } from '../../modules/payment/types/InvoiceReason';
|
||||||
|
import type { BtcDetailsOverrides, MoneroDetailsOverrides } from './types/ToStorefrontInvoiceViewTestTypes';
|
||||||
import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo';
|
import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo';
|
||||||
import * as generateQrCodeDataUrlModule from '../generateQrCodeDataUrl';
|
import * as generateQrCodeDataUrlModule from '../generateQrCodeDataUrl';
|
||||||
import { toStorefrontInvoiceView } from './toStorefrontInvoiceView';
|
import { toStorefrontInvoiceView } from './toStorefrontInvoiceView';
|
||||||
|
|
||||||
const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString();
|
const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString();
|
||||||
|
const oneBtcAtomic = BTC_ATOMIC_PER_BTC.toString();
|
||||||
const paymentAddress = '4StorefrontInvoiceViewTestAddress';
|
const paymentAddress = '4StorefrontInvoiceViewTestAddress';
|
||||||
|
|
||||||
const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =>
|
const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =>
|
||||||
@@ -21,10 +25,6 @@ const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =
|
|||||||
...overrides
|
...overrides
|
||||||
}) as InvoicePayment;
|
}) as InvoicePayment;
|
||||||
|
|
||||||
type MoneroDetailsOverrides = Partial<
|
|
||||||
Pick<InvoiceMoneroDetails, 'paymentAddressIndex' | 'fiatPerXmrAtCreation' | 'requiredConfirmations'>
|
|
||||||
>;
|
|
||||||
|
|
||||||
const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails =>
|
const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails =>
|
||||||
({
|
({
|
||||||
paymentAddressIndex: 1,
|
paymentAddressIndex: 1,
|
||||||
@@ -33,6 +33,13 @@ const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMone
|
|||||||
...overrides
|
...overrides
|
||||||
}) as InvoiceMoneroDetails;
|
}) as InvoiceMoneroDetails;
|
||||||
|
|
||||||
|
const buildBtcDetails = (overrides: BtcDetailsOverrides = {}): InvoiceBtcDetails =>
|
||||||
|
({
|
||||||
|
fiatPerBtcAtCreation: 60_000,
|
||||||
|
requiredConfirmations: 1,
|
||||||
|
...overrides
|
||||||
|
}) as InvoiceBtcDetails;
|
||||||
|
|
||||||
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
|
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
|
||||||
({
|
({
|
||||||
reason: InvoiceReason.Checkout,
|
reason: InvoiceReason.Checkout,
|
||||||
@@ -77,10 +84,21 @@ describe('toStorefrontInvoiceView', () => {
|
|||||||
await expect(toStorefrontInvoiceView(invoice)).rejects.toBeInstanceOf(InternalServerErrorException);
|
await expect(toStorefrontInvoiceView(invoice)).rejects.toBeInstanceOf(InternalServerErrorException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws for unsupported payment methods', async () => {
|
it('throws when bitcoin details are missing', async () => {
|
||||||
const invoice = buildInvoice({ paymentMethod: 'btc' as PaymentMethod });
|
const invoice = buildInvoice({
|
||||||
|
paymentMethod: PaymentMethod.Btc,
|
||||||
|
moneroDetails: null,
|
||||||
|
btcDetails: null,
|
||||||
|
expectedTotalAtomic: oneBtcAtomic
|
||||||
|
});
|
||||||
|
|
||||||
await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Unsupported payment method: btc');
|
await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Invoice is missing Bitcoin payment details');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for unsupported payment methods', async () => {
|
||||||
|
const invoice = buildInvoice({ paymentMethod: 'eth' as PaymentMethod, moneroDetails: null });
|
||||||
|
|
||||||
|
await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Unsupported payment method: eth');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +109,7 @@ describe('toStorefrontInvoiceView', () => {
|
|||||||
const view = await toStorefrontInvoiceView(invoice);
|
const view = await toStorefrontInvoiceView(invoice);
|
||||||
|
|
||||||
expect(view).toMatchObject({
|
expect(view).toMatchObject({
|
||||||
cryptoCurrency: 'XMR',
|
paymentLabel: 'XMR',
|
||||||
expectedTotalCrypto: '1.00000000',
|
expectedTotalCrypto: '1.00000000',
|
||||||
receivedTotalCrypto: null,
|
receivedTotalCrypto: null,
|
||||||
paymentAddress,
|
paymentAddress,
|
||||||
@@ -124,6 +142,27 @@ describe('toStorefrontInvoiceView', () => {
|
|||||||
expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=1.00000000`);
|
expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=1.00000000`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps bitcoin invoices and builds a bip21 qr code', async () => {
|
||||||
|
const invoice = buildInvoice({
|
||||||
|
paymentMethod: PaymentMethod.Btc,
|
||||||
|
moneroDetails: null,
|
||||||
|
btcDetails: buildBtcDetails(),
|
||||||
|
expectedTotalAtomic: oneBtcAtomic,
|
||||||
|
paymentAddress: 'bc1qstorefronttest'
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = await toStorefrontInvoiceView(invoice);
|
||||||
|
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
paymentLabel: 'BTC',
|
||||||
|
expectedTotalCrypto: '1.00000000',
|
||||||
|
paymentAddress: 'bc1qstorefronttest'
|
||||||
|
});
|
||||||
|
expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(
|
||||||
|
'bitcoin:bc1qstorefronttest?amount=1.00000000'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('formats expiry as seconds only when under one minute remains', async () => {
|
it('formats expiry as seconds only when under one minute remains', async () => {
|
||||||
const invoice = buildInvoice({
|
const invoice = buildInvoice({
|
||||||
expiresAt: new Date('2026-06-01T12:00:45.000Z')
|
expiresAt: new Date('2026-06-01T12:00:45.000Z')
|
||||||
@@ -424,19 +463,6 @@ describe('toStorefrontInvoiceView', () => {
|
|||||||
statusVariant: 'confirmed'
|
statusVariant: 'confirmed'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats tx-detected invoices as confirmed for payment status display', async () => {
|
|
||||||
const payment = buildPayment({ confirmations: 0 });
|
|
||||||
const invoice = buildInvoice({
|
|
||||||
moneroDetails: buildMoneroDetails({ requiredConfirmations: 0 }),
|
|
||||||
payments: [payment]
|
|
||||||
});
|
|
||||||
|
|
||||||
const view = await toStorefrontInvoiceView(invoice);
|
|
||||||
|
|
||||||
expect(view.showRefresh).toBe(false);
|
|
||||||
expect(view.payments[0].confirmationStatus).toBe('Confirmed');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('overpayment', () => {
|
describe('overpayment', () => {
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import Decimal from 'decimal.js';
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import type { StorefrontInvoicePaymentView } from '../../modules/storefrontCore/types/StorefrontInvoicePaymentView';
|
import type { StorefrontInvoicePaymentView } from '../../modules/storefrontCore/types/StorefrontInvoicePaymentView';
|
||||||
import type { StorefrontInvoiceView } from '../../modules/storefrontCore/types/StorefrontInvoiceView';
|
import type { StorefrontInvoiceView } from '../../modules/storefrontCore/types/StorefrontInvoiceView';
|
||||||
|
import type { CryptoAtomicConverter } from '../../types/CryptoAtomicConverter';
|
||||||
import type { Invoice } from '../../modules/payment/entities/Invoice';
|
import type { Invoice } from '../../modules/payment/entities/Invoice';
|
||||||
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
|
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
|
||||||
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
|
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
|
||||||
import dayjs from '../../plugins/dayjs';
|
import dayjs from '../../plugins/dayjs';
|
||||||
import { generateQrCodeDataUrl } from '../generateQrCodeDataUrl';
|
import { generateQrCodeDataUrl } from '../generateQrCodeDataUrl';
|
||||||
|
import { convertBtcAtomicToBtc } from '../bitcoin/convertBtcAtomicToBtc';
|
||||||
import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr';
|
import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr';
|
||||||
import { subtractAtomic } from '../atomic/subtractAtomic';
|
import { subtractAtomic } from '../atomic/subtractAtomic';
|
||||||
|
import { paymentMethodLabel } from '../../consts/paymentMethodLabel';
|
||||||
import { deriveInvoiceState } from './deriveInvoiceState';
|
import { deriveInvoiceState } from './deriveInvoiceState';
|
||||||
import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus';
|
import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus';
|
||||||
import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage';
|
import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage';
|
||||||
@@ -19,17 +22,44 @@ import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic
|
|||||||
export const toStorefrontInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => {
|
export const toStorefrontInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => {
|
||||||
switch (invoice.paymentMethod) {
|
switch (invoice.paymentMethod) {
|
||||||
case PaymentMethod.Xmr:
|
case PaymentMethod.Xmr:
|
||||||
return toXmrInvoiceView(invoice);
|
if (!invoice.moneroDetails) {
|
||||||
|
throw new InternalServerErrorException('Invoice is missing Monero payment details');
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildStorefrontInvoiceView({
|
||||||
|
invoice,
|
||||||
|
paymentLabel: paymentMethodLabel[PaymentMethod.Xmr],
|
||||||
|
convertAtomicToCrypto: convertXmrAtomicToXmr,
|
||||||
|
buildQrCodePayload: (paymentAddress, amountCrypto) =>
|
||||||
|
`monero:${paymentAddress}?tx_amount=${amountCrypto}`
|
||||||
|
});
|
||||||
|
case PaymentMethod.Btc:
|
||||||
|
if (!invoice.btcDetails) {
|
||||||
|
throw new InternalServerErrorException('Invoice is missing Bitcoin payment details');
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildStorefrontInvoiceView({
|
||||||
|
invoice,
|
||||||
|
paymentLabel: paymentMethodLabel[PaymentMethod.Btc],
|
||||||
|
convertAtomicToCrypto: convertBtcAtomicToBtc,
|
||||||
|
buildQrCodePayload: (paymentAddress, amountCrypto) => `bitcoin:${paymentAddress}?amount=${amountCrypto}`
|
||||||
|
});
|
||||||
default:
|
default:
|
||||||
throw new InternalServerErrorException(`Unsupported payment method: ${String(invoice.paymentMethod)}`);
|
throw new InternalServerErrorException(`Unsupported payment method: ${String(invoice.paymentMethod)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => {
|
const buildStorefrontInvoiceView = async ({
|
||||||
if (!invoice.moneroDetails) {
|
invoice,
|
||||||
throw new InternalServerErrorException('Invoice is missing Monero payment details');
|
paymentLabel,
|
||||||
}
|
convertAtomicToCrypto,
|
||||||
|
buildQrCodePayload
|
||||||
|
}: {
|
||||||
|
invoice: Invoice;
|
||||||
|
paymentLabel: string;
|
||||||
|
convertAtomicToCrypto: CryptoAtomicConverter;
|
||||||
|
buildQrCodePayload: (paymentAddress: string, amountCrypto: string) => string;
|
||||||
|
}): Promise<StorefrontInvoiceView> => {
|
||||||
const invoiceState = deriveInvoiceState(invoice);
|
const invoiceState = deriveInvoiceState(invoice);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -42,22 +72,20 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
|
|||||||
hasPendingConfirmations
|
hasPendingConfirmations
|
||||||
} = invoiceState;
|
} = invoiceState;
|
||||||
|
|
||||||
const cryptoCurrency = 'XMR';
|
const expectedTotalCrypto = convertAtomicToCrypto(invoice.expectedTotalAtomic);
|
||||||
const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic);
|
|
||||||
const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments);
|
const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments);
|
||||||
const receivedTotalCrypto = receivedAtomic === '0' ? null : convertXmrAtomicToXmr(receivedAtomic);
|
const receivedTotalCrypto = receivedAtomic === '0' ? null : convertAtomicToCrypto(receivedAtomic);
|
||||||
|
|
||||||
const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice);
|
const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice);
|
||||||
|
|
||||||
const payments = [...(invoice.payments ?? [])]
|
const payments = [...(invoice.payments ?? [])]
|
||||||
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
|
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
|
||||||
.map(payment => toPaymentView(payment, requiredConfirmations));
|
.map(payment => toPaymentView(payment, requiredConfirmations, convertAtomicToCrypto));
|
||||||
|
|
||||||
const showPaymentCapture = (isAwaitingPayment || isUnderpaid) && !isExpired;
|
const showPaymentCapture = (isAwaitingPayment || isUnderpaid) && !isExpired;
|
||||||
|
|
||||||
const expiresInDuration = showPaymentCapture ? formatInvoiceExpiresInDuration(invoice.expiresAt) : null;
|
const expiresInDuration = showPaymentCapture ? formatInvoiceExpiresInDuration(invoice.expiresAt) : null;
|
||||||
|
|
||||||
let remainingTotalCrypto: string | null = null;
|
|
||||||
let instructionPrefix: string | null = null;
|
let instructionPrefix: string | null = null;
|
||||||
let instructionAmountCrypto: string | null = null;
|
let instructionAmountCrypto: string | null = null;
|
||||||
let instructionSuffix: string | null = null;
|
let instructionSuffix: string | null = null;
|
||||||
@@ -65,22 +93,20 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
|
|||||||
|
|
||||||
if (showPaymentCapture) {
|
if (showPaymentCapture) {
|
||||||
if (isAwaitingPayment) {
|
if (isAwaitingPayment) {
|
||||||
remainingTotalCrypto = expectedTotalCrypto;
|
|
||||||
instructionAmountCrypto = expectedTotalCrypto;
|
instructionAmountCrypto = expectedTotalCrypto;
|
||||||
instructionPrefix = 'Send exactly';
|
instructionPrefix = 'Send exactly';
|
||||||
instructionSuffix = 'to the address below.';
|
instructionSuffix = 'to the address below.';
|
||||||
} else {
|
} else {
|
||||||
const remainingTotalCryptoAtomic = subtractAtomic(invoice.expectedTotalAtomic, receivedAtomic);
|
const remainingTotalCryptoAtomic = subtractAtomic(invoice.expectedTotalAtomic, receivedAtomic);
|
||||||
|
|
||||||
remainingTotalCrypto = convertXmrAtomicToXmr(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL);
|
instructionAmountCrypto = convertAtomicToCrypto(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL);
|
||||||
instructionAmountCrypto = remainingTotalCrypto;
|
|
||||||
instructionPrefix = 'Send';
|
instructionPrefix = 'Send';
|
||||||
instructionSuffix = 'more to the same address below.';
|
instructionSuffix = 'more to the same address below.';
|
||||||
}
|
}
|
||||||
|
|
||||||
qrCodeUrl = await generateQrCodeDataUrl(
|
const qrCodePayload = buildQrCodePayload(invoice.paymentAddress, instructionAmountCrypto);
|
||||||
`monero:${invoice.paymentAddress}?tx_amount=${instructionAmountCrypto}`
|
|
||||||
);
|
qrCodeUrl = await generateQrCodeDataUrl(qrCodePayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusMessage = resolveInvoiceStatusMessage(invoiceState);
|
const statusMessage = resolveInvoiceStatusMessage(invoiceState);
|
||||||
@@ -88,7 +114,7 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
|
|||||||
const statusVariant = resolveInvoiceStatusVariant(invoiceState);
|
const statusVariant = resolveInvoiceStatusVariant(invoiceState);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cryptoCurrency,
|
paymentLabel,
|
||||||
expectedTotalCrypto,
|
expectedTotalCrypto,
|
||||||
receivedTotalCrypto,
|
receivedTotalCrypto,
|
||||||
paymentAddress: invoice.paymentAddress,
|
paymentAddress: invoice.paymentAddress,
|
||||||
@@ -122,12 +148,16 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const toPaymentView = (payment: InvoicePayment, requiredConfirmations: number): StorefrontInvoicePaymentView => {
|
const toPaymentView = (
|
||||||
|
payment: InvoicePayment,
|
||||||
|
requiredConfirmations: number,
|
||||||
|
convertAtomicToCrypto: CryptoAtomicConverter
|
||||||
|
): StorefrontInvoicePaymentView => {
|
||||||
const isConfirmed = payment.confirmations >= requiredConfirmations;
|
const isConfirmed = payment.confirmations >= requiredConfirmations;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
txHash: payment.txHash,
|
txHash: payment.txHash,
|
||||||
amountCrypto: convertXmrAtomicToXmr(payment.amountAtomic),
|
amountCrypto: convertAtomicToCrypto(payment.amountAtomic),
|
||||||
confirmationStatus: formatInvoicePaymentConfirmationStatus({
|
confirmationStatus: formatInvoicePaymentConfirmationStatus({
|
||||||
confirmations: payment.confirmations,
|
confirmations: payment.confirmations,
|
||||||
requiredConfirmations,
|
requiredConfirmations,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { InvoiceBtcDetails } from '../../../modules/payment/entities/InvoiceBtcDetails';
|
||||||
|
import type { InvoiceMoneroDetails } from '../../../modules/payment/entities/InvoiceMoneroDetails';
|
||||||
|
|
||||||
|
export type MoneroDetailsOverrides = Partial<
|
||||||
|
Pick<InvoiceMoneroDetails, 'paymentAddressIndex' | 'fiatPerXmrAtCreation' | 'requiredConfirmations'>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type BtcDetailsOverrides = Partial<Pick<InvoiceBtcDetails, 'fiatPerBtcAtCreation' | 'requiredConfirmations'>>;
|
||||||
@@ -11,9 +11,11 @@ export const createOrderDetailQuery = (orderRepo: Repository<Order>, orderId: st
|
|||||||
.leftJoinAndSelect('order.discounts', 'orderDiscount')
|
.leftJoinAndSelect('order.discounts', 'orderDiscount')
|
||||||
.leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice')
|
.leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice')
|
||||||
.leftJoinAndSelect('checkoutInvoice.moneroDetails', 'checkoutMoneroDetails')
|
.leftJoinAndSelect('checkoutInvoice.moneroDetails', 'checkoutMoneroDetails')
|
||||||
|
.leftJoinAndSelect('checkoutInvoice.btcDetails', 'checkoutBtcDetails')
|
||||||
.leftJoinAndSelect('checkoutInvoice.payments', 'checkoutPayment')
|
.leftJoinAndSelect('checkoutInvoice.payments', 'checkoutPayment')
|
||||||
.leftJoinAndSelect('order.shippingInvoice', 'shippingInvoice')
|
.leftJoinAndSelect('order.shippingInvoice', 'shippingInvoice')
|
||||||
.leftJoinAndSelect('shippingInvoice.moneroDetails', 'shippingMoneroDetails')
|
.leftJoinAndSelect('shippingInvoice.moneroDetails', 'shippingMoneroDetails')
|
||||||
|
.leftJoinAndSelect('shippingInvoice.btcDetails', 'shippingBtcDetails')
|
||||||
.leftJoinAndSelect('shippingInvoice.payments', 'shippingPayment')
|
.leftJoinAndSelect('shippingInvoice.payments', 'shippingPayment')
|
||||||
.leftJoinAndSelect('order.messages', 'message')
|
.leftJoinAndSelect('order.messages', 'message')
|
||||||
.addSelect('order.accessToken')
|
.addSelect('order.accessToken')
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
|
||||||
|
import type { CryptoAtomicConverter } from '../../types/CryptoAtomicConverter';
|
||||||
|
import { convertBtcAtomicToBtc } from '../bitcoin/convertBtcAtomicToBtc';
|
||||||
|
import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr';
|
||||||
|
|
||||||
|
export const resolveAtomicToCryptoConverter = (paymentMethod: PaymentMethod): CryptoAtomicConverter => {
|
||||||
|
switch (paymentMethod) {
|
||||||
|
case PaymentMethod.Xmr:
|
||||||
|
return convertXmrAtomicToXmr;
|
||||||
|
case PaymentMethod.Btc:
|
||||||
|
return convertBtcAtomicToBtc;
|
||||||
|
default:
|
||||||
|
throw new InternalServerErrorException(`Unsupported payment method: ${String(paymentMethod)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Validate, ValidatorConstraint, type ValidatorConstraintInterface } from 'class-validator';
|
||||||
|
import { getElectrumWalletConfig } from '../../config';
|
||||||
|
import { ElectrumNetwork } from '../../types/ElectrumNetwork';
|
||||||
|
|
||||||
|
// Soft validation only: prefix/length checks to reject obvious garbage and wrong-network
|
||||||
|
// addresses early. Checksums and spendability are validated by Electrum on payto.
|
||||||
|
const BASE58 = '[1-9A-HJ-NP-Za-km-z]';
|
||||||
|
|
||||||
|
const NETWORK_ADDRESS_PATTERNS: Record<ElectrumNetwork, RegExp[]> = {
|
||||||
|
[ElectrumNetwork.Mainnet]: [
|
||||||
|
new RegExp(`^1${BASE58}{25,34}$`),
|
||||||
|
new RegExp(`^3${BASE58}{25,34}$`),
|
||||||
|
/^bc1[a-z0-9]{25,87}$/
|
||||||
|
],
|
||||||
|
[ElectrumNetwork.Testnet4]: [
|
||||||
|
new RegExp(`^[mn]${BASE58}{25,34}$`),
|
||||||
|
new RegExp(`^2${BASE58}{25,34}$`),
|
||||||
|
/^(?:tb1|bcrt1)[a-z0-9]{25,87}$/
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'isBitcoinAddress' })
|
||||||
|
class IsBitcoinAddressConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { network } = getElectrumWalletConfig();
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
return NETWORK_ADDRESS_PATTERNS[network].some(pattern => pattern.test(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(): string {
|
||||||
|
const { network } = getElectrumWalletConfig();
|
||||||
|
|
||||||
|
return `Enter a valid ${network} Bitcoin address.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IsBitcoinAddress = () => Validate(IsBitcoinAddressConstraint);
|
||||||
@@ -13,20 +13,13 @@ const validateTiers = (value: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validTiers =
|
const validTiers =
|
||||||
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||||
|
|
||||||
describe('IsConfirmationTiers', () => {
|
describe('IsConfirmationTiers', () => {
|
||||||
it('accepts valid default tiers', () => {
|
it('accepts valid default tiers', () => {
|
||||||
expect(validateTiers(validTiers)).toHaveLength(0);
|
expect(validateTiers(validTiers)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts numeric-only tiers without tx-detected (0)', () => {
|
|
||||||
const tiers =
|
|
||||||
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
|
||||||
|
|
||||||
expect(validateTiers(tiers)).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects empty string', () => {
|
it('rejects empty string', () => {
|
||||||
expect(validateTiers('').length).toBeGreaterThan(0);
|
expect(validateTiers('').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@@ -39,22 +32,16 @@ describe('IsConfirmationTiers', () => {
|
|||||||
expect(validateTiers('[]').length).toBeGreaterThan(0);
|
expect(validateTiers('[]').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects tx-detected (0) more than once', () => {
|
it('rejects minConfirmations: 0', () => {
|
||||||
const tiers =
|
const tiers =
|
||||||
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":0},{"minConfirmations":10}]';
|
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||||
|
|
||||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects tx-detected (0) on catch-all tier', () => {
|
it('rejects non-numeric minConfirmations', () => {
|
||||||
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]';
|
|
||||||
|
|
||||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects legacy tx-detected string', () => {
|
|
||||||
const tiers =
|
const tiers =
|
||||||
'[{"upToTotalFiat":"25","minConfirmations":"tx-detected"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
'[{"upToTotalFiat":"25","minConfirmations":"foo"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||||
|
|
||||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const isPositiveDecimalString = (value: string): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isMinConfirmations = (value: unknown): boolean =>
|
const isMinConfirmations = (value: unknown): boolean =>
|
||||||
typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
typeof value === 'number' && Number.isInteger(value) && value >= 1;
|
||||||
|
|
||||||
const isConfirmationTier = (value: unknown): value is ConfirmationTier => {
|
const isConfirmationTier = (value: unknown): value is ConfirmationTier => {
|
||||||
if (typeof value !== 'object' || value === null) {
|
if (typeof value !== 'object' || value === null) {
|
||||||
@@ -48,22 +48,12 @@ const isValidConfirmationTiersJson = (raw: string): boolean => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tiers = parsed;
|
const tiers = parsed;
|
||||||
const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length;
|
|
||||||
|
|
||||||
if (txDetectedTierCount > 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastTier = tiers[tiers.length - 1];
|
const lastTier = tiers[tiers.length - 1];
|
||||||
|
|
||||||
if (lastTier.upToTotalFiat !== undefined) {
|
if (lastTier.upToTotalFiat !== undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastTier.minConfirmations === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < tiers.length - 1; index++) {
|
for (let index = 0; index < tiers.length - 1; index++) {
|
||||||
const tier = tiers[index];
|
const tier = tiers[index];
|
||||||
|
|
||||||
@@ -86,7 +76,7 @@ class IsConfirmationTiersConstraint implements ValidatorConstraintInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(): string {
|
defaultMessage(): string {
|
||||||
return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be 0 (tx-detected) or an integer >= 1, 0 may appear only once and not on the catch-all tier, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
|
return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be an integer >= 1, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-1
@@ -11,6 +11,7 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
BitcoinWallet: typeof import('./components/wallet/BitcoinWallet.vue')['default']
|
||||||
CmsListPagination: typeof import('./components/CmsListPagination.vue')['default']
|
CmsListPagination: typeof import('./components/CmsListPagination.vue')['default']
|
||||||
CreateOrEditCategoryModal: typeof import('./components/CreateOrEditCategoryModal.vue')['default']
|
CreateOrEditCategoryModal: typeof import('./components/CreateOrEditCategoryModal.vue')['default']
|
||||||
CreateOrEditDiscountCodeModal: typeof import('./components/CreateOrEditDiscountCodeModal.vue')['default']
|
CreateOrEditDiscountCodeModal: typeof import('./components/CreateOrEditDiscountCodeModal.vue')['default']
|
||||||
@@ -56,9 +57,9 @@ declare module 'vue' {
|
|||||||
MoneroWallet: typeof import('./components/wallet/MoneroWallet.vue')['default']
|
MoneroWallet: typeof import('./components/wallet/MoneroWallet.vue')['default']
|
||||||
OrderCartPanel: typeof import('./components/OrderCartPanel.vue')['default']
|
OrderCartPanel: typeof import('./components/OrderCartPanel.vue')['default']
|
||||||
OrderChatPanel: typeof import('./components/OrderChatPanel.vue')['default']
|
OrderChatPanel: typeof import('./components/OrderChatPanel.vue')['default']
|
||||||
|
OrderInvoicePaymentPanel: typeof import('./components/OrderInvoicePaymentPanel.vue')['default']
|
||||||
OrderLineAutoFulfillmentModal: typeof import('./components/OrderLineAutoFulfillmentModal.vue')['default']
|
OrderLineAutoFulfillmentModal: typeof import('./components/OrderLineAutoFulfillmentModal.vue')['default']
|
||||||
OrderManualShippingQuotePanel: typeof import('./components/OrderManualShippingQuotePanel.vue')['default']
|
OrderManualShippingQuotePanel: typeof import('./components/OrderManualShippingQuotePanel.vue')['default']
|
||||||
OrderMoneroPaymentPanel: typeof import('./components/OrderMoneroPaymentPanel.vue')['default']
|
|
||||||
OrderPaymentPanel: typeof import('./components/OrderPaymentPanel.vue')['default']
|
OrderPaymentPanel: typeof import('./components/OrderPaymentPanel.vue')['default']
|
||||||
OrderSummaryPanel: typeof import('./components/OrderSummaryPanel.vue')['default']
|
OrderSummaryPanel: typeof import('./components/OrderSummaryPanel.vue')['default']
|
||||||
RichTextEditor: typeof import('./components/RichTextEditor.vue')['default']
|
RichTextEditor: typeof import('./components/RichTextEditor.vue')['default']
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
collapse-tags
|
collapse-tags
|
||||||
:max-collapse-tags="3"
|
:max-collapse-tags="3"
|
||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
|
fit-input-width
|
||||||
class="cms-scope-select"
|
class="cms-scope-select"
|
||||||
placeholder="Select categories"
|
placeholder="Select categories"
|
||||||
@update:model-value="value => updateScope({ categoryIds: value })"
|
@update:model-value="value => updateScope({ categoryIds: value })"
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
collapse-tags
|
collapse-tags
|
||||||
:max-collapse-tags="3"
|
:max-collapse-tags="3"
|
||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
|
fit-input-width
|
||||||
class="cms-scope-select"
|
class="cms-scope-select"
|
||||||
:remote-method="loadProducts"
|
:remote-method="loadProducts"
|
||||||
:loading="productSearchLoading"
|
:loading="productSearchLoading"
|
||||||
@@ -69,6 +71,7 @@
|
|||||||
collapse-tags
|
collapse-tags
|
||||||
:max-collapse-tags="3"
|
:max-collapse-tags="3"
|
||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
|
fit-input-width
|
||||||
class="cms-scope-select"
|
class="cms-scope-select"
|
||||||
:remote-method="loadVariants"
|
:remote-method="loadVariants"
|
||||||
:loading="variantSearchLoading"
|
:loading="variantSearchLoading"
|
||||||
|
|||||||
+20
-12
@@ -8,14 +8,12 @@
|
|||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
|
||||||
<el-descriptions-item label="Expected total">
|
<el-descriptions-item label="Expected total">
|
||||||
<span class="mono"
|
<span class="mono">{{ invoice.expectedTotalCrypto }} {{ invoice.paymentLabel }}</span>
|
||||||
>{{ invoice.expectedTotalCrypto }} {{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}</span
|
|
||||||
>
|
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
|
||||||
<el-descriptions-item v-if="fiatPerXmrAtCreation !== undefined" :label="`Rate at ${rateLabel}`">
|
<el-descriptions-item v-if="fiatPerCryptoAtCreation !== undefined" :label="`Rate at ${rateLabel}`">
|
||||||
{{ formatFiatPrice(fiatPerXmrAtCreation, config.shopFiatCurrency) }} /
|
{{ formatFiatPrice(fiatPerCryptoAtCreation, config.shopFiatCurrency) }} /
|
||||||
{{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}
|
{{ invoice.paymentLabel }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
@@ -37,9 +35,7 @@
|
|||||||
|
|
||||||
<el-table-column label="Amount" width="140">
|
<el-table-column label="Amount" width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="mono"
|
<span class="mono">{{ row.amountCrypto }} {{ invoice.paymentLabel }}</span>
|
||||||
>{{ row.amountCrypto }} {{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}</span
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
@@ -69,7 +65,7 @@
|
|||||||
import { computed, type PropType } from 'vue';
|
import { computed, type PropType } from 'vue';
|
||||||
import { config } from '@/config';
|
import { config } from '@/config';
|
||||||
import type { InvoiceExtended } from '@/types/payment/InvoiceExtended';
|
import type { InvoiceExtended } from '@/types/payment/InvoiceExtended';
|
||||||
import { paymentMethodCryptoCurrency } from '@/types/payment/PaymentMethod';
|
import { PaymentMethod } from '@/types/payment/PaymentMethod';
|
||||||
import { formatDate } from '@/utils/formatDate';
|
import { formatDate } from '@/utils/formatDate';
|
||||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||||
import { resolveInvoiceStatusTagType } from '@/utils/order/resolveInvoiceStatusTagType';
|
import { resolveInvoiceStatusTagType } from '@/utils/order/resolveInvoiceStatusTagType';
|
||||||
@@ -85,13 +81,25 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'No Monero payment session.'
|
default: 'No payment session.'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const payments = computed(() => props.invoice?.payments ?? []);
|
const payments = computed(() => props.invoice?.payments ?? []);
|
||||||
|
|
||||||
const fiatPerXmrAtCreation = computed(() => props.invoice?.moneroDetails?.fiatPerXmrAtCreation);
|
const fiatPerCryptoAtCreation = computed(() => {
|
||||||
|
const invoice = props.invoice;
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.paymentMethod === PaymentMethod.Btc) {
|
||||||
|
return invoice.btcDetails?.fiatPerBtcAtCreation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return invoice.moneroDetails?.fiatPerXmrAtCreation;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user