fix(bot): remove redundant deposit amount step, add Visa/MC label, Mercuryo auth note, enforce product completeness

- Remove deposit amount-selection step; deposit_wallet_<TYPE> now routes directly to instruction
- Add VISA / Mastercard text to Mercuryo card button (i18n en/es/de)
- Add deposit_important5: Mercuryo 1-payment-without-auth + top-up-reserve warning
- Admin validation: description + photo required on product create/update (products.js, catalogProducts.js)
- Cleanup orphan uploaded files on validation failure (catalogProducts.js)
- Bot guards: fallback to products.no_description / products.no_photo for incomplete products
- Add vitest + 7 purchase edge-case tests (src/__tests__/userProductHandler.test.js)
- Bump version to v1.2.1
This commit is contained in:
NW
2026-07-18 14:28:26 +01:00
parent b6eb42ccfc
commit 776d0e8552
17 changed files with 344 additions and 103 deletions

View File

@@ -0,0 +1,223 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import UserProductHandler from '../handlers/userHandlers/userProductHandler.js';
const en = {
products: {
product_price: '💰 Price',
product_description: '📝 Description',
product_available: '📦 Available',
product_category: 'Category',
buy_now: '🛒 Buy Now',
back: '« Back',
infinite_stock: '∞ Always available',
no_description: 'No description provided',
no_photo: 'No photo available',
error_loading_product: 'Error loading product details. Please try again.'
}
};
vi.mock('../context/bot.js', () => {
const bot = {
deleteMessage: vi.fn(),
sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }),
sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 })
};
return { __esModule: true, default: bot, botAvailable: true };
});
vi.mock('../context/userStates.js', () => ({
__esModule: true,
default: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
delete: vi.fn()
}
}));
vi.mock('../services/userService.js', () => ({
__esModule: true,
default: {
getUserByTelegramId: vi.fn().mockResolvedValue({ language: 'en' })
}
}));
vi.mock('../i18n/index.js', () => ({
__esModule: true,
tForUser: (lang) => (key) => getNestedValue(en, key) || key
}));
vi.mock('../services/productService.js', () => ({
__esModule: true,
default: {
getDetailedProductById: vi.fn(),
getProductById: vi.fn()
}
}));
vi.mock('../utils/logger.js', () => ({
__esModule: true,
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
fatal: vi.fn()
}
}));
vi.mock('../config/config.js', () => ({
__esModule: true,
default: {
BOT_TOKEN: 'test-token',
ADMIN_IDS: ['123456789'],
SUPER_ADMIN_IDS: ['123456789'],
SUPPORT_LINK: 'https://t.me/support',
DEFAULT_LANGUAGE: 'en',
ENCRYPTION_KEY: 'x'.repeat(64)
}
}));
function getNestedValue(obj, keyPath) {
return keyPath.split('.').reduce((o, k) => o?.[k], obj);
}
import bot from '../context/bot.js';
import userStates from '../context/userStates.js';
import ProductService from '../services/productService.js';
function makeCallbackQuery(productId, overrides = {}) {
return {
id: 'callback-id',
from: { id: 12345, first_name: 'Test' },
message: {
chat: { id: 67890 },
message_id: 111
},
data: `shop_product_${productId}`,
...overrides
};
}
function baseProduct(overrides = {}) {
return {
id: 1,
name: 'Test Product',
price: 10,
description: 'A nice product',
quantity_in_stock: 5,
photo_url: 'http://example.com/photo.jpg',
is_mono: 0,
category_name: 'Test Category',
category_id: 2,
location_id: 3,
...overrides
};
}
describe('UserProductHandler.handleProductSelection edge cases', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders fallback text when description and photo are missing, sends message, does not send photo', async () => {
ProductService.getDetailedProductById.mockResolvedValue(
baseProduct({ description: '', photo_url: '' })
);
await expect(
UserProductHandler.handleProductSelection(makeCallbackQuery(1))
).resolves.toBeUndefined();
expect(bot.deleteMessage).toHaveBeenCalledWith(67890, 111);
expect(bot.sendPhoto).not.toHaveBeenCalled();
expect(bot.sendMessage).toHaveBeenCalledTimes(1);
const sentText = bot.sendMessage.mock.calls[0][1];
expect(sentText).toContain(en.products.no_description);
expect(sentText).toContain(en.products.no_photo);
});
it('renders no-photo line and does not call sendPhoto when photo_url is missing', async () => {
ProductService.getDetailedProductById.mockResolvedValue(
baseProduct({ photo_url: '' })
);
await expect(
UserProductHandler.handleProductSelection(makeCallbackQuery(1))
).resolves.toBeUndefined();
expect(bot.sendPhoto).not.toHaveBeenCalled();
const sentText = bot.sendMessage.mock.calls[0][1];
expect(sentText).toContain(en.products.no_photo);
expect(sentText).toContain('A nice product');
});
it('renders description fallback and calls sendPhoto when description is missing', async () => {
ProductService.getDetailedProductById.mockResolvedValue(
baseProduct({ description: '' })
);
await expect(
UserProductHandler.handleProductSelection(makeCallbackQuery(1))
).resolves.toBeUndefined();
expect(bot.sendPhoto).toHaveBeenCalledWith(67890, 'http://example.com/photo.jpg', { caption: 'Public photo' });
const sentText = bot.sendMessage.mock.calls[0][1];
expect(sentText).toContain(en.products.no_description);
expect(sentText).not.toContain(en.products.no_photo);
});
it('takes normal path with both fields present, calls sendPhoto, omits fallback text', async () => {
ProductService.getDetailedProductById.mockResolvedValue(baseProduct());
await expect(
UserProductHandler.handleProductSelection(makeCallbackQuery(1))
).resolves.toBeUndefined();
expect(bot.sendPhoto).toHaveBeenCalledWith(67890, 'http://example.com/photo.jpg', { caption: 'Public photo' });
const sentText = bot.sendMessage.mock.calls[0][1];
expect(sentText).toContain('A nice product');
expect(sentText).not.toContain(en.products.no_description);
expect(sentText).not.toContain(en.products.no_photo);
});
it('does not crash when ProductService returns null, sends error message', async () => {
ProductService.getDetailedProductById.mockResolvedValue(null);
await expect(
UserProductHandler.handleProductSelection(makeCallbackQuery(1))
).resolves.toBeUndefined();
expect(bot.sendMessage).toHaveBeenCalledWith(67890, en.products.error_loading_product);
expect(bot.sendPhoto).not.toHaveBeenCalled();
});
it('renders infinite_stock line for mono product with missing description', async () => {
ProductService.getDetailedProductById.mockResolvedValue(
baseProduct({ is_mono: 1, description: '', photo_url: '' })
);
await expect(
UserProductHandler.handleProductSelection(makeCallbackQuery(1))
).resolves.toBeUndefined();
const sentText = bot.sendMessage.mock.calls[0][1];
expect(sentText).toContain(en.products.no_description);
expect(sentText).toContain(en.products.infinite_stock);
expect(sentText).toContain(en.products.no_photo);
expect(bot.sendPhoto).not.toHaveBeenCalled();
});
it('renders non-mono product with quantity_in_stock=0 without crashing', async () => {
ProductService.getDetailedProductById.mockResolvedValue(
baseProduct({ quantity_in_stock: 0, photo_url: '' })
);
await expect(
UserProductHandler.handleProductSelection(makeCallbackQuery(1))
).resolves.toBeUndefined();
const sentText = bot.sendMessage.mock.calls[0][1];
expect(sentText).toContain('0 pcs');
expect(bot.sendPhoto).not.toHaveBeenCalled();
});
});

View File

@@ -230,13 +230,13 @@ function buildProductFormHtml(action, catOptions, locations) {
</div>
<div class="col-md-12">
<label class="form-label">Description</label>
<textarea name="description" class="form-control form-control-sm" rows="3" placeholder="Public description"></textarea>
<label class="form-label">Description <span class="text-danger">*</span></label>
<textarea name="description" class="form-control form-control-sm" rows="3" placeholder="Public description" required></textarea>
</div>
<div class="col-12">
<hr class="my-2">
<h6 class="text-muted mb-2">Public Photo</h6>
<h6 class="text-muted mb-2">Public Photo <span class="text-danger">*</span></h6>
</div>
<div class="col-md-6">
<label class="form-label">Photo URL</label>
@@ -245,6 +245,7 @@ function buildProductFormHtml(action, catOptions, locations) {
<div class="col-md-6">
<label class="form-label">Or Upload Photo</label>
<input type="file" name="photo_file" accept="image/*" class="form-control form-control-sm">
<small class="text-danger">Photo is required — upload a file or paste a URL.</small>
</div>
<div class="col-12">

View File

@@ -3,10 +3,18 @@ import multer from 'multer';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
import fs from 'fs';
import db from '../../config/database.js';
import { asyncHandler } from '../errorHandler.js';
import { validateCsrfFromBody } from '../csrf.js';
function cleanupUploadedFiles(req) {
for (const field of ['photo_file', 'hidden_photo_file']) {
const p = req.files?.[field]?.[0]?.path;
if (p) fs.unlink(p, () => {});
}
}
const __dirname = dirname(fileURLToPath(import.meta.url));
const uploadsDir = join(__dirname, '..', '..', '..', 'uploads');
const storage = multer.diskStorage({
@@ -34,10 +42,13 @@ router.post('/products', upload.fields([{ name: 'photo_file' }, { name: 'hidden_
if (!validateCsrfFromBody(req, res)) return;
const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url,
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id, is_mono } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
if (!name || !price || !category_id) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error'); }
const priceNum = parseFloat(price);
if (isNaN(priceNum) || priceNum <= 0) return res.redirect('/catalog?msg=Price+must+be+greater+than+0&msg_type=error');
if (isNaN(priceNum) || priceNum <= 0) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Price+must+be+greater+than+0&msg_type=error'); }
const desc = (description || '').trim();
if (!desc) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Description+is+required&msg_type=error'); }
const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || '');
if (!pu.trim()) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Photo+is+required&msg_type=error'); }
const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || '');
const locId = location_id || (await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]))?.location_id || null;
const monoFlag = is_mono === '1' ? 1 : 0;
@@ -45,7 +56,7 @@ router.post('/products', upload.fields([{ name: 'photo_file' }, { name: 'hidden_
await db.runAsync(`INSERT INTO products (name,price,quantity_in_stock,description,photo_url,hidden_photo_url,
hidden_coordinates,hidden_description,private_data,category_id,subcategory_id,location_id,is_mono)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[name.trim(), priceNum, stockQty, description || '', pu, hu,
[name.trim(), priceNum, stockQty, desc, pu, hu,
hidden_coordinates || '', hidden_description || '', private_data || '', category_id, subcategory_id || null, locId, monoFlag]);
res.redirect('/catalog?msg=Product+added&msg_type=success');
}));
@@ -54,17 +65,20 @@ router.post('/products/:id/edit', upload.fields([{ name: 'photo_file' }, { name:
if (!validateCsrfFromBody(req, res)) return;
const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url,
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id, is_mono } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
if (!name || !price || !category_id) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error'); }
const priceNum = parseFloat(price);
if (isNaN(priceNum) || priceNum <= 0) return res.redirect('/catalog?msg=Price+must+be+greater+than+0&msg_type=error');
if (isNaN(priceNum) || priceNum <= 0) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Price+must+be+greater+than+0&msg_type=error'); }
const desc = (description || '').trim();
if (!desc) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Description+is+required&msg_type=error'); }
const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || '');
if (!pu.trim()) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Photo+is+required&msg_type=error'); }
const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || '');
const locId = location_id || (await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]))?.location_id || null;
const monoFlag = is_mono === '1' ? 1 : 0;
const stockQty = monoFlag ? 999999 : (parseInt(quantity_in_stock, 10) || 0);
await db.runAsync(`UPDATE products SET name=?,price=?,quantity_in_stock=?,description=?,photo_url=?,hidden_photo_url=?,
hidden_coordinates=?,hidden_description=?,private_data=?,category_id=?,subcategory_id=?,location_id=?,is_mono=? WHERE id=?`,
[name.trim(), priceNum, stockQty, description || '', pu, hu,
[name.trim(), priceNum, stockQty, desc, pu, hu,
hidden_coordinates || '', hidden_description || '', private_data || '', category_id, subcategory_id || null, locId, monoFlag, req.params.id]);
res.redirect('/catalog?msg=Product+updated&msg_type=success');
}));

View File

@@ -26,12 +26,20 @@ router.post('/', asyncHandler(async (req, res) => {
if (isNaN(priceNum) || priceNum <= 0) {
return res.redirect('/products?error=Price+must+be+greater+than+0');
}
const desc = (description || '').trim();
if (!desc) {
return res.redirect('/products?error=Description+is+required');
}
const photo = (photo_url || '').trim();
if (!photo) {
return res.redirect('/products?error=Photo+is+required');
}
const monoFlag = is_mono === '1' ? 1 : 0;
const stockQty = monoFlag ? 999999 : (quantity_in_stock || 0);
await db.runAsync(
`INSERT INTO products (name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id, location_id, is_mono)
VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT location_id FROM categories WHERE id = ?), ?)`,
[name, priceNum, stockQty, description || '', photo_url || '', category_id, subcategory_id || null, category_id, monoFlag]
[name, priceNum, stockQty, desc, photo, category_id, subcategory_id || null, category_id, monoFlag]
);
res.redirect('/products');
}));
@@ -56,12 +64,20 @@ router.post('/:id/update', asyncHandler(async (req, res) => {
if (isNaN(priceNum) || priceNum <= 0) {
return res.redirect('/products?error=Price+must+be+greater+than+0');
}
const desc = (description || '').trim();
if (!desc) {
return res.redirect('/products?error=Description+is+required');
}
const photo = (photo_url || '').trim();
if (!photo) {
return res.redirect('/products?error=Photo+is+required');
}
const monoFlag = is_mono === '1' ? 1 : 0;
const stockQty = monoFlag ? 999999 : (quantity_in_stock || 0);
await db.runAsync(
`UPDATE products SET name=?, price=?, quantity_in_stock=?, description=?, photo_url=?, category_id=?, subcategory_id=?, location_id=?, is_mono=?
WHERE id=?`,
[name, priceNum, stockQty, description || '', photo_url || '', category_id, subcategory_id || null, location_id || null, monoFlag, req.params.id]
[name, priceNum, stockQty, desc, photo, category_id, subcategory_id || null, location_id || null, monoFlag, req.params.id]
);
res.redirect('/products');
}));

View File

@@ -28,7 +28,7 @@
<svg class="sa-icon sa-thin">
<use href="/icons/sprite.svg#tag"></use>
</svg>
<span class="fs-xs opacity-70">v1.2.0</span>
<span class="fs-xs opacity-70">v1.2.1</span>
</div>
</div>
</aside>
@@ -43,9 +43,16 @@
</div>
<div class="modal-body">
<div class="alert alert-info mb-3">
<strong>Current:</strong> v1.2.0 &middot; 2026-07-08
<strong>Current:</strong> v1.2.1 &middot; 2026-07-18
</div>
<h6 class="fw-bold mb-2">v1.2.1 <span class="text-muted fs-sm">&mdash; 2026-07-18</span></h6>
<ul class="small mb-3">
<li><span class="badge bg-info">refactor</span> Removed deposit amount-selection step; deposit_wallet_ now goes directly to Mercuryo instructions</li>
<li><span class="badge bg-primary">feat</span> Updated Mercuryo button text to include VISA/Mastercard branding in all locales</li>
<li><span class="badge bg-primary">feat</span> Added deposit_important5 note about Mercuryo authorization limits and top-up reserve</li>
</ul>
<h6 class="fw-bold mb-2">v1.2.0 <span class="text-muted fs-sm">&mdash; 2026-07-08</span></h6>
<ul class="small mb-3">
<li><span class="badge bg-warning text-dark">fix</span> Disabled CSRF checks in admin panel for Tor / onion zone compatibility</li>

View File

@@ -65,12 +65,12 @@
</select>
</div>
<div class="col-md-6">
<label for="edit-photo" class="form-label">Photo URL</label>
<input type="url" class="form-control" id="edit-photo" name="photo_url" value="<%= product.photo_url || '' %>" placeholder="https://example.com/image.jpg">
<label for="edit-photo" class="form-label">Photo URL <span class="text-danger">*</span></label>
<input type="url" class="form-control" id="edit-photo" name="photo_url" value="<%= product.photo_url || '' %>" placeholder="https://example.com/image.jpg" required>
</div>
<div class="col-12">
<label for="edit-description" class="form-label">Description</label>
<textarea class="form-control" id="edit-description" name="description" rows="4" placeholder="Enter product description"><%= product.description || '' %></textarea>
<label for="edit-description" class="form-label">Description <span class="text-danger">*</span></label>
<textarea class="form-control" id="edit-description" name="description" rows="4" placeholder="Enter product description" required><%= product.description || '' %></textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">

View File

@@ -46,12 +46,12 @@
</select>
</div>
<div class="col-md-6">
<label for="add-photo" class="form-label">Photo URL</label>
<input type="url" class="form-control" id="add-photo" name="photo_url" placeholder="https://example.com/image.jpg">
<label for="add-photo" class="form-label">Photo URL <span class="text-danger">*</span></label>
<input type="url" class="form-control" id="add-photo" name="photo_url" placeholder="https://example.com/image.jpg" required>
</div>
<div class="col-12">
<label for="add-description" class="form-label">Description</label>
<textarea class="form-control" id="add-description" name="description" rows="3" placeholder="Enter product description"></textarea>
<label for="add-description" class="form-label">Description <span class="text-danger">*</span></label>
<textarea class="form-control" id="add-description" name="description" rows="3" placeholder="Enter product description" required></textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">

View File

@@ -431,7 +431,7 @@ export default class UserProductHandler {
📦 ${product.name}
${t('products.product_price')}: $${product.price}
${t('products.product_description')}: ${product.description}
${t('products.product_description')}: ${product.description || t('products.no_description')}
${product.is_mono ? t('products.infinite_stock') : `${t('products.product_available')}: ${product.quantity_in_stock} pcs`}
${t('products.product_category')}: ${product.category_name}
@@ -443,6 +443,9 @@ export default class UserProductHandler {
photoMessage = await sendProductPhoto(chatId, product.photo_url, 'Public photo');
}
// Append no-photo line to message if no photo
const displayMessage = product.photo_url ? message : message + `📷 ${t('products.no_photo')}\n`;
const keyboard = {
inline_keyboard: product.is_mono ? [
[{ text: t('products.buy_now'), callback_data: `buy_product_${productId}` }],
@@ -467,7 +470,7 @@ export default class UserProductHandler {
};
// Отправляем сообщение с кнопками
const productMessage = await bot.sendMessage(chatId, message, {
const productMessage = await bot.sendMessage(chatId, displayMessage, {
reply_markup: keyboard,
parse_mode: 'HTML'
});

View File

@@ -5,9 +5,6 @@ import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { tForUser } from '../../../i18n/index.js';
import WalletUtils from '../../../utils/walletUtils.js';
const DEPOSIT_AMOUNTS = [25, 50, 100, 250, 500];
const CRYPTO_SYMBOLS = {
BTC: '₿',
@@ -79,39 +76,10 @@ export default class DepositHandler {
}
}
static async handleDepositSelectAmount(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const walletType = callbackQuery.data.replace('deposit_wallet_', '');
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
const amountButtons = DEPOSIT_AMOUNTS.map(amount => ([{
text: `$${amount}`,
callback_data: `deposit_amount_${walletType}_${amount}`
}]));
amountButtons.push([{ text: t('wallet.back'), callback_data: 'top_up_wallet' }]);
await bot.editMessageText(
t('wallet.deposit_select_amount', { type: walletType }),
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: amountButtons }
}
);
}
static async handleDepositInstruction(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const parts = callbackQuery.data.replace('deposit_amount_', '').split('_');
const walletType = parts[0];
const amount = parts[1];
const walletType = callbackQuery.data.replace('deposit_wallet_', '');
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
@@ -135,7 +103,7 @@ export default class DepositHandler {
const mercuryoUrl = 'https://mercuryo.io/';
let message = `${t('wallet.deposit_title', { type: walletType, amount })}
let message = `${t('wallet.deposit_title', { type: walletType })}
`;
message += `${t('wallet.deposit_instructions_title')}
@@ -143,11 +111,11 @@ export default class DepositHandler {
`;
message += `${t('wallet.deposit_step1')}
`;
message += `${t('wallet.deposit_step2', { amount, type: walletType })}
message += `${t('wallet.deposit_step2', { type: walletType })}
`;
message += `${t('wallet.deposit_step3')}
`;
message += `${t('wallet.deposit_step4')}
message += `${t('wallet.deposit_step4', { type: walletType })}
`;
message += `${t('wallet.deposit_step5')}
`;
@@ -167,17 +135,18 @@ export default class DepositHandler {
`;
message += `${t('wallet.deposit_important3')}
`;
message += `${t('wallet.deposit_important4')}`;
message += `${t('wallet.deposit_important4')}
`;
message += `${t('wallet.deposit_important5')}`;
const keyboard = {
inline_keyboard: [
[
{ text: t('wallet.deposit_open_mercuryo'), url: mercuryoUrl },
{ text: t('wallet.deposit_pay_crypto'), callback_data: `deposit_crypto_${walletType}_${amount}` }
{ text: t('wallet.deposit_pay_crypto'), callback_data: `deposit_crypto_${walletType}` }
],
[
{ text: t('wallet.deposit_copy_address'), callback_data: `deposit_copy_${walletType}` },
{ text: t('wallet.deposit_change_amount'), callback_data: `deposit_wallet_${walletType}` }
{ text: t('wallet.deposit_copy_address'), callback_data: `deposit_copy_${walletType}` }
],
[
{ text: t('wallet.deposit_choose_different'), callback_data: 'top_up_wallet' },
@@ -246,9 +215,7 @@ export default class DepositHandler {
static async handleDepositCryptoQR(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const parts = callbackQuery.data.replace('deposit_crypto_', '').split('_');
const walletType = parts[0];
const amount = parts[1];
const walletType = callbackQuery.data.replace('deposit_crypto_', '');
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
@@ -274,10 +241,7 @@ export default class DepositHandler {
const upperType = walletType.toUpperCase();
if (upperType === 'BTC') {
const prices = await WalletUtils.getCryptoPrices();
const btcPrice = prices.btc || 0;
const btcAmount = btcPrice > 0 ? (parseFloat(amount) / btcPrice).toFixed(8) : '0';
paymentUri = `bitcoin:${wallet.address}?amount=${btcAmount}`;
paymentUri = `bitcoin:${wallet.address}`;
} else if (upperType === 'ETH' || upperType === 'USDT' || upperType === 'USDC') {
paymentUri = `ethereum:${wallet.address}`;
} else if (upperType === 'LTC') {
@@ -297,7 +261,7 @@ export default class DepositHandler {
caption += `${t('wallet.deposit_crypto_scan_qr')}\n\n`;
caption += `${t('wallet.deposit_crypto_address', { type: walletType })}\n`;
caption += `\`${wallet.address}\`\n\n`;
caption += `${t('wallet.deposit_crypto_instruction', { amount, type: walletType })}`;
caption += `${t('wallet.deposit_crypto_instruction', { type: walletType })}`;
await bot.sendPhoto(chatId, qrBuffer, {
caption,
@@ -306,7 +270,7 @@ export default class DepositHandler {
inline_keyboard: [
[
{ text: t('wallet.deposit_copy_address'), callback_data: `deposit_copy_${walletType}` },
{ text: t('wallet.back_to_deposit'), callback_data: `deposit_amount_${walletType}_${amount}` }
{ text: t('wallet.back_to_deposit'), callback_data: `deposit_wallet_${walletType}` }
]
]
}

View File

@@ -18,7 +18,6 @@ export default {
handleViewArchivedWallets: ArchiveHandler.handleViewArchivedWallets,
handleBackToBalance: BalanceHandler.handleBackToBalance,
handleDepositSelectWallet: DepositHandler.handleDepositSelectWallet,
handleDepositSelectAmount: DepositHandler.handleDepositSelectAmount,
handleDepositInstruction: DepositHandler.handleDepositInstruction,
handleDepositCopyAddress: DepositHandler.handleDepositCopyAddress,
getNetworkName: WalletHelpers.getNetworkName,

View File

@@ -55,7 +55,9 @@
"error_loading_product": "Fehler beim Laden der Produktdetails. Bitte versuche es erneut.",
"not_found": "Standort nicht gefunden. Zurück zum vorherigen Menü.",
"mono_product": "📦 Digitales Produkt",
"infinite_stock": "∞ Immer verfügbar"
"infinite_stock": "∞ Immer verfügbar",
"no_description": "Keine Beschreibung",
"no_photo": "Kein Foto verfügbar"
},
"purchase": {
"summary": "🛒 Kaufübersicht:",
@@ -156,13 +158,12 @@
"wallet_not_found": "Wallet nicht gefunden. Bitte versuche es erneut.",
"wallet_not_found_short": "Wallet nicht gefunden.",
"deposit_select_gateway": "💳 *Einzahlung über Krypto*\n\nWähle das Wallet zum Aufladen:",
"deposit_select_amount": "💳 *{{type}} aufladen*\n\nWähle den Betrag (USD) zum Aufladen:",
"deposit_title": "💳 *{{type}} aufladen — €{{amount}}*",
"deposit_title": "💳 *{{type}} aufladen*",
"deposit_instructions_title": "📋 *Schritt-für-Schritt-Anleitung:*",
"deposit_step1": "1⃣ Tippe auf *Adresse kopieren* unten, um deine Wallet-Adresse zu kopieren",
"deposit_step2": "2⃣ Öffne Mercuryo über den Button unten",
"deposit_step2": "2⃣ Öffne Mercuryo über den Button unten und gib den gewünschten Betrag ein",
"deposit_step3": "3⃣ Füge die kopierte Adresse als Empfangswallet ein",
"deposit_step4": "4⃣ Wähle die richtige Währung: *{{type}}* und den Betrag €{{amount}}",
"deposit_step4": "4⃣ Wähle die richtige Währung: *{{type}}* und gib den gewünschten Betrag ein",
"deposit_step5": "5⃣ Bezahle mit deiner Bankkarte",
"deposit_step6": "6⃣ Die Kryptowährung wird innerhalb von 5\\-30 Minuten in deinem Wallet eintreffen",
"deposit_your_address": "🔐 *Deine {{type}} Wallet-Adresse:*",
@@ -171,14 +172,14 @@
"deposit_important2": "• Stelle sicher\\, dass du die richtige Währung in Mercuryo auswählst",
"deposit_important3": "• E-Mail \\+ Telefonnummer können von Mercuryo für die Verifizierung verlangt werden",
"deposit_important4": "• Wenn die Kryptowährung nicht innerhalb von 30 Minuten eintrifft \\— kontaktiere den Mercuryo-Support",
"deposit_open_mercuryo": "💳 Mercuryo",
"deposit_important5": "• Ohne Autorisierung erlaubt Mercuryo nur 1 Zahlung. Danach fordert Mercuryo Autorisierung & Identitätsverifikation. Lade dein Guthaben einmalig mit Reserve auf.",
"deposit_open_mercuryo": "💳 Mit Karte zahlen (VISA / Mastercard)",
"deposit_pay_crypto": "💰 Mit Krypto zahlen",
"deposit_crypto_title": "💳 *{{type}} über Krypto einzahlen*",
"deposit_crypto_address": "🔐 *Deine {{type}} Wallet-Adresse:*",
"deposit_crypto_instruction": "Sende {{amount}} USD in {{type}} an die Adresse oben. Der QR-Code enthält die Zahlungsadresse.",
"deposit_crypto_instruction": "Sende den gewünschten Betrag in USD in {{type}} an die Adresse oben. Der QR-Code enthält die Zahlungsadresse.",
"deposit_crypto_scan_qr": "📱 Scanne den QR-Code unten, um die Wallet-Adresse zu erhalten",
"deposit_copy_address": "📋 Adresse kopieren",
"deposit_change_amount": "🔄 Betrag ändern",
"deposit_choose_different": "💸 Anderes Wallet wählen",
"deposit_wallet_address": "{{type}} Wallet-Adresse:",
"back_to_deposit": "« Zurück zur Einzahlung",

View File

@@ -55,7 +55,9 @@
"error_loading_product": "Error loading product details. Please try again.",
"not_found": "Location not found. Returning to previous menu.",
"mono_product": "📦 Digital Product",
"infinite_stock": "∞ Always available"
"infinite_stock": "∞ Always available",
"no_description": "No description provided",
"no_photo": "No photo available"
},
"purchase": {
"summary": "🛒 Purchase Summary:",
@@ -156,13 +158,12 @@
"wallet_not_found": "Wallet not found. Please try again.",
"wallet_not_found_short": "Wallet not found.",
"deposit_select_gateway": "💳 *Deposit via Crypto*\n\nSelect the wallet you want to top up:",
"deposit_select_amount": "💳 *Deposit {{type}}*\n\nSelect the amount (USD) you want to deposit:",
"deposit_title": "💳 *Deposit {{type}} — €{{amount}}*",
"deposit_title": "💳 *Deposit {{type}}*",
"deposit_instructions_title": "📋 *Step\\-by\\-step instructions:*",
"deposit_step1": "1⃣ Tap *Copy Address* below to copy your wallet address",
"deposit_step2": "2⃣ Open Mercuryo via the button below",
"deposit_step2": "2⃣ Open Mercuryo via the button below and enter your desired amount",
"deposit_step3": "3⃣ Paste the copied address as the receiving wallet",
"deposit_step4": "4⃣ Select the correct currency: *{{type}}* and the amount €{{amount}}",
"deposit_step4": "4⃣ Select the correct currency: *{{type}}* and enter your desired amount",
"deposit_step5": "5⃣ Complete the payment with your bank card",
"deposit_step6": "6⃣ Crypto will arrive in your wallet within 5\\-30 minutes",
"deposit_your_address": "🔐 *Your {{type}} wallet address:*",
@@ -171,14 +172,14 @@
"deposit_important2": "• Make sure you select the correct currency in Mercuryo",
"deposit_important3": "• Email \\+ phone may be required by Mercuryo for verification",
"deposit_important4": "• If crypto doesn't arrive within 30 min \\— contact Mercuryo support",
"deposit_open_mercuryo": "💳 Mercuryo",
"deposit_important5": "• Without authorization, Mercuryo allows only 1 payment. Then Mercuryo will request authorization & identity verification. Top up your balance with a reserve in one go.",
"deposit_open_mercuryo": "💳 Pay with Card (VISA / Mastercard)",
"deposit_pay_crypto": "💰 Pay with Crypto",
"deposit_crypto_title": "💳 *Deposit {{type}} via Crypto*",
"deposit_crypto_address": "🔐 *Your {{type}} wallet address:*",
"deposit_crypto_instruction": "Send {{amount}} USD in {{type}} to the address above. The QR code contains the payment address.",
"deposit_crypto_instruction": "Send the desired amount in USD in {{type}} to the address above. The QR code contains the payment address.",
"deposit_crypto_scan_qr": "📱 Scan the QR code below to get the wallet address",
"deposit_copy_address": "📋 Copy Address",
"deposit_change_amount": "🔄 Change Amount",
"deposit_choose_different": "💸 Choose Different Wallet",
"deposit_wallet_address": "{{type}} wallet address:",
"back_to_deposit": "« Back to Deposit",

View File

@@ -55,7 +55,9 @@
"error_loading_product": "Error al cargar detalles del producto. Inténtalo de nuevo.",
"not_found": "Ubicación no encontrada. Volviendo al menú anterior.",
"mono_product": "📦 Producto Digital",
"infinite_stock": "∞ Siempre disponible"
"infinite_stock": "∞ Siempre disponible",
"no_description": "Sin descripción",
"no_photo": "Sin foto disponible"
},
"purchase": {
"summary": "🛒 Resumen de compra:",
@@ -156,13 +158,12 @@
"wallet_not_found": "Billetera no encontrada. Inténtalo de nuevo.",
"wallet_not_found_short": "Billetera no encontrada.",
"deposit_select_gateway": "💳 *Depositar vía Cripto*\n\nSelecciona la billetera que quieres recargar:",
"deposit_select_amount": "💳 *Depositar {{type}}*\n\nSelecciona la cantidad (USD) que quieres depositar:",
"deposit_title": "💳 *Depositar {{type}} — €{{amount}}*",
"deposit_title": "💳 *Depositar {{type}}*",
"deposit_instructions_title": "📋 *Instrucciones paso a paso:*",
"deposit_step1": "1⃣ Toca *Copiar dirección* abajo para copiar tu dirección de billetera",
"deposit_step2": "2⃣ Abre Mercuryo con el botón de abajo",
"deposit_step2": "2⃣ Abre Mercuryo con el botón de abajo e ingresa la cantidad deseada",
"deposit_step3": "3⃣ Pega la dirección copiada como billetera receptora",
"deposit_step4": "4⃣ Selecciona la moneda correcta: *{{type}}* y la cantidad €{{amount}}",
"deposit_step4": "4⃣ Selecciona la moneda correcta: *{{type}}* e ingresa la cantidad deseada",
"deposit_step5": "5⃣ Paga con tu tarjeta bancaria",
"deposit_step6": "6⃣ Las criptomonedas llegarán a tu billetera en 5\\-30 minutos",
"deposit_your_address": "🔐 *Tu dirección de billetera {{type}}:*",
@@ -171,14 +172,14 @@
"deposit_important2": "• Asegúrate de seleccionar la moneda correcta en Mercuryo",
"deposit_important3": "• Correo \\+ teléfono pueden ser requeridos por Mercuryo para verificación",
"deposit_important4": "• Si las criptomonedas no llegan en 30 min \\— contacta al soporte de Mercuryo",
"deposit_open_mercuryo": "💳 Mercuryo",
"deposit_important5": "• Sin autorización, Mercuryo permite solo 1 pago. Luego Mercuryo pedirá autorización y verificación de identidad. Recarga tu saldo con reserva de una sola vez.",
"deposit_open_mercuryo": "💳 Pagar con Tarjeta (VISA / Mastercard)",
"deposit_pay_crypto": "💰 Pagar con Cripto",
"deposit_crypto_title": "💳 *Depositar {{type}} vía Cripto*",
"deposit_crypto_address": "🔐 *Tu dirección de billetera {{type}}:*",
"deposit_crypto_instruction": "Envía {{amount}} USD en {{type}} a la dirección de arriba. El código QR contiene la dirección de pago.",
"deposit_crypto_instruction": "Envía la cantidad deseada en USD en {{type}} a la dirección de arriba. El código QR contiene la dirección de pago.",
"deposit_crypto_scan_qr": "📱 Escanea el código QR de abajo para obtener la dirección de la billetera",
"deposit_copy_address": "📋 Copiar dirección",
"deposit_change_amount": "🔄 Cambiar cantidad",
"deposit_choose_different": "💸 Elegir otra billetera",
"deposit_wallet_address": "Dirección de billetera {{type}}:",
"back_to_deposit": "« Volver al depósito",

View File

@@ -119,10 +119,6 @@ export function registerRoutes() {
await DepositHandler.handleDepositSelectWallet(cq);
});
callbackRouter.registerPrefix('deposit_wallet_', async (cq) => {
logDebug(cq.data, 'handleDepositSelectAmount');
await DepositHandler.handleDepositSelectAmount(cq);
});
callbackRouter.registerPrefix('deposit_amount_', async (cq) => {
logDebug(cq.data, 'handleDepositInstruction');
await DepositHandler.handleDepositInstruction(cq);
});