From d83f64c5fe7819265772c97679e6266a973f0f2c Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 19:21:14 +0300 Subject: [PATCH] Refactor subscription handling and payment method management - Updated the user router to import the subscription router directly, preparing for future package separation. - Removed the outdated subscription handler file, streamlining the codebase and improving maintainability. - Introduced a new method for deleting user payment methods by provider ID, enhancing flexibility in payment management. - Improved error handling and clarity in payment method operations, ensuring a smoother user experience. --- bot/handlers/user/__init__.py | 5 +- bot/handlers/user/subscription.py | 1083 ----------------- bot/handlers/user/subscription/__init__.py | 14 + bot/handlers/user/subscription/core.py | 246 ++++ .../user/subscription/payment_methods.py | 416 +++++++ bot/handlers/user/subscription/payments.py | 260 ++++ db/dal/user_billing_dal.py | 22 + 7 files changed, 961 insertions(+), 1085 deletions(-) delete mode 100644 bot/handlers/user/subscription.py create mode 100644 bot/handlers/user/subscription/__init__.py create mode 100644 bot/handlers/user/subscription/core.py create mode 100644 bot/handlers/user/subscription/payment_methods.py create mode 100644 bot/handlers/user/subscription/payments.py diff --git a/bot/handlers/user/__init__.py b/bot/handlers/user/__init__.py index 2aa7de7..6a37b5b 100644 --- a/bot/handlers/user/__init__.py +++ b/bot/handlers/user/__init__.py @@ -1,7 +1,8 @@ from aiogram import Router from . import start -from . import subscription +# TODO: after splitting subscription into a package, replace this import +from .subscription import router as subscription_router from . import referral from . import promo_user from . import trial_handler @@ -11,5 +12,5 @@ user_router_aggregate = Router(name="user_router_aggregate") user_router_aggregate.include_router(promo_user.router) user_router_aggregate.include_router(trial_handler.router) user_router_aggregate.include_router(start.router) -user_router_aggregate.include_router(subscription.router) +user_router_aggregate.include_router(subscription_router) user_router_aggregate.include_router(referral.router) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py deleted file mode 100644 index e01c645..0000000 --- a/bot/handlers/user/subscription.py +++ /dev/null @@ -1,1083 +0,0 @@ -import logging -from aiogram import Router, F, types, Bot -from aiogram.filters import Command -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice -from typing import Optional, Dict, Any, Union, List -from datetime import datetime, timezone -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select - -from config.settings import Settings -from db.dal import payment_dal -from bot.keyboards.inline.user_keyboards import ( - get_subscription_options_keyboard, get_payment_method_keyboard, - get_payment_url_keyboard, get_back_to_main_menu_markup, - get_payment_methods_manage_keyboard, get_payment_method_delete_confirm_keyboard, - get_payment_method_details_keyboard, get_bind_url_keyboard, - get_payment_methods_list_keyboard, get_back_to_payment_methods_keyboard, get_back_to_payment_method_details_keyboard) -from bot.services.yookassa_service import YooKassaService -from db.dal import user_billing_dal -from bot.services.stars_service import StarsService -from bot.services.crypto_pay_service import CryptoPayService -from bot.services.subscription_service import SubscriptionService -from bot.services.panel_api_service import PanelApiService -from bot.services.referral_service import ReferralService -from bot.services.yookassa_service import YooKassaService -from bot.middlewares.i18n import JsonI18n -from db.dal import subscription_dal -from db.models import Subscription, Payment - -router = Router(name="user_subscription_router") - - -async def display_subscription_options(event: Union[types.Message, - types.CallbackQuery], - i18n_data: dict, settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - - get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs - ) if i18n else key - - if not i18n: - err_msg = "Language service error." - if isinstance(event, types.CallbackQuery): - try: - await event.answer(err_msg, show_alert=True) - except Exception: - pass - elif isinstance(event, types.Message): - await event.answer(err_msg) - return - - currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - text_content = get_text("select_subscription_period" - ) if settings.subscription_options else get_text( - "no_subscription_options_available") - - reply_markup = get_subscription_options_keyboard( - settings.subscription_options, currency_symbol_val, current_lang, i18n - ) if settings.subscription_options else get_back_to_main_menu_markup( - current_lang, i18n) - - target_message_obj = event.message if isinstance( - event, types.CallbackQuery) else event - if not target_message_obj: - if isinstance(event, types.CallbackQuery): - try: - await event.answer(get_text("error_occurred_try_again"), - show_alert=True) - except Exception: - pass - return - - if isinstance(event, types.CallbackQuery): - try: - await target_message_obj.edit_text(text_content, - reply_markup=reply_markup) - except Exception: - await target_message_obj.answer(text_content, - reply_markup=reply_markup) - try: - await event.answer() - except Exception: - pass - else: - await target_message_obj.answer(text_content, - reply_markup=reply_markup) - - -@router.callback_query(F.data.startswith("subscribe_period:")) -async def select_subscription_period_callback_handler( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs - ) if i18n else key - - if not i18n or not callback.message: - try: - await callback.answer(get_text("error_occurred_try_again"), - show_alert=True) - except Exception: - pass - return - - try: - months = int(callback.data.split(":")[-1]) - except (ValueError, IndexError): - logging.error( - f"Invalid subscription period in callback_data: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - price_rub = settings.subscription_options.get(months) - if price_rub is None: - logging.error( - f"Price not found for {months} months subscription period in settings.subscription_options." - ) - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - text_content = get_text("choose_payment_method") - tribute_url = settings.tribute_payment_links.get(months) - stars_price = settings.stars_subscription_options.get(months) - reply_markup = get_payment_method_keyboard( - months, - price_rub, - tribute_url, - stars_price, - currency_symbol_val, - current_lang, - i18n, - settings, - ) - - try: - await callback.message.edit_text(text_content, - reply_markup=reply_markup) - except Exception as e_edit: - logging.warning( - f"Edit message for payment method selection failed: {e_edit}. Sending new one." - ) - await callback.message.answer(text_content, - reply_markup=reply_markup) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_stars:")) -async def pay_stars_callback_handler( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict, - session: AsyncSession, bot: Bot, stars_service: StarsService): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - - get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - if not i18n or not callback.message: - try: - await callback.answer(get_text("error_occurred_try_again"), show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - stars_price = int(price_str) - except (ValueError, IndexError): - logging.error(f"Invalid pay_stars data in callback: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) - - payment_id = await stars_service.create_invoice( - session, user_id, months, stars_price, payment_description) - if payment_id is None: - await callback.message.edit_text(get_text("error_payment_gateway")) - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk:")) -async def pay_yk_callback_handler( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict, - yookassa_service: YooKassaService, session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - - get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs - ) if i18n else key - - if not i18n or not callback.message: - try: - await callback.answer(get_text("error_occurred_try_again"), - show_alert=True) - except Exception: - pass - return - - if not yookassa_service or not yookassa_service.configured: - logging.error("YooKassa service is not configured or unavailable.") - target_msg_edit = callback.message - await target_msg_edit.edit_text(get_text("payment_service_unavailable") - ) - try: - await callback.answer(get_text("payment_service_unavailable_alert"), - show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - price_rub = float(price_str) - except (ValueError, IndexError): - logging.error( - f"Invalid pay_yk data in callback: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - user_id = callback.from_user.id - - payment_description = get_text("payment_description_subscription", - months=months) - currency_code_for_yk = "RUB" - - payment_record_data = { - "user_id": user_id, - "amount": price_rub, - "currency": currency_code_for_yk, - "status": "pending_yookassa", - "description": payment_description, - "subscription_duration_months": months, - } - db_payment_record = None - try: - db_payment_record = await payment_dal.create_payment_record( - session, payment_record_data) - await session.commit() - logging.info( - f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." - ) - except Exception as e_db_payment: - await session.rollback() - logging.error( - f"Failed to create payment record in DB for user {user_id}: {e_db_payment}", - exc_info=True) - await callback.message.edit_text( - get_text("error_creating_payment_record")) - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - if not db_payment_record: - await callback.message.edit_text( - get_text("error_creating_payment_record")) - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - yookassa_metadata = { - "user_id": str(user_id), - "subscription_months": str(months), - "payment_db_id": str(db_payment_record.payment_id), - } - receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL - - payment_response_yk = await yookassa_service.create_payment( - amount=price_rub, - currency=currency_code_for_yk, - description=payment_description, - metadata=yookassa_metadata, - receipt_email=receipt_email_for_yk, - save_payment_method=True) - - if payment_response_yk and payment_response_yk.get("confirmation_url"): - # If YooKassa already provided a payment_method (rare on redirect), store it - pm = payment_response_yk.get("payment_method") - try: - if pm and pm.get('id'): - pm_type = pm.get('type') - title = pm.get('title') - card = pm.get('card') or {} - account_number = pm.get('account_number') or pm.get('account') - if isinstance(card, dict) and (pm_type or '').lower() in {"bank_card", "bank-card", "card"}: - display_network = card.get('card_type') or title or 'Card' - display_last4 = card.get('last4') - elif (pm_type or '').lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: - # Normalize wallet display name to avoid leaking full account from title - display_network = 'YooMoney' - display_last4 = account_number[-4:] if isinstance(account_number, str) and len(account_number) >= 4 else None - else: - display_network = title or (pm_type.upper() if pm_type else 'Payment method') - display_last4 = None - await user_billing_dal.upsert_yk_payment_method( - session, - user_id=user_id, - payment_method_id=pm['id'], - card_last4=display_last4, - card_network=display_network, - ) - # Also save multi-card entry and mark default if first - try: - from db.dal import user_billing_dal as ub - await ub.upsert_user_payment_method( - session, - user_id=user_id, - provider_payment_method_id=pm['id'], - provider="yookassa", - card_last4=display_last4, - card_network=display_network, - set_default=True, - ) - except Exception: - pass - await session.commit() - except Exception: - await session.rollback() - logging.exception("Failed to save YooKassa payment method preliminarily") - try: - await payment_dal.update_payment_status_by_db_id( - session, - payment_db_id=db_payment_record.payment_id, - new_status=payment_response_yk.get("status", "pending"), - yk_payment_id=payment_response_yk.get("id")) - await session.commit() - except Exception as e_db_update_ykid: - await session.rollback() - logging.error( - f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", - exc_info=True) - await callback.message.edit_text( - get_text("error_payment_gateway_link_failed")) - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - await callback.message.edit_text( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - payment_response_yk["confirmation_url"], current_lang, i18n), - disable_web_page_preview=False) - else: - try: - await payment_dal.update_payment_status_by_db_id( - session, db_payment_record.payment_id, "failed_creation") - await session.commit() - except Exception as e_db_fail_create: - await session.rollback() - logging.error( - f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", - exc_info=True) - - logging.error( - f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" - ) - await callback.message.edit_text(get_text("error_payment_gateway")) - - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_crypto:")) -async def pay_crypto_callback_handler( - callback: types.CallbackQuery, settings: Settings, i18n_data: dict, - cryptopay_service: CryptoPayService, session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - if not i18n or not callback.message: - try: - await callback.answer(get_text("error_occurred_try_again"), show_alert=True) - except Exception: - pass - return - - if not cryptopay_service or not cryptopay_service.configured: - await callback.message.edit_text(get_text("payment_service_unavailable")) - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - months_str, amount_str = data_payload.split(":") - months = int(months_str) - amount_val = float(amount_str) - except (ValueError, IndexError): - logging.error(f"Invalid pay_crypto data in callback: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - user_id = callback.from_user.id - description = get_text("payment_description_subscription", months=months) - - invoice_url = await cryptopay_service.create_invoice( - session, user_id, months, amount_val, description) - if invoice_url: - await callback.message.edit_text( - get_text("payment_link_message", months=months), - reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n), - disable_web_page_preview=False, - ) - else: - await callback.message.edit_text(get_text("error_payment_gateway")) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data == "main_action:subscribe") -async def reshow_subscription_options_callback(callback: types.CallbackQuery, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - await display_subscription_options(callback, i18n_data, settings, session) - - -async def my_subscription_command_handler( - event: Union[types.Message, types.CallbackQuery], - i18n_data: dict, - settings: Settings, - panel_service: PanelApiService, - subscription_service: SubscriptionService, - session: AsyncSession, - bot: Bot -): - target = event.message if isinstance(event, types.CallbackQuery) else event - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: JsonI18n = i18n_data.get("i18n_instance") - get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw) - - if not i18n or not target: - if isinstance(event, types.Message): - await event.answer(get_text("error_occurred_try_again")) - return - - if not panel_service or not subscription_service: - await target.answer(get_text("error_service_unavailable")) - return - - active = await subscription_service.get_active_subscription_details(session, event.from_user.id) - - if not active: - text = get_text("subscription_not_active") - - buy_button = InlineKeyboardButton( - text=get_text("menu_subscribe_inline", default="Купить"), - callback_data="main_action:subscribe" - ) - back_markup = get_back_to_main_menu_markup(current_lang, i18n) - - kb = InlineKeyboardMarkup( - inline_keyboard=[ - [buy_button], - *back_markup.inline_keyboard - ] - ) - - if isinstance(event, types.CallbackQuery): - try: - await event.answer() - except Exception: - pass - try: - await event.message.edit_text(text, reply_markup=kb) - except: - await event.message.answer(text, reply_markup=kb) - else: - await event.answer(text, reply_markup=kb) - return - - end_date = active.get("end_date") - days_left = ( - (end_date.date() - datetime.now().date()).days - if end_date else 0 - ) - # Auto-renew toggle hint and Tribute notice - tribute_hint = "" - if active.get("status_from_panel", "").lower() == "active": - # Try to infer provider; fetch local sub for flags - # NOTE: Lightweight lookup by user_id - local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) - auto_renew_state = None - if local_sub: - auto_renew_state = local_sub.auto_renew_enabled - if local_sub.provider == "tribute": - link = None - link = (settings.tribute_payment_links.get(local_sub.duration_months or 1) - if hasattr(settings, 'tribute_payment_links') else None) - if link: - tribute_hint = "\n\n" + get_text("subscription_tribute_notice_with_link", link=link) - else: - tribute_hint = "\n\n" + get_text("subscription_tribute_notice") - - text = get_text( - "my_subscription_details", - end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", - days_left=max(0, days_left), - status=active.get("status_from_panel", get_text("status_active")).capitalize(), - config_link=active.get("config_link") or get_text("config_link_not_available"), - traffic_limit=( - f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" - if active.get("traffic_limit_bytes") - else get_text("traffic_unlimited") - ), - traffic_used=( - f"{active['traffic_used_bytes'] / 2**30:.2f} GB" - if active.get("traffic_used_bytes") is not None - else get_text("traffic_na") - ) - ) - # Build markup with auto-renew toggle and payment methods if available - base_markup = get_back_to_main_menu_markup(current_lang, i18n) - kb = base_markup.inline_keyboard - try: - if 'local_sub' in locals() and local_sub and local_sub.provider != 'tribute': - toggle_text = get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") - kb = [[InlineKeyboardButton(text=toggle_text, callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}")]] + kb - # Add payment methods manage entry point - kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb - except Exception: - pass - markup = InlineKeyboardMarkup(inline_keyboard=kb) - - if isinstance(event, types.CallbackQuery): - try: - await event.answer() - except Exception: - pass - try: - await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) - except: - await bot.send_message(chat_id=target.chat.id, text=text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) - else: - await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) - - -@router.callback_query(F.data.startswith("toggle_autorenew:")) -async def toggle_autorenew_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, subscription_service: SubscriptionService, panel_service: PanelApiService, bot: Bot): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - try: - _, payload = callback.data.split(":", 1) - sub_id_str, enable_str = payload.split(":") - sub_id = int(sub_id_str) - enable = bool(int(enable_str)) - except Exception: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - # Fetch subscription by ID directly - sub = await session.get(Subscription, sub_id) - if not sub or sub.user_id != callback.from_user.id: - await callback.answer(get_text("error_try_again"), show_alert=True) - return - if sub.provider == 'tribute': - await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) - return - - await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable}) - await session.commit() - - try: - await callback.answer(get_text("subscription_autorenew_updated")) - except Exception: - pass - # Refresh panel info screen - await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) - - -@router.callback_query(F.data == "pm:manage") -async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - # Build and show the paginated list directly (page 0) - from db.dal.user_billing_dal import list_user_payment_methods - get_text = _ - methods = await list_user_payment_methods(session, callback.from_user.id) - cards: List[tuple] = [] - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return get_text("payment_method_wallet_title", last4=l4) - return get_text("payment_method_wallet_title", last4="****") - if last4: - network_name = network or get_text("payment_network_card", default="Card") - return get_text("payment_method_card_title", network=network_name, last4=last4) - network_name = network or get_text("payment_network_generic", default="Payment method") - return get_text("payment_method_generic_title", network=network_name) - for m in methods: - title = _format_pm_title(m.card_network, m.card_last4) - cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) - - text = get_text("payment_methods_title") - if not cards: - text += "\n\n" + get_text("payment_method_none") - - await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data == "pm:bind") -async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - # Create a minimal binding payment (1 RUB) with save_payment_method - metadata = { - "user_id": str(callback.from_user.id), - "bind_only": "1", - } - resp = await yookassa_service.create_payment( - amount=1.00, - currency="RUB", - description="Bind card", - metadata=metadata, - receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, - save_payment_method=True, - capture=False, - bind_only=True, - ) - if not resp or not resp.get("confirmation_url"): - await callback.answer(_("error_payment_gateway"), show_alert=True) - return - await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n)) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pm:delete_confirm")) -async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - parts = callback.data.split(":", 2) - pm_id = parts[2] if len(parts) >= 3 else "" - await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n)) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pm:delete")) -async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - # Try to parse specific method id for multi-card deletion - parts = callback.data.split(":", 2) - pm_id_raw = parts[2] if len(parts) >= 3 else "" - deleted = False - # Attempt multi-card deletion first - try: - if pm_id_raw and pm_id_raw.isdigit(): - from db.dal.user_billing_dal import delete_user_payment_method, list_user_payment_methods - deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw)) - await session.commit() - # Build updated list - methods = await list_user_payment_methods(session, callback.from_user.id) - text = _("payment_methods_title") - cards = [] - for m in methods: - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return _("payment_method_wallet_title", last4=l4) - return _("payment_method_wallet_title", last4="****") - if last4: - network_name = network or _("payment_network_card", default="Card") - return _("payment_method_card_title", network=network_name, last4=last4) - network_name = network or _("payment_network_generic", default="Payment method") - return _("payment_method_generic_title", network=network_name) - title = _format_pm_title(m.card_network, m.card_last4) - cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) - if not cards: - text += "\n\n" + _("payment_method_none") - msg = _("payment_method_deleted_success") if deleted else _("error_try_again") - # Prepend status message to title - await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)) - try: - await callback.answer() - except Exception: - pass - return - except Exception: - await session.rollback() - deleted = False - - # Fallback: legacy single-card storage deletion - try: - deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id) - await session.commit() - except Exception: - await session.rollback() - deleted = False - - msg = _("payment_method_deleted_success") if deleted else _("error_try_again") - # After legacy deletion, route user to list (which will be empty) for consistency - from db.dal.user_billing_dal import list_user_payment_methods - methods = await list_user_payment_methods(session, callback.from_user.id) - cards = [] - for m in methods: - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return _("payment_method_wallet_title", last4=l4) - return _("payment_method_wallet_title", last4="****") - if last4: - network_name = network or _("payment_network_card", default="Card") - return _("payment_method_card_title", network=network_name, last4=last4) - network_name = network or _("payment_network_generic", default="Payment method") - return _("payment_method_generic_title", network=network_name) - title = _format_pm_title(m.card_network, m.card_last4) - cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) - text = _("payment_methods_title") - if not cards: - text += "\n\n" + _("payment_method_none") - await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pm:view")) -async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - billing = await user_billing_dal.get_user_billing(session, callback.from_user.id) - if not billing or not billing.yookassa_payment_method_id: - # Try multi-card records - from db.dal.user_billing_dal import list_user_payment_methods - methods = await list_user_payment_methods(session, callback.from_user.id) - if not methods: - await callback.answer(_("payment_method_none"), show_alert=True) - return - parts = callback.data.split(":", 2) - pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id) - # Map: - sel = next((m for m in methods if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id), methods[0]) - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return _("payment_method_wallet_title", last4=l4) - return _("payment_method_wallet_title", last4="****") - if last4: - network_name = network or _("payment_network_card", default="Card") - return _("payment_method_card_title", network=network_name, last4=last4) - network_name = network or _("payment_network_generic", default="Payment method") - return _("payment_method_generic_title", network=network_name) - title = _format_pm_title(sel.card_network, sel.card_last4) - added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else "—" - # Last tx - last_tx = "—" - try: - stmt = ( - select(Payment) - .where( - Payment.user_id == callback.from_user.id, - Payment.status == 'succeeded', - Payment.provider == 'yookassa', - ) - .order_by(Payment.created_at.desc()) - .limit(1) - ) - result = await session.execute(stmt) - lp = result.scalar_one_or_none() - if lp and lp.created_at: - last_tx = lp.created_at.strftime('%Y-%m-%d') - except Exception: - pass - details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" - await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n)) - try: - await callback.answer() - except Exception: - pass - return - added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—" - # Last transaction lookup (latest succeeded YooKassa payment by user) - last_tx = "—" - try: - stmt = ( - select(Payment) - .where( - Payment.user_id == callback.from_user.id, - Payment.status == 'succeeded', - Payment.provider == 'yookassa', - ) - .order_by(Payment.created_at.desc()) - .limit(1) - ) - result = await session.execute(stmt) - last_payment = result.scalar_one_or_none() - if last_payment and last_payment.created_at: - last_tx = last_payment.created_at.strftime('%Y-%m-%d') - except Exception: - pass - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return _("payment_method_wallet_title", last4=l4) - return _("payment_method_wallet_title", last4="****") - if last4: - network_name = network or _("payment_network_card", default="Card") - return _("payment_method_card_title", network=network_name, last4=last4) - network_name = network or _("payment_network_generic", default="Payment method") - return _("payment_method_generic_title", network=network_name) - title = _format_pm_title(billing.card_network, billing.card_last4) - details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" - await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n)) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pm:history")) -async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - # Simple history from payments table filtered by user - from db.dal import payment_dal - payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0) - user_payments = [p for p in payments if p.user_id == callback.from_user.id] - - # If viewing a specific saved payment method, filter history by that method when possible - selected_pm_provider_id: Optional[str] = None - pm_filter_requested: bool = False - try: - split_a, split_b, split_pm_id = callback.data.split(":", 2) - if split_pm_id: - pm_filter_requested = True - # Two possible formats: - # - Internal method_id (digits) - # - Direct provider payment_method.id (e.g., YooKassa 'pm_...') - if split_pm_id.isdigit(): - # Map internal id to provider id - from db.dal.user_billing_dal import list_user_payment_methods - methods = await list_user_payment_methods(session, callback.from_user.id) - sel = next((m for m in methods if str(m.method_id) == split_pm_id), None) - if sel and sel.provider_payment_method_id: - selected_pm_provider_id = sel.provider_payment_method_id - else: - # Assume it's already a provider payment_method.id - selected_pm_provider_id = split_pm_id - except Exception: - selected_pm_provider_id = None - pm_filter_requested = False - - # If filter was explicitly requested but method can't be resolved (e.g., deleted), show empty history - if pm_filter_requested and not selected_pm_provider_id: - user_payments = [] - - if selected_pm_provider_id: - # Filter to rows we can confidently associate with the selected method - # Heuristics: - # 1) Payments with yookassa_payment_id -> fetch payment info and compare payment_method.id - filtered: List[Payment] = [] - for p in user_payments: - if p.provider != 'yookassa': - continue - if p.yookassa_payment_id and yookassa_service: - try: - info = await yookassa_service.get_payment_info(p.yookassa_payment_id) - pm = (info or {}).get("payment_method") or {} - if pm.get("id") == selected_pm_provider_id: - filtered.append(p) - continue - except Exception: - pass - user_payments = filtered - if not user_payments: - # Try to get pm_id from context to go one step back - back_pm_id = "" - try: - split_a, split_b, back_pm_id = callback.data.split(":", 2) - except Exception: - back_pm_id = "" - back_markup = get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n) if back_pm_id else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True) - await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup) - return - # Show subscription purchase titles instead of raw provider/status - def _format_item(p): - title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1) - date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A" - return f"{date_str} — {title} — {p.amount:.2f} {p.currency}" - - lines = [_format_item(p) for p in user_payments] - text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines) - try: - split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2) - except Exception: - split_pm_id_for_back = "" - back_markup = get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n) if split_pm_id_for_back else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True) - await callback.message.edit_text(text, reply_markup=back_markup) - - -@router.callback_query(F.data.startswith("pm:list:")) -async def payment_methods_list(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - - # For now we only support single saved YK method; format as list API-ready - from db.dal.user_billing_dal import list_user_payment_methods - cards: List[tuple] = [] - methods = await list_user_payment_methods(session, callback.from_user.id) - for m in methods: - def _is_yoomoney_network(network: Optional[str]) -> bool: - s = (network or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: - if _is_yoomoney_network(network): - l4 = last4 or _extract_last4(network or "") - if l4: - return get_text("payment_method_wallet_title", last4=l4) - return get_text("payment_method_wallet_title", last4="****") - if last4: - network_name = network or get_text("payment_network_card", default="Card") - return get_text("payment_method_card_title", network=network_name, last4=last4) - network_name = network or get_text("payment_network_generic", default="Payment method") - return get_text("payment_method_generic_title", network=network_name) - title = _format_pm_title(m.card_network, m.card_last4) - cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) - - # Parse page - try: - _, _, page_str = callback.data.split(":", 2) - page = int(page_str) - except Exception: - page = 0 - - text = get_text("payment_methods_title") - if not cards: - text += "\n\n" + get_text("payment_method_none") - await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n)) - try: - await callback.answer() - except Exception: - pass - - -@router.pre_checkout_query() -async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery): - await pre_checkout_query.answer(ok=True) - - -@router.message(F.successful_payment) -async def stars_successful_payment_handler( - message: types.Message, settings: Settings, i18n_data: dict, - session: AsyncSession, stars_service: StarsService): - sp = message.successful_payment - if not sp or sp.currency != "XTR": - return - - payload = sp.invoice_payload or "" - try: - payment_id_str, months_str = payload.split(":") - payment_db_id = int(payment_id_str) - months = int(months_str) - except (ValueError, IndexError): - logging.error(f"Invalid invoice payload for stars payment: {payload}") - return - - stars_amount = sp.total_amount - await stars_service.process_successful_payment( - session, message, payment_db_id, months, stars_amount, i18n_data) - - -@router.message(Command("connect")) -async def connect_command_handler(message: types.Message, i18n_data: dict, - settings: Settings, - panel_service: PanelApiService, - subscription_service: SubscriptionService, - session: AsyncSession, bot: Bot): - logging.info(f"User {message.from_user.id} used /connect command.") - await my_subscription_command_handler(message, i18n_data, settings, - panel_service, subscription_service, - session, bot) diff --git a/bot/handlers/user/subscription/__init__.py b/bot/handlers/user/subscription/__init__.py new file mode 100644 index 0000000..2d7f70a --- /dev/null +++ b/bot/handlers/user/subscription/__init__.py @@ -0,0 +1,14 @@ +from aiogram import Router + +from . import core +from . import payments +from . import payment_methods + +router = Router(name="user_subscription_router") + +# Include sub-routers +router.include_router(core.router) +router.include_router(payments.router) +router.include_router(payment_methods.router) + + diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py new file mode 100644 index 0000000..58cd8a1 --- /dev/null +++ b/bot/handlers/user/subscription/core.py @@ -0,0 +1,246 @@ +import logging +from aiogram import Router, F, types, Bot +from aiogram.filters import Command +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from typing import Optional, Union +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from config.settings import Settings +from bot.keyboards.inline.user_keyboards import ( + get_subscription_options_keyboard, + get_back_to_main_menu_markup, +) +from bot.services.subscription_service import SubscriptionService +from bot.services.panel_api_service import PanelApiService +from bot.middlewares.i18n import JsonI18n +from db.dal import subscription_dal +from db.models import Subscription + +router = Router(name="user_subscription_core_router") + + +async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + if not i18n: + err_msg = "Language service error." + if isinstance(event, types.CallbackQuery): + try: + await event.answer(err_msg, show_alert=True) + except Exception: + pass + elif isinstance(event, types.Message): + await event.answer(err_msg) + return + + currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL + text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available") + + reply_markup = ( + get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n) + if settings.subscription_options + else get_back_to_main_menu_markup(current_lang, i18n) + ) + + target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event + if not target_message_obj: + if isinstance(event, types.CallbackQuery): + try: + await event.answer(get_text("error_occurred_try_again"), show_alert=True) + except Exception: + pass + return + + if isinstance(event, types.CallbackQuery): + try: + await target_message_obj.edit_text(text_content, reply_markup=reply_markup) + except Exception: + await target_message_obj.answer(text_content, reply_markup=reply_markup) + try: + await event.answer() + except Exception: + pass + else: + await target_message_obj.answer(text_content, reply_markup=reply_markup) + + +@router.callback_query(F.data == "main_action:subscribe") +async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): + await display_subscription_options(callback, i18n_data, settings, session) + + +async def my_subscription_command_handler( + event: Union[types.Message, types.CallbackQuery], + i18n_data: dict, + settings: Settings, + panel_service: PanelApiService, + subscription_service: SubscriptionService, + session: AsyncSession, + bot: Bot, +): + target = event.message if isinstance(event, types.CallbackQuery) else event + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw) + + if not i18n or not target: + if isinstance(event, types.Message): + await event.answer(get_text("error_occurred_try_again")) + return + + if not panel_service or not subscription_service: + await target.answer(get_text("error_service_unavailable")) + return + + active = await subscription_service.get_active_subscription_details(session, event.from_user.id) + + if not active: + text = get_text("subscription_not_active") + + buy_button = InlineKeyboardButton( + text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe" + ) + back_markup = get_back_to_main_menu_markup(current_lang, i18n) + + kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard]) + + if isinstance(event, types.CallbackQuery): + try: + await event.answer() + except Exception: + pass + try: + await event.message.edit_text(text, reply_markup=kb) + except Exception: + await event.message.answer(text, reply_markup=kb) + else: + await event.answer(text, reply_markup=kb) + return + + end_date = active.get("end_date") + days_left = (end_date.date() - datetime.now().date()).days if end_date else 0 + tribute_hint = "" + if active.get("status_from_panel", "").lower() == "active": + local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) + if local_sub: + if local_sub.provider == "tribute": + link = None + link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None + tribute_hint = "\n\n" + ( + get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice") + ) + + text = get_text( + "my_subscription_details", + end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", + days_left=max(0, days_left), + status=active.get("status_from_panel", get_text("status_active")).capitalize(), + config_link=active.get("config_link") or get_text("config_link_not_available"), + traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")), + traffic_used=( + f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na") + ), + ) + + base_markup = get_back_to_main_menu_markup(current_lang, i18n) + kb = base_markup.inline_keyboard + try: + local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) + if local_sub and local_sub.provider != "tribute": + toggle_text = ( + get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") + ) + kb = [ + [ + InlineKeyboardButton( + text=toggle_text, + callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}", + ) + ] + ] + kb + kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb + except Exception: + pass + markup = InlineKeyboardMarkup(inline_keyboard=kb) + + if isinstance(event, types.CallbackQuery): + try: + await event.answer() + except Exception: + pass + try: + await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + except Exception: + await bot.send_message( + chat_id=target.chat.id, + text=text + tribute_hint, + reply_markup=markup, + parse_mode="HTML", + disable_web_page_preview=True, + ) + else: + await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + + +@router.callback_query(F.data.startswith("toggle_autorenew:")) +async def toggle_autorenew_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + subscription_service: SubscriptionService, + panel_service: PanelApiService, + bot: Bot, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + try: + _, payload = callback.data.split(":", 1) + sub_id_str, enable_str = payload.split(":") + sub_id = int(sub_id_str) + enable = bool(int(enable_str)) + except Exception: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + sub = await session.get(Subscription, sub_id) + if not sub or sub.user_id != callback.from_user.id: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + if sub.provider == "tribute": + await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) + return + + await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable}) + await session.commit() + try: + await callback.answer(get_text("subscription_autorenew_updated")) + except Exception: + pass + await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) + + +@router.message(Command("connect")) +async def connect_command_handler( + message: types.Message, + i18n_data: dict, + settings: Settings, + panel_service: PanelApiService, + subscription_service: SubscriptionService, + session: AsyncSession, + bot: Bot, +): + logging.info(f"User {message.from_user.id} used /connect command.") + await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot) + + diff --git a/bot/handlers/user/subscription/payment_methods.py b/bot/handlers/user/subscription/payment_methods.py new file mode 100644 index 0000000..13972c4 --- /dev/null +++ b/bot/handlers/user/subscription/payment_methods.py @@ -0,0 +1,416 @@ +from aiogram import Router, F, types +from typing import Optional, List +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from bot.keyboards.inline.user_keyboards import ( + get_payment_methods_list_keyboard, + get_payment_method_delete_confirm_keyboard, + get_payment_method_details_keyboard, + get_bind_url_keyboard, +) +from bot.services.yookassa_service import YooKassaService +from bot.middlewares.i18n import JsonI18n +from db.dal import user_billing_dal +from db.models import Payment +from sqlalchemy.future import select + +router = Router(name="user_subscription_payment_methods_router") + + +@router.callback_query(F.data == "pm:manage") +async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + from db.dal.user_billing_dal import list_user_payment_methods + get_text = _ + methods = await list_user_payment_methods(session, callback.from_user.id) + cards: List[tuple] = [] + + def _is_yoomoney_network(network: Optional[str]) -> bool: + s = (network or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: + if _is_yoomoney_network(network): + l4 = last4 or _extract_last4(network or "") + if l4: + return get_text("payment_method_wallet_title", last4=l4) + return get_text("payment_method_wallet_title", last4="****") + if last4: + network_name = network or get_text("payment_network_card", default="Card") + return get_text("payment_method_card_title", network=network_name, last4=last4) + network_name = network or get_text("payment_network_generic", default="Payment method") + return get_text("payment_method_generic_title", network=network_name) + + for m in methods: + title = _format_pm_title(m.card_network, m.card_last4) + cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) + + text = get_text("payment_methods_title") + if not cards: + text += "\n\n" + get_text("payment_method_none") + + await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data == "pm:bind") +async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"} + resp = await yookassa_service.create_payment( + amount=1.00, + currency="RUB", + description="Bind card", + metadata=metadata, + receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, + save_payment_method=True, + capture=False, + bind_only=True, + ) + if not resp or not resp.get("confirmation_url"): + await callback.answer(_("error_payment_gateway"), show_alert=True) + return + await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pm:delete_confirm")) +async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + parts = callback.data.split(":", 2) + pm_id = parts[2] if len(parts) >= 3 else "" + await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pm:delete")) +async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + parts = callback.data.split(":", 2) + pm_id_raw = parts[2] if len(parts) >= 3 else "" + deleted = False + + try: + from db.dal.user_billing_dal import ( + delete_user_payment_method, + delete_user_payment_method_by_provider_id, + list_user_payment_methods, + ) + if pm_id_raw: + if pm_id_raw.isdigit(): + deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw)) + else: + deleted = await delete_user_payment_method_by_provider_id(session, callback.from_user.id, pm_id_raw) + try: + legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id) + deleted = deleted or legacy_deleted + except Exception: + pass + await session.commit() + + methods = await list_user_payment_methods(session, callback.from_user.id) + text = _("payment_methods_title") + cards = [] + for m in methods: + def _is_yoomoney_network(network: Optional[str]) -> bool: + s = (network or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: + if _is_yoomoney_network(network): + l4 = last4 or _extract_last4(network or "") + if l4: + return _("payment_method_wallet_title", last4=l4) + return _("payment_method_wallet_title", last4="****") + if last4: + network_name = network or _("payment_network_card", default="Card") + return _("payment_method_card_title", network=network_name, last4=last4) + network_name = network or _("payment_network_generic", default="Payment method") + return _("payment_method_generic_title", network=network_name) + title = _format_pm_title(m.card_network, m.card_last4) + cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) + if not cards: + text += "\n\n" + _("payment_method_none") + msg = _("payment_method_deleted_success") if deleted else _("error_try_again") + await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + return + except Exception: + await session.rollback() + try: + await callback.answer(_("error_try_again"), show_alert=True) + except Exception: + pass + + +@router.callback_query(F.data.startswith("pm:view")) +async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + billing = await user_billing_dal.get_user_billing(session, callback.from_user.id) + if not billing or not billing.yookassa_payment_method_id: + from db.dal.user_billing_dal import list_user_payment_methods + methods = await list_user_payment_methods(session, callback.from_user.id) + if not methods: + await callback.answer(_("payment_method_none"), show_alert=True) + return + parts = callback.data.split(":", 2) + pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id) + sel = next((m for m in methods if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id), methods[0]) + + def _is_yoomoney_network(network: Optional[str]) -> bool: + s = (network or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: + if _is_yoomoney_network(network): + l4 = last4 or _extract_last4(network or "") + if l4: + return _("payment_method_wallet_title", last4=l4) + return _("payment_method_wallet_title", last4="****") + if last4: + network_name = network or _("payment_network_card", default="Card") + return _("payment_method_card_title", network=network_name, last4=last4) + network_name = network or _("payment_network_generic", default="Payment method") + return _("payment_method_generic_title", network=network_name) + + title = _format_pm_title(sel.card_network, sel.card_last4) + added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else "—" + last_tx = "—" + try: + stmt = ( + select(Payment) + .where( + Payment.user_id == callback.from_user.id, + Payment.status == 'succeeded', + Payment.provider == 'yookassa', + ) + .order_by(Payment.created_at.desc()) + .limit(1) + ) + result = await session.execute(stmt) + lp = result.scalar_one_or_none() + if lp and lp.created_at: + last_tx = lp.created_at.strftime('%Y-%m-%d') + except Exception: + pass + details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" + await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + return + + added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—" + last_tx = "—" + try: + stmt = ( + select(Payment) + .where( + Payment.user_id == callback.from_user.id, + Payment.status == 'succeeded', + Payment.provider == 'yookassa', + ) + .order_by(Payment.created_at.desc()) + .limit(1) + ) + result = await session.execute(stmt) + last_payment = result.scalar_one_or_none() + if last_payment and last_payment.created_at: + last_tx = last_payment.created_at.strftime('%Y-%m-%d') + except Exception: + pass + + def _is_yoomoney_network(network: Optional[str]) -> bool: + s = (network or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: + if _is_yoomoney_network(network): + l4 = last4 or _extract_last4(network or "") + if l4: + return _("payment_method_wallet_title", last4=l4) + return _("payment_method_wallet_title", last4="****") + if last4: + network_name = network or _("payment_network_card", default="Card") + return _("payment_method_card_title", network=network_name, last4=last4) + network_name = network or _("payment_network_generic", default="Payment method") + return _("payment_method_generic_title", network=network_name) + + title = _format_pm_title(billing.card_network, billing.card_last4) + details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" + await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pm:history")) +async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + from db.dal import payment_dal + payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0) + user_payments = [p for p in payments if p.user_id == callback.from_user.id] + + selected_pm_provider_id: Optional[str] = None + pm_filter_requested: bool = False + try: + split_a, split_b, split_pm_id = callback.data.split(":", 2) + if split_pm_id: + pm_filter_requested = True + if split_pm_id.isdigit(): + from db.dal.user_billing_dal import list_user_payment_methods + methods = await list_user_payment_methods(session, callback.from_user.id) + sel = next((m for m in methods if str(m.method_id) == split_pm_id), None) + if sel and sel.provider_payment_method_id: + selected_pm_provider_id = sel.provider_payment_method_id + else: + selected_pm_provider_id = split_pm_id + except Exception: + selected_pm_provider_id = None + pm_filter_requested = False + + if pm_filter_requested and not selected_pm_provider_id: + user_payments = [] + + if selected_pm_provider_id: + filtered: List[Payment] = [] + for p in user_payments: + if p.provider != 'yookassa': + continue + if p.yookassa_payment_id and yookassa_service: + try: + info = await yookassa_service.get_payment_info(p.yookassa_payment_id) + pm = (info or {}).get("payment_method") or {} + if pm.get("id") == selected_pm_provider_id: + filtered.append(p) + continue + except Exception: + pass + user_payments = filtered + + if not user_payments: + from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard + back_pm_id = "" + try: + split_a, split_b, back_pm_id = callback.data.split(":", 2) + except Exception: + back_pm_id = "" + back_markup = ( + get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n) + if back_pm_id + else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True) + ) + await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup) + return + + def _format_item(p: Payment) -> str: + title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1) + date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A" + return f"{date_str} — {title} — {p.amount:.2f} {p.currency}" + + lines = [_format_item(p) for p in user_payments] + text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines) + try: + split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2) + except Exception: + split_pm_id_for_back = "" + from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard + back_markup = ( + get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n) + if split_pm_id_for_back + else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True) + ) + await callback.message.edit_text(text, reply_markup=back_markup) + + +@router.callback_query(F.data.startswith("pm:list:")) +async def payment_methods_list(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + from db.dal.user_billing_dal import list_user_payment_methods + cards: List[tuple] = [] + methods = await list_user_payment_methods(session, callback.from_user.id) + for m in methods: + def _is_yoomoney_network(network: Optional[str]) -> bool: + s = (network or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str: + if _is_yoomoney_network(network): + l4 = last4 or _extract_last4(network or "") + if l4: + return get_text("payment_method_wallet_title", last4=l4) + return get_text("payment_method_wallet_title", last4="****") + if last4: + network_name = network or get_text("payment_network_card", default="Card") + return get_text("payment_method_card_title", network=network_name, last4=last4) + network_name = network or get_text("payment_network_generic", default="Payment method") + return get_text("payment_method_generic_title", network=network_name) + title = _format_pm_title(m.card_network, m.card_last4) + cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) + + try: + _, _, page_str = callback.data.split(":", 2) + page = int(page_str) + except Exception: + page = 0 + + text = get_text("payment_methods_title") + if not cards: + text += "\n\n" + get_text("payment_method_none") + await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n)) + try: + await callback.answer() + except Exception: + pass + + diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py new file mode 100644 index 0000000..be09f26 --- /dev/null +++ b/bot/handlers/user/subscription/payments.py @@ -0,0 +1,260 @@ +import logging +from aiogram import Router, F, types +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard, get_payment_url_keyboard +from bot.services.yookassa_service import YooKassaService +from bot.middlewares.i18n import JsonI18n +from db.dal import payment_dal, user_billing_dal + +router = Router(name="user_subscription_payments_router") + + +@router.callback_query(F.data.startswith("subscribe_period:")) +async def select_subscription_period_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + if not i18n or not callback.message: + try: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + except Exception: + pass + return + + try: + months = int(callback.data.split(":")[-1]) + except (ValueError, IndexError): + logging.error(f"Invalid subscription period in callback_data: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + price_rub = settings.subscription_options.get(months) + if price_rub is None: + logging.error( + f"Price not found for {months} months subscription period in settings.subscription_options." + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL + text_content = get_text("choose_payment_method") + tribute_url = settings.tribute_payment_links.get(months) + stars_price = settings.stars_subscription_options.get(months) + reply_markup = get_payment_method_keyboard( + months, + price_rub, + tribute_url, + stars_price, + currency_symbol_val, + current_lang, + i18n, + settings, + ) + + try: + await callback.message.edit_text(text_content, reply_markup=reply_markup) + except Exception as e_edit: + logging.warning( + f"Edit message for payment method selection failed: {e_edit}. Sending new one." + ) + await callback.message.answer(text_content, reply_markup=reply_markup) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk:")) +async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + if not i18n or not callback.message: + try: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + except Exception: + pass + return + + if not yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service is not configured or unavailable.") + target_msg_edit = callback.message + await target_msg_edit.edit_text(get_text("payment_service_unavailable")) + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + months_str, price_str = data_payload.split(":") + months = int(months_str) + price_rub = float(price_str) + except (ValueError, IndexError): + logging.error(f"Invalid pay_yk data in callback: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + user_id = callback.from_user.id + payment_description = get_text("payment_description_subscription", months=months) + currency_code_for_yk = "RUB" + + payment_record_data = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code_for_yk, + "status": "pending_yookassa", + "description": payment_description, + "subscription_duration_months": months, + } + + db_payment_record = None + try: + db_payment_record = await payment_dal.create_payment_record(session, payment_record_data) + await session.commit() + logging.info( + f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." + ) + except Exception as e_db_payment: + await session.rollback() + logging.error( + f"Failed to create payment record in DB for user {user_id}: {e_db_payment}", + exc_info=True, + ) + await callback.message.edit_text(get_text("error_creating_payment_record")) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + if not db_payment_record: + await callback.message.edit_text(get_text("error_creating_payment_record")) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + yookassa_metadata = { + "user_id": str(user_id), + "subscription_months": str(months), + "payment_db_id": str(db_payment_record.payment_id), + } + receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL + + payment_response_yk = await yookassa_service.create_payment( + amount=price_rub, + currency=currency_code_for_yk, + description=payment_description, + metadata=yookassa_metadata, + receipt_email=receipt_email_for_yk, + save_payment_method=True, + ) + + if payment_response_yk and payment_response_yk.get("confirmation_url"): + pm = payment_response_yk.get("payment_method") + try: + if pm and pm.get("id"): + pm_type = pm.get("type") + title = pm.get("title") + card = pm.get("card") or {} + account_number = pm.get("account_number") or pm.get("account") + if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}: + display_network = card.get("card_type") or title or "Card" + display_last4 = card.get("last4") + elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: + display_network = "YooMoney" + display_last4 = ( + account_number[-4:] + if isinstance(account_number, str) and len(account_number) >= 4 + else None + ) + else: + display_network = title or (pm_type.upper() if pm_type else "Payment method") + display_last4 = None + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=pm["id"], + card_last4=display_last4, + card_network=display_network, + ) + try: + await user_billing_dal.upsert_user_payment_method( + session, + user_id=user_id, + provider_payment_method_id=pm["id"], + provider="yookassa", + card_last4=display_last4, + card_network=display_network, + set_default=True, + ) + except Exception: + pass + await session.commit() + except Exception: + await session.rollback() + logging.exception("Failed to save YooKassa payment method preliminarily") + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=db_payment_record.payment_id, + new_status=payment_response_yk.get("status", "pending"), + yk_payment_id=payment_response_yk.get("id"), + ) + await session.commit() + except Exception as e_db_update_ykid: + await session.rollback() + logging.error( + f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", + exc_info=True, + ) + await callback.message.edit_text(get_text("error_payment_gateway_link_failed")) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard(payment_response_yk["confirmation_url"], current_lang, i18n), + disable_web_page_preview=False, + ) + else: + try: + await payment_dal.update_payment_status_by_db_id(session, db_payment_record.payment_id, "failed_creation") + await session.commit() + except Exception as e_db_fail_create: + await session.rollback() + logging.error( + f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", + exc_info=True, + ) + logging.error( + f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" + ) + await callback.message.edit_text(get_text("error_payment_gateway")) + + try: + await callback.answer() + except Exception: + pass + + diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py index 319be82..a6c1039 100644 --- a/db/dal/user_billing_dal.py +++ b/db/dal/user_billing_dal.py @@ -140,3 +140,25 @@ async def delete_user_payment_method(session: AsyncSession, user_id: int, method await session.delete(method) await session.flush() return True + + +async def delete_user_payment_method_by_provider_id( + session: AsyncSession, + user_id: int, + provider_payment_method_id: str, +) -> bool: + """Delete a saved payment method by its provider payment_method.id for a specific user. + + Useful when callbacks pass the provider id (e.g., YooKassa pm_...) instead of our internal method_id. + """ + stmt = select(UserPaymentMethod).where( + UserPaymentMethod.user_id == user_id, + UserPaymentMethod.provider_payment_method_id == provider_payment_method_id, + ) + result = await session.execute(stmt) + method: Optional[UserPaymentMethod] = result.scalar_one_or_none() + if not method: + return False + await session.delete(method) + await session.flush() + return True