8d3cd50098
- Мигрирован confirm_purchase() на calculate_classic_new_subscription_price() - Мигрирован compute_simple_subscription_price на делегацию в PricingEngine - Мигрирован handle_custom_confirm на calculate_tariff_purchase_price() - Мигрированы daily confirm handlers (confirm_daily_tariff_purchase, confirm_daily_tariff_switch, confirm_instant_switch daily path) - Мигрирован gift.py на calculate_tariff_purchase_price() - Мигрированы FSM cache prices (select_period, select_devices, toggle_country) - Добавлен lock_user_for_pricing в admin_buy_tariff_execute (TOCTOU fix) - Добавлен lock + recompute в _auto_add_devices и _auto_add_traffic - Исправлено двойное применение promo-offer в simple_subscription (критический баг) - Унифицирован daily price display (group+offer) на всех 6 поверхностях - PricingEngine.get_addon_discount_percent: добавлен promo_group= kwarg - PricingEngine._calculate_switch_to/from_daily: добавлен promo-offer discount - Удалён мёртвый код из common.py (_get_addon_discount_percent_for_user) - Miniapp period_discounts: исправлен доступ через get_discount_percent()
829 lines
33 KiB
Python
829 lines
33 KiB
Python
import structlog
|
|
from aiogram import Dispatcher, F, types
|
|
from aiogram.exceptions import TelegramBadRequest
|
|
from aiogram.fsm.context import FSMContext
|
|
from aiogram.types import InaccessibleMessage
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.database.crud.transaction import get_user_transactions
|
|
from app.database.models import TransactionType, User
|
|
from app.handlers.subscription.autopay import handle_confirm_unlink, handle_saved_cards_list, handle_unlink_card
|
|
from app.keyboards.inline import (
|
|
get_back_keyboard,
|
|
get_balance_keyboard,
|
|
get_pagination_keyboard,
|
|
get_payment_methods_keyboard,
|
|
)
|
|
from app.localization.texts import get_texts
|
|
from app.states import BalanceStates
|
|
from app.utils.decorators import error_handler
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
TRANSACTIONS_PER_PAGE = 10
|
|
|
|
CREDIT_TRANSACTION_TYPES: frozenset[str] = frozenset(
|
|
{
|
|
TransactionType.DEPOSIT.value,
|
|
TransactionType.REFERRAL_REWARD.value,
|
|
TransactionType.REFUND.value,
|
|
TransactionType.POLL_REWARD.value,
|
|
}
|
|
)
|
|
|
|
|
|
async def route_payment_by_method(
|
|
message: types.Message, db_user: User, amount_kopeks: int, state: FSMContext, payment_method: str
|
|
) -> bool:
|
|
"""
|
|
Роутер платежей по методу оплаты.
|
|
|
|
Args:
|
|
message: Сообщение для ответа
|
|
db_user: Пользователь БД
|
|
amount_kopeks: Сумма в копейках
|
|
state: FSM состояние
|
|
payment_method: Метод оплаты (yookassa, stars, cryptobot и т.д.)
|
|
|
|
Returns:
|
|
True если платеж обработан, False если метод неизвестен
|
|
"""
|
|
if payment_method == 'stars':
|
|
from .stars import process_stars_payment_amount
|
|
|
|
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
|
|
return True
|
|
|
|
# Все остальные методы требуют сессию БД
|
|
from app.database.database import AsyncSessionLocal
|
|
|
|
if payment_method == 'yookassa':
|
|
from .yookassa import process_yookassa_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
if payment_method == 'yookassa_sbp':
|
|
from .yookassa import process_yookassa_sbp_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
if payment_method == 'mulenpay':
|
|
from .mulenpay import process_mulenpay_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
if payment_method == 'platega':
|
|
from .platega import process_platega_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_platega_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
if payment_method == 'wata':
|
|
from .wata import process_wata_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
if payment_method == 'pal24':
|
|
from .pal24 import process_pal24_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
if payment_method == 'cryptobot':
|
|
from .cryptobot import process_cryptobot_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
if payment_method == 'heleket':
|
|
from .heleket import process_heleket_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
if payment_method == 'cloudpayments':
|
|
from .cloudpayments import process_cloudpayments_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_cloudpayments_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
if payment_method in ('freekassa', 'freekassa_sbp', 'freekassa_card'):
|
|
from .freekassa import process_freekassa_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_freekassa_payment_amount(
|
|
message, db_user, db, amount_kopeks, state, payment_method=payment_method
|
|
)
|
|
return True
|
|
|
|
if payment_method == 'kassa_ai':
|
|
from .kassa_ai import process_kassa_ai_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
if payment_method == 'riopay':
|
|
from .riopay import process_riopay_payment_amount
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
await process_riopay_payment_amount(message, db_user, db, amount_kopeks, state)
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
async def get_quick_amount_buttons(language: str, user: User) -> list:
|
|
"""
|
|
Generate quick amount buttons with user-specific pricing and discounts.
|
|
|
|
Uses PricingEngine as the single source of truth for all price calculations,
|
|
including base period price, devices, servers, traffic, and per-category discounts.
|
|
|
|
Args:
|
|
language: User's language for formatting
|
|
user: User object to calculate personalized discounts
|
|
|
|
Returns:
|
|
List of button rows for inline keyboard
|
|
"""
|
|
if not settings.is_quick_amount_buttons_enabled():
|
|
return []
|
|
|
|
from app.database.crud.subscription import get_subscription_by_user_id
|
|
from app.database.database import AsyncSessionLocal
|
|
from app.services.pricing_engine import pricing_engine
|
|
|
|
texts = get_texts(language)
|
|
|
|
buttons = []
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
subscription = await get_subscription_by_user_id(db, user.id)
|
|
|
|
tariff = None
|
|
tariff_periods = None
|
|
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
|
|
tariff = subscription.tariff
|
|
if tariff and tariff.period_prices:
|
|
tariff_periods = sorted(int(k) for k in tariff.period_prices.keys())
|
|
|
|
if tariff_periods:
|
|
periods = tariff_periods[:6]
|
|
else:
|
|
periods = settings.get_available_subscription_periods()[:6]
|
|
|
|
for period in periods:
|
|
try:
|
|
if tariff and tariff_periods and period in tariff_periods:
|
|
result = await pricing_engine.calculate_tariff_purchase_price(
|
|
tariff,
|
|
period,
|
|
device_limit=subscription.device_limit if subscription else None,
|
|
user=user,
|
|
)
|
|
elif subscription:
|
|
result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
|
|
else:
|
|
result = await pricing_engine.calculate_classic_new_subscription_price(
|
|
db,
|
|
period,
|
|
[],
|
|
0,
|
|
settings.DEFAULT_DEVICE_LIMIT,
|
|
user=user,
|
|
)
|
|
|
|
total_price = result.final_total
|
|
original_total = result.original_total
|
|
|
|
if total_price <= 0:
|
|
continue
|
|
|
|
callback_data = f'quick_amount_{total_price}'
|
|
period_label = f'{period} дней'
|
|
|
|
has_discount = original_total > total_price and original_total > 0
|
|
if has_discount:
|
|
discount_pct = round((original_total - total_price) * 100 / original_total)
|
|
if discount_pct > 0:
|
|
button_text = (
|
|
f'{texts.format_price(original_total)} ➜ '
|
|
f'{texts.format_price(total_price)} '
|
|
f'(-{discount_pct}%) • {period_label}'
|
|
)
|
|
else:
|
|
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
|
else:
|
|
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
|
|
|
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
|
|
except Exception:
|
|
logger.warning('Failed to calculate price for period', period=period)
|
|
continue
|
|
|
|
keyboard_rows = []
|
|
for i in range(0, len(buttons), 2):
|
|
keyboard_rows.append(buttons[i : i + 2])
|
|
|
|
return keyboard_rows
|
|
|
|
|
|
@error_handler
|
|
async def show_balance_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
|
# Проверяем, доступно ли сообщение
|
|
if isinstance(callback.message, InaccessibleMessage):
|
|
await callback.answer()
|
|
return
|
|
|
|
texts = get_texts(db_user.language)
|
|
|
|
balance_text = texts.BALANCE_INFO.format(balance=texts.format_price(db_user.balance_kopeks))
|
|
|
|
reply_markup = get_balance_keyboard(db_user.language)
|
|
|
|
try:
|
|
if callback.message and callback.message.text:
|
|
await callback.message.edit_text(balance_text, reply_markup=reply_markup)
|
|
elif callback.message and callback.message.caption:
|
|
await callback.message.edit_caption(balance_text, reply_markup=reply_markup)
|
|
else:
|
|
await callback.message.answer(balance_text, reply_markup=reply_markup)
|
|
except TelegramBadRequest as error:
|
|
logger.warning('Failed to edit balance message, sending a new one instead', error=error)
|
|
await callback.message.answer(balance_text, reply_markup=reply_markup)
|
|
await callback.answer()
|
|
|
|
|
|
@error_handler
|
|
async def show_balance_history(callback: types.CallbackQuery, db_user: User, db: AsyncSession, page: int = 1):
|
|
texts = get_texts(db_user.language)
|
|
|
|
offset = (page - 1) * TRANSACTIONS_PER_PAGE
|
|
|
|
raw_transactions = await get_user_transactions(db, db_user.id, limit=TRANSACTIONS_PER_PAGE * 3, offset=offset)
|
|
|
|
seen_transactions = set()
|
|
unique_transactions = []
|
|
|
|
for transaction in raw_transactions:
|
|
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
|
transaction_key = (transaction.amount_kopeks, transaction.description, rounded_time)
|
|
|
|
if transaction_key not in seen_transactions:
|
|
seen_transactions.add(transaction_key)
|
|
unique_transactions.append(transaction)
|
|
|
|
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
|
|
break
|
|
|
|
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
|
|
seen_all = set()
|
|
total_unique = 0
|
|
|
|
for transaction in all_transactions:
|
|
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
|
transaction_key = (transaction.amount_kopeks, transaction.description, rounded_time)
|
|
if transaction_key not in seen_all:
|
|
seen_all.add(transaction_key)
|
|
total_unique += 1
|
|
|
|
if not unique_transactions:
|
|
await callback.message.edit_text('📊 История операций пуста', reply_markup=get_back_keyboard(db_user.language))
|
|
await callback.answer()
|
|
return
|
|
|
|
text = '📊 <b>История операций</b>\n\n'
|
|
|
|
for transaction in unique_transactions:
|
|
is_credit = transaction.type in CREDIT_TRANSACTION_TYPES
|
|
emoji = '💰' if is_credit else '💸'
|
|
amount_text = (
|
|
f'+{texts.format_price(transaction.amount_kopeks)}'
|
|
if is_credit
|
|
else f'-{texts.format_price(abs(transaction.amount_kopeks))}'
|
|
)
|
|
|
|
text += f'{emoji} {amount_text}\n'
|
|
text += f'📝 {transaction.description}\n'
|
|
text += f'📅 {transaction.created_at.strftime("%d.%m.%Y %H:%M")}\n\n'
|
|
|
|
keyboard = []
|
|
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
|
|
|
|
if total_pages > 1:
|
|
pagination_row = get_pagination_keyboard(page, total_pages, 'balance_history', db_user.language)
|
|
keyboard.extend(pagination_row)
|
|
|
|
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
|
|
|
await callback.message.edit_text(
|
|
text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML'
|
|
)
|
|
await callback.answer()
|
|
|
|
|
|
@error_handler
|
|
async def handle_balance_history_pagination(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
|
page = int(callback.data.split('_')[-1])
|
|
await show_balance_history(callback, db_user, db, page)
|
|
|
|
|
|
@error_handler
|
|
async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
|
from app.config import settings
|
|
from app.utils.payment_utils import get_payment_methods_text
|
|
|
|
texts = get_texts(db_user.language)
|
|
|
|
# Проверка ограничения на пополнение
|
|
if getattr(db_user, 'restriction_topup', False):
|
|
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
|
support_url = settings.get_support_contact_url()
|
|
keyboard = []
|
|
if support_url:
|
|
keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
|
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
|
|
|
await callback.message.edit_text(
|
|
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n'
|
|
'Если вы считаете это ошибкой, вы можете обжаловать решение.',
|
|
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
|
)
|
|
await callback.answer()
|
|
return
|
|
|
|
payment_text = get_payment_methods_text(db_user.language)
|
|
|
|
full_text = payment_text
|
|
|
|
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
|
|
|
# Если сообщение недоступно, отправляем новое
|
|
if isinstance(callback.message, InaccessibleMessage):
|
|
await callback.message.answer(full_text, reply_markup=keyboard, parse_mode='HTML')
|
|
await callback.answer()
|
|
return
|
|
|
|
try:
|
|
await callback.message.edit_text(full_text, reply_markup=keyboard, parse_mode='HTML')
|
|
except TelegramBadRequest:
|
|
try:
|
|
await callback.message.edit_caption(full_text, reply_markup=keyboard, parse_mode='HTML')
|
|
except TelegramBadRequest:
|
|
try:
|
|
await callback.message.delete()
|
|
except TelegramBadRequest:
|
|
pass
|
|
await callback.message.answer(full_text, reply_markup=keyboard, parse_mode='HTML')
|
|
|
|
await callback.answer()
|
|
|
|
|
|
@error_handler
|
|
async def handle_payment_methods_unavailable(callback: types.CallbackQuery, db_user: User):
|
|
texts = get_texts(db_user.language)
|
|
|
|
await callback.answer(
|
|
texts.t(
|
|
'PAYMENT_METHODS_UNAVAILABLE_ALERT',
|
|
'⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.',
|
|
),
|
|
show_alert=True,
|
|
)
|
|
|
|
|
|
@error_handler
|
|
async def handle_successful_topup_with_cart(user_id: int, amount_kopeks: int, bot, db: AsyncSession):
|
|
from aiogram.fsm.storage.base import StorageKey
|
|
|
|
from app.bot import dp
|
|
from app.database.crud.user import get_user_by_id
|
|
|
|
user = await get_user_by_id(db, user_id)
|
|
if not user:
|
|
return
|
|
|
|
# Email-only users don't have telegram_id - skip Telegram notification
|
|
if not user.telegram_id:
|
|
logger.info('Skipping cart notification for email-only user', user_id=user_id)
|
|
return
|
|
|
|
storage = dp.storage
|
|
key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id)
|
|
|
|
try:
|
|
state_data = await storage.get_data(key)
|
|
current_state = await storage.get_state(key)
|
|
|
|
if current_state == 'SubscriptionStates:cart_saved_for_topup' and state_data.get('saved_cart'):
|
|
texts = get_texts(user.language)
|
|
total_price = state_data.get('total_price', 0)
|
|
|
|
keyboard = types.InlineKeyboardMarkup(
|
|
inline_keyboard=[
|
|
[
|
|
types.InlineKeyboardButton(
|
|
text='🛒 Вернуться к оформлению подписки', callback_data='return_to_saved_cart'
|
|
)
|
|
],
|
|
[types.InlineKeyboardButton(text='💰 Мой баланс', callback_data='menu_balance')],
|
|
[types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='back_to_menu')],
|
|
]
|
|
)
|
|
|
|
if 0 < total_price <= user.balance_kopeks:
|
|
balance_hint = 'Средств на балансе достаточно для оформления.'
|
|
else:
|
|
missing = max(total_price - user.balance_kopeks, 0)
|
|
balance_hint = f'Не хватает: {texts.format_price(missing)}'
|
|
|
|
success_text = (
|
|
f'✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n'
|
|
f'💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n'
|
|
f'🛒 У вас есть сохранённая корзина на {texts.format_price(total_price)}\n'
|
|
f'{balance_hint}\n\n'
|
|
f'Хотите продолжить оформление?'
|
|
)
|
|
|
|
await bot.send_message(
|
|
chat_id=user.telegram_id, text=success_text, reply_markup=keyboard, parse_mode='HTML'
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error('Ошибка обработки успешного пополнения с корзиной', error=e)
|
|
|
|
|
|
@error_handler
|
|
async def request_support_topup(callback: types.CallbackQuery, db_user: User):
|
|
texts = get_texts(db_user.language)
|
|
|
|
if not settings.is_support_topup_enabled():
|
|
await callback.answer(
|
|
texts.t(
|
|
'SUPPORT_TOPUP_DISABLED',
|
|
'Пополнение через поддержку отключено. Попробуйте другой способ оплаты.',
|
|
),
|
|
show_alert=True,
|
|
)
|
|
return
|
|
|
|
user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}'
|
|
support_text = f"""
|
|
🛠️ <b>Пополнение через поддержку</b>
|
|
|
|
Для пополнения баланса обратитесь в техподдержку:
|
|
{settings.get_support_contact_display_html()}
|
|
|
|
Укажите:
|
|
• ID: {user_id_display}
|
|
• Сумму пополнения
|
|
• Способ оплаты
|
|
|
|
⏰ Время обработки: 1-24 часа
|
|
|
|
<b>Доступные способы:</b>
|
|
• Криптовалюта
|
|
• Переводы между банками
|
|
• Другие платежные системы
|
|
"""
|
|
|
|
keyboard = types.InlineKeyboardMarkup(
|
|
inline_keyboard=[
|
|
[
|
|
types.InlineKeyboardButton(
|
|
text='💬 Написать в поддержку', url=settings.get_support_contact_url() or 'https://t.me/'
|
|
)
|
|
],
|
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')],
|
|
]
|
|
)
|
|
|
|
await callback.message.edit_text(support_text, reply_markup=keyboard, parse_mode='HTML')
|
|
await callback.answer()
|
|
|
|
|
|
@error_handler
|
|
async def process_topup_amount(message: types.Message, db_user: User, state: FSMContext):
|
|
texts = get_texts(db_user.language)
|
|
|
|
try:
|
|
if not message.text:
|
|
if message.successful_payment:
|
|
logger.info(
|
|
'Получено сообщение об успешном платеже без текста, обработчик суммы пополнения завершает работу'
|
|
)
|
|
await state.clear()
|
|
return
|
|
|
|
await message.answer(texts.INVALID_AMOUNT, reply_markup=get_back_keyboard(db_user.language))
|
|
return
|
|
|
|
amount_text = message.text.strip()
|
|
if not amount_text:
|
|
await message.answer(texts.INVALID_AMOUNT, reply_markup=get_back_keyboard(db_user.language))
|
|
return
|
|
|
|
amount_rubles = float(amount_text.replace(',', '.'))
|
|
|
|
if amount_rubles < 1:
|
|
await message.answer('Минимальная сумма пополнения: 1 ₽')
|
|
return
|
|
|
|
if amount_rubles > 50000:
|
|
await message.answer('Максимальная сумма пополнения: 50,000 ₽')
|
|
return
|
|
|
|
amount_kopeks = int(amount_rubles * 100)
|
|
data = await state.get_data()
|
|
payment_method = data.get('payment_method', 'stars')
|
|
|
|
if payment_method in ['yookassa', 'yookassa_sbp']:
|
|
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
|
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
|
await message.answer(f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽')
|
|
return
|
|
|
|
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
|
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
|
await message.answer(
|
|
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽'.replace(',', ' ')
|
|
)
|
|
return
|
|
|
|
if not await route_payment_by_method(message, db_user, amount_kopeks, state, payment_method):
|
|
await message.answer('Неизвестный способ оплаты')
|
|
|
|
except ValueError:
|
|
await message.answer(texts.INVALID_AMOUNT, reply_markup=get_back_keyboard(db_user.language))
|
|
|
|
|
|
@error_handler
|
|
async def handle_sbp_payment(callback: types.CallbackQuery, db: AsyncSession):
|
|
try:
|
|
local_payment_id = int(callback.data.split('_')[-1])
|
|
|
|
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
|
|
|
|
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
|
|
|
|
if not payment:
|
|
await callback.answer('❌ Платеж не найден', show_alert=True)
|
|
return
|
|
|
|
import json
|
|
|
|
metadata = json.loads(payment.metadata_json) if payment.metadata_json else {}
|
|
confirmation_token = metadata.get('confirmation_token')
|
|
|
|
if not confirmation_token:
|
|
await callback.answer('❌ Токен подтверждения не найден', show_alert=True)
|
|
return
|
|
|
|
await callback.message.answer(
|
|
f'Для оплаты через СБП откройте приложение вашего банка и подтвердите платеж.\\n\\n'
|
|
f'Если у вас не открылось банковское приложение автоматически, вы можете:\\n'
|
|
f'1. Скопировать этот токен: <code>{confirmation_token}</code>\\n'
|
|
f'2. Открыть приложение вашего банка\\n'
|
|
f'3. Найти функцию оплаты по токену\\n'
|
|
f'4. Вставить токен и подтвердить платеж',
|
|
parse_mode='HTML',
|
|
)
|
|
|
|
await callback.answer('Информация об оплате отправлена', show_alert=True)
|
|
|
|
except Exception as e:
|
|
logger.error('Ошибка обработки embedded платежа СБП', error=e)
|
|
await callback.answer('❌ Ошибка обработки платежа', show_alert=True)
|
|
|
|
|
|
@error_handler
|
|
async def handle_quick_amount_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext):
|
|
"""
|
|
Обработчик выбора суммы через кнопки быстрого выбора
|
|
"""
|
|
# Проверяем, что пользователь в правильном состоянии FSM
|
|
current_state = await state.get_state()
|
|
if current_state != BalanceStates.waiting_for_amount:
|
|
await callback.answer('❌ Сначала выберите способ оплаты', show_alert=True)
|
|
return
|
|
|
|
# Извлекаем сумму из callback_data
|
|
try:
|
|
amount_kopeks = int(callback.data.split('_')[-1])
|
|
|
|
# Получаем метод оплаты из состояния
|
|
data = await state.get_data()
|
|
payment_method = data.get('payment_method', 'yookassa')
|
|
|
|
# Роутим платеж на соответствующий обработчик
|
|
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, payment_method):
|
|
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
|
return
|
|
|
|
except ValueError:
|
|
await callback.answer('❌ Ошибка обработки суммы', show_alert=True)
|
|
except Exception as e:
|
|
logger.error('Ошибка обработки быстрого выбора суммы', error=e)
|
|
await callback.answer('❌ Ошибка обработки запроса', show_alert=True)
|
|
|
|
|
|
@error_handler
|
|
async def handle_topup_amount_callback(
|
|
callback: types.CallbackQuery,
|
|
db_user: User,
|
|
state: FSMContext,
|
|
):
|
|
try:
|
|
_, method, amount_str = callback.data.split('|', 2)
|
|
amount_kopeks = int(amount_str)
|
|
except ValueError:
|
|
await callback.answer('❌ Некорректный запрос', show_alert=True)
|
|
return
|
|
|
|
if amount_kopeks <= 0:
|
|
await callback.answer('❌ Некорректная сумма', show_alert=True)
|
|
return
|
|
|
|
try:
|
|
# Особые случаи, требующие специальной логики
|
|
if method == 'platega':
|
|
from app.database.database import AsyncSessionLocal
|
|
|
|
from .platega import process_platega_payment_amount, start_platega_payment
|
|
|
|
data = await state.get_data()
|
|
method_code = int(data.get('platega_method', 0)) if data else 0
|
|
|
|
if method_code > 0:
|
|
async with AsyncSessionLocal() as db:
|
|
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
|
|
else:
|
|
await state.update_data(platega_pending_amount=amount_kopeks)
|
|
await start_platega_payment(callback, db_user, state)
|
|
elif method == 'tribute':
|
|
from .tribute import start_tribute_payment
|
|
|
|
await start_tribute_payment(callback, db_user)
|
|
return
|
|
# Стандартные методы через роутер
|
|
elif not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
|
|
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
|
return
|
|
|
|
await callback.answer()
|
|
|
|
except Exception as error:
|
|
logger.error('Ошибка быстрого пополнения', error=error)
|
|
await callback.answer('❌ Ошибка обработки запроса', show_alert=True)
|
|
|
|
|
|
def register_balance_handlers(dp: Dispatcher):
|
|
dp.callback_query.register(show_balance_menu, F.data == 'menu_balance')
|
|
|
|
dp.callback_query.register(show_balance_history, F.data == 'balance_history')
|
|
|
|
dp.callback_query.register(handle_balance_history_pagination, F.data.startswith('balance_history_page_'))
|
|
|
|
dp.callback_query.register(show_payment_methods, F.data == 'balance_topup')
|
|
|
|
from .stars import start_stars_payment
|
|
|
|
dp.callback_query.register(start_stars_payment, F.data == 'topup_stars')
|
|
|
|
from .yookassa import start_yookassa_payment
|
|
|
|
dp.callback_query.register(start_yookassa_payment, F.data == 'topup_yookassa')
|
|
|
|
from .yookassa import start_yookassa_sbp_payment
|
|
|
|
dp.callback_query.register(start_yookassa_sbp_payment, F.data == 'topup_yookassa_sbp')
|
|
|
|
from .mulenpay import start_mulenpay_payment
|
|
|
|
dp.callback_query.register(start_mulenpay_payment, F.data == 'topup_mulenpay')
|
|
|
|
from .wata import start_wata_payment
|
|
|
|
dp.callback_query.register(start_wata_payment, F.data == 'topup_wata')
|
|
|
|
from .pal24 import start_pal24_payment
|
|
|
|
dp.callback_query.register(start_pal24_payment, F.data == 'topup_pal24')
|
|
from .pal24 import handle_pal24_method_selection
|
|
|
|
dp.callback_query.register(
|
|
handle_pal24_method_selection,
|
|
F.data.startswith('pal24_method_'),
|
|
)
|
|
|
|
from .platega import handle_platega_method_selection, start_platega_payment
|
|
|
|
dp.callback_query.register(start_platega_payment, F.data == 'topup_platega')
|
|
dp.callback_query.register(
|
|
handle_platega_method_selection,
|
|
F.data.startswith('platega_method_'),
|
|
)
|
|
|
|
from .yookassa import check_yookassa_payment_status
|
|
|
|
dp.callback_query.register(check_yookassa_payment_status, F.data.startswith('check_yookassa_'))
|
|
|
|
from .tribute import start_tribute_payment
|
|
|
|
dp.callback_query.register(start_tribute_payment, F.data == 'topup_tribute')
|
|
|
|
dp.callback_query.register(request_support_topup, F.data == 'topup_support')
|
|
|
|
from .yookassa import check_yookassa_payment_status
|
|
|
|
dp.callback_query.register(check_yookassa_payment_status, F.data.startswith('check_yookassa_'))
|
|
|
|
dp.message.register(process_topup_amount, BalanceStates.waiting_for_amount)
|
|
|
|
from .cryptobot import start_cryptobot_payment
|
|
|
|
dp.callback_query.register(start_cryptobot_payment, F.data == 'topup_cryptobot')
|
|
|
|
from .cryptobot import check_cryptobot_payment_status
|
|
|
|
dp.callback_query.register(check_cryptobot_payment_status, F.data.startswith('check_cryptobot_'))
|
|
|
|
from .heleket import check_heleket_payment_status, start_heleket_payment
|
|
|
|
dp.callback_query.register(start_heleket_payment, F.data == 'topup_heleket')
|
|
dp.callback_query.register(check_heleket_payment_status, F.data.startswith('check_heleket_'))
|
|
|
|
from .cloudpayments import handle_cloudpayments_quick_amount, start_cloudpayments_payment
|
|
|
|
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
|
|
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
|
|
|
|
from .freekassa import (
|
|
process_freekassa_card_quick_amount,
|
|
process_freekassa_quick_amount,
|
|
process_freekassa_sbp_quick_amount,
|
|
start_freekassa_card_topup,
|
|
start_freekassa_sbp_topup,
|
|
start_freekassa_topup,
|
|
)
|
|
|
|
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
|
|
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
|
|
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
|
|
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
|
|
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
|
|
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
|
|
|
|
from .kassa_ai import process_kassa_ai_quick_amount, start_kassa_ai_topup
|
|
|
|
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
|
|
dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|'))
|
|
|
|
from .riopay import process_riopay_quick_amount, start_riopay_topup
|
|
|
|
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
|
|
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
|
|
|
|
from .mulenpay import check_mulenpay_payment_status
|
|
|
|
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
|
|
|
from .wata import check_wata_payment_status
|
|
|
|
dp.callback_query.register(check_wata_payment_status, F.data.startswith('check_wata_'))
|
|
|
|
from .pal24 import check_pal24_payment_status
|
|
|
|
dp.callback_query.register(check_pal24_payment_status, F.data.startswith('check_pal24_'))
|
|
|
|
from .platega import check_platega_payment_status
|
|
|
|
dp.callback_query.register(check_platega_payment_status, F.data.startswith('check_platega_'))
|
|
|
|
dp.callback_query.register(handle_payment_methods_unavailable, F.data == 'payment_methods_unavailable')
|
|
|
|
# Регистрируем обработчик для кнопок быстрого выбора суммы
|
|
dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_'))
|
|
|
|
dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|'))
|
|
|
|
dp.callback_query.register(handle_saved_cards_list, F.data == 'saved_cards_list')
|
|
dp.callback_query.register(handle_unlink_card, F.data.startswith('unlink_card_'))
|
|
dp.callback_query.register(handle_confirm_unlink, F.data.startswith('confirm_unlink_'))
|