diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index 22b1f4f..da1507a 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -1,1546 +1,21 @@ -import json -import logging -from datetime import datetime -from aiogram import Router, F, types -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup -from typing import Optional, List, Tuple -from sqlalchemy.ext.asyncio import AsyncSession +from aiogram import Router -from config.settings import Settings -from bot.keyboards.inline.user_keyboards import ( - get_payment_method_keyboard, - get_payment_url_keyboard, - get_yk_autopay_choice_keyboard, - get_yk_saved_cards_keyboard, - get_back_to_main_menu_markup, -) -from bot.services.yookassa_service import YooKassaService -from bot.services.freekassa_service import FreeKassaService -from bot.services.crypto_pay_service import CryptoPayService -from bot.services.stars_service import StarsService -from bot.services.platega_service import PlategaService -from bot.services.severpay_service import SeverPayService -from bot.middlewares.i18n import JsonI18n -from db.dal import payment_dal, user_billing_dal +from .payments_crypto import router as crypto_router +from .payments_freekassa import router as freekassa_router +from .payments_platega import router as platega_router +from .payments_severpay import router as severpay_router +from .payments_stars import router as stars_router +from .payments_subscription import router as subscription_selection_router +from .payments_yookassa import router as yookassa_router router = Router(name="user_subscription_payments_router") - -def _parse_months_and_price(payload: str) -> Optional[Tuple[int, float]]: - try: - months_str, price_str = payload.split(":") - return int(months_str), float(price_str) - except (ValueError, IndexError): - return None - - -def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str: - def _is_yoomoney_network(name: Optional[str]) -> bool: - s = (name 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 - - if _is_yoomoney_network(network): - inferred_last4 = last4 or (_extract_last4(network or "") or "****") - title = get_text("payment_method_wallet_title", last4=inferred_last4) - elif last4: - network_name = network or get_text("payment_network_card") - title = get_text("payment_method_card_title", network=network_name, last4=last4) - else: - network_name = network or get_text("payment_network_generic") - title = get_text("payment_method_generic_title", network=network_name) - return f"⭐ {title}" if is_default else title - - -async def _initiate_yk_payment( - callback: types.CallbackQuery, - *, - settings: Settings, - session: AsyncSession, - yookassa_service: YooKassaService, - i18n: Optional[JsonI18n], - current_lang: str, - get_text, - user_id: int, - months: int, - price_rub: float, - currency_code_for_yk: str, - save_payment_method: bool, - back_callback: str, - payment_method_id: Optional[str] = None, - selected_method_internal_id: Optional[int] = None, -) -> bool: - """Create payment record and initiate YooKassa payment (new card or saved card).""" - if not callback.message: - return False - - payment_description = get_text("payment_description_subscription", months=months) - 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, - ) - try: - await callback.message.edit_text(get_text("error_creating_payment_record")) - except Exception: - pass - return False - - if not db_payment_record: - try: - await callback.message.edit_text(get_text("error_creating_payment_record")) - except Exception: - pass - return False - - yookassa_metadata = { - "user_id": str(user_id), - "subscription_months": str(months), - "payment_db_id": str(db_payment_record.payment_id), - } - if payment_method_id: - yookassa_metadata["used_saved_payment_method_id"] = payment_method_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=save_payment_method, - payment_method_id=payment_method_id, - ) - - 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=save_payment_method, - ) - 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"), - ) - if selected_method_internal_id is not None: - try: - await user_billing_dal.set_user_default_payment_method( - session, user_id, selected_method_internal_id - ) - except Exception: - logging.exception("Failed to set default payment method after initiating payment") - 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, - ) - try: - await callback.message.edit_text(get_text("error_payment_gateway_link_failed")) - except Exception: - pass - return False - - try: - 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, - back_callback=back_callback, - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception as e_edit: - logging.warning( - f"Edit message for payment link failed: {e_edit}. Sending new one." - ) - try: - await callback.message.answer( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - payment_response_yk["confirmation_url"], - current_lang, - i18n, - back_callback=back_callback, - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - pass - return True - - if payment_response_yk and payment_method_id: - status_to_store = payment_response_yk.get("status", "pending") - try: - await payment_dal.update_payment_status_by_db_id( - session, - payment_db_id=db_payment_record.payment_id, - new_status=status_to_store, - yk_payment_id=payment_response_yk.get("id"), - ) - if selected_method_internal_id is not None: - try: - await user_billing_dal.set_user_default_payment_method( - session, user_id, selected_method_internal_id - ) - except Exception: - logging.exception("Failed to set default payment method after saved-card payment start") - await session.commit() - except Exception as e_db_update_saved: - await session.rollback() - logging.error( - f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", - exc_info=True, - ) - try: - await callback.message.edit_text(get_text("error_payment_gateway")) - except Exception: - pass - return False - - message_text = get_text("yookassa_autopay_charge_initiated") - try: - await callback.message.edit_text( - message_text, - reply_markup=get_back_to_main_menu_markup(current_lang, i18n), - ) - except Exception as e_edit: - logging.warning(f"Failed to notify about saved-card charge start: {e_edit}") - try: - await callback.message.answer( - message_text, - reply_markup=get_back_to_main_menu_markup(current_lang, i18n), - ) - except Exception: - pass - return True - - 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}" - ) - try: - await callback.message.edit_text(get_text("error_payment_gateway")) - except Exception: - pass - return False - - -@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) - except ValueError: - 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 - - parsed = _parse_months_and_price(data_payload) - if not parsed: - logging.error(f"Invalid pay_yk payload structure: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - months, price_rub = parsed - user_id = callback.from_user.id - currency_code_for_yk = "RUB" - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) - autopay_require_binding = bool( - getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) - ) - saved_methods: List = [] - if autopay_enabled: - try: - saved_methods = await user_billing_dal.list_user_payment_methods( - session, user_id, provider="yookassa" - ) - except Exception as e_list: - logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}") - saved_methods = [] - - if autopay_enabled and saved_methods: - try: - await callback.message.edit_text( - get_text("yookassa_autopay_flow_prompt"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=True, - ), - ) - except Exception as e_edit: - logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.") - try: - await callback.message.answer( - get_text("yookassa_autopay_flow_prompt"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=True, - ), - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - await _initiate_yk_payment( - callback, - settings=settings, - session=session, - yookassa_service=yookassa_service, - i18n=i18n, - current_lang=current_lang, - get_text=get_text, - user_id=user_id, - months=months, - price_rub=price_rub, - currency_code_for_yk=currency_code_for_yk, - save_payment_method=autopay_enabled and autopay_require_binding, - back_callback=f"subscribe_period:{months}", - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk_new:")) -async def pay_yk_new_card_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 unavailable for pay_yk_new.") - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - try: - await callback.message.edit_text(get_text("payment_service_unavailable")) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - except ValueError: - logging.error(f"Invalid pay_yk_new data in callback: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parsed = _parse_months_and_price(data_payload) - if not parsed: - logging.error(f"Invalid pay_yk_new payload structure: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - months, price_rub = parsed - user_id = callback.from_user.id - currency_code_for_yk = "RUB" - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) - autopay_require_binding = bool( - getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) - ) - - await _initiate_yk_payment( - callback, - settings=settings, - session=session, - yookassa_service=yookassa_service, - i18n=i18n, - current_lang=current_lang, - get_text=get_text, - user_id=user_id, - months=months, - price_rub=price_rub, - currency_code_for_yk=currency_code_for_yk, - save_payment_method=autopay_enabled and autopay_require_binding, - back_callback=f"subscribe_period:{months}", - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk_saved_list:")) -async def pay_yk_saved_list_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 - - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) - if not autopay_enabled: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - except ValueError: - logging.error(f"Invalid pay_yk_saved_list data: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parts = data_payload.split(":") - if len(parts) < 2: - logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - try: - months = int(parts[0]) - price_rub = float(parts[1]) - page = int(parts[2]) if len(parts) > 2 else 0 - except (ValueError, IndexError): - logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - user_id = callback.from_user.id - try: - saved_methods = await user_billing_dal.list_user_payment_methods( - session, user_id, provider="yookassa" - ) - except Exception as e_list: - logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") - saved_methods = [] - - if not saved_methods: - try: - await callback.message.edit_text( - get_text("yookassa_autopay_no_saved_cards"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=False, - ), - ) - except Exception as e_edit: - logging.warning(f"Failed to display no-saved-card notice: {e_edit}") - try: - await callback.message.answer( - get_text("yookassa_autopay_no_saved_cards"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=False, - ), - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - cards: List[Tuple[str, str]] = [] - for method in saved_methods: - title = _format_saved_payment_method_title( - get_text, method.card_network, method.card_last4, method.is_default - ) - cards.append((str(method.method_id), title)) - - per_page = 5 - max_page = max(0, (len(cards) - 1) // per_page) - page = max(0, min(page, max_page)) - - try: - await callback.message.edit_text( - get_text("yookassa_autopay_choose_saved_card"), - reply_markup=get_yk_saved_cards_keyboard( - cards, - months, - price_rub, - current_lang, - i18n, - page=page, - ), - ) - except Exception as e_edit: - logging.warning(f"Failed to display saved card list: {e_edit}") - try: - await callback.message.answer( - get_text("yookassa_autopay_choose_saved_card"), - reply_markup=get_yk_saved_cards_keyboard( - cards, - months, - price_rub, - current_lang, - i18n, - page=page, - ), - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk_use_saved:")) -async def pay_yk_use_saved_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 - - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) - if not autopay_enabled: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - if not yookassa_service or not yookassa_service.configured: - logging.error("YooKassa service unavailable for pay_yk_use_saved.") - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - try: - await callback.message.edit_text(get_text("payment_service_unavailable")) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - except ValueError: - logging.error(f"Invalid pay_yk_use_saved data: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parts = data_payload.split(":") - if len(parts) < 3: - logging.error(f"pay_yk_use_saved payload missing components: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - try: - months = int(parts[0]) - price_rub = float(parts[1]) - except (ValueError, IndexError): - logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - method_identifier = parts[2] - user_id = callback.from_user.id - - try: - saved_methods = await user_billing_dal.list_user_payment_methods( - session, user_id, provider="yookassa" - ) - except Exception as e_list: - logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") - saved_methods = [] - - selected_method = None - for method in saved_methods: - if method_identifier.isdigit(): - if method.method_id == int(method_identifier): - selected_method = method - break - if method.provider_payment_method_id == method_identifier: - selected_method = method - break - - if not selected_method: - logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - currency_code_for_yk = "RUB" - - await _initiate_yk_payment( - callback, - settings=settings, - session=session, - yookassa_service=yookassa_service, - i18n=i18n, - current_lang=current_lang, - get_text=get_text, - user_id=user_id, - months=months, - price_rub=price_rub, - currency_code_for_yk=currency_code_for_yk, - save_payment_method=False, - back_callback=f"pay_yk_saved_list:{months}:{price_rub}", - payment_method_id=selected_method.provider_payment_method_id, - selected_method_internal_id=selected_method.method_id, - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_fk:")) -async def pay_fk_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - freekassa_service: FreeKassaService, - 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 freekassa_service or not freekassa_service.configured: - logging.error("FreeKassa service is not configured or unavailable.") - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - try: - await callback.message.edit_text(get_text("payment_service_unavailable")) - 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_fk 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 = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - - payment_record_payload = { - "user_id": user_id, - "amount": price_rub, - "currency": currency_code, - "status": "pending_freekassa", - "description": payment_description, - "subscription_duration_months": months, - "provider": "freekassa", - } - - try: - payment_record = await payment_dal.create_payment_record(session, payment_record_payload) - await session.commit() - except Exception as e_db_create: - await session.rollback() - logging.error( - f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}", - exc_info=True, - ) - try: - await callback.message.edit_text(get_text("error_creating_payment_record")) - except Exception: - pass - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - success, response_data = await freekassa_service.create_order( - payment_db_id=payment_record.payment_id, - user_id=payment_record.user_id, - months=months, - amount=price_rub, - currency=freekassa_service.default_currency, - payment_method_id=freekassa_service.payment_method_id, - ip_address=freekassa_service.server_ip, - extra_params={ - "us_method": freekassa_service.payment_method_id, - }, - ) - - if success: - location = response_data.get("location") - order_hash = response_data.get("orderHash") - order_id_api = response_data.get("orderId") - provider_identifier = order_hash or order_id_api - - if provider_identifier: - try: - await payment_dal.update_provider_payment_and_status( - session, - payment_record.payment_id, - str(provider_identifier), - payment_record.status, - ) - await session.commit() - except Exception as e_status: - await session.rollback() - logging.error( - f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", - exc_info=True, - ) - - if location: - order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id) - order_info_text = get_text( - "free_kassa_order_info", - order_id=order_identifier_display, - date=datetime.now().strftime("%Y-%m-%d"), - ) - try: - await callback.message.edit_text( - f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - location, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception as e_edit: - logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.") - try: - await callback.message.answer( - f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - location, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - logging.error( - "FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", - payment_record.payment_id, - response_data, - ) - else: - logging.error( - "FreeKassa: create_order failed for payment %s with response %s", - payment_record.payment_id, - response_data, - ) - - try: - await payment_dal.update_payment_status_by_db_id( - session, - payment_record.payment_id, - "failed_creation", - ) - await session.commit() - except Exception as e_status: - await session.rollback() - logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True) - - try: - await callback.message.edit_text(get_text("error_payment_gateway")) - except Exception: - pass - try: - await callback.answer(get_text("error_payment_gateway"), show_alert=True) - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_platega:")) -async def pay_platega_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - platega_service: PlategaService, - 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 platega_service or not platega_service.configured: - logging.error("Platega service is not configured or unavailable.") - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - try: - await callback.message.edit_text(get_text("payment_service_unavailable")) - 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_platega 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 = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - - payment_record_payload = { - "user_id": user_id, - "amount": price_rub, - "currency": currency_code, - "status": "pending_platega", - "description": payment_description, - "subscription_duration_months": months, - "provider": "platega", - } - - try: - payment_record = await payment_dal.create_payment_record(session, payment_record_payload) - await session.commit() - except Exception as e_db_create: - await session.rollback() - logging.error( - f"Platega: failed to create payment record for user {user_id}: {e_db_create}", - exc_info=True, - ) - try: - await callback.message.edit_text(get_text("error_creating_payment_record")) - except Exception: - pass - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - payload_meta = json.dumps( - { - "payment_db_id": payment_record.payment_id, - "user_id": user_id, - "months": months, - } - ) - - success, response_data = await platega_service.create_transaction( - payment_db_id=payment_record.payment_id, - user_id=user_id, - months=months, - amount=price_rub, - currency=currency_code, - description=payment_description, - payload=payload_meta, - ) - - if success: - transaction_id = response_data.get("transactionId") or response_data.get("id") - redirect_url = ( - response_data.get("redirect") - or response_data.get("url") - or response_data.get("paymentUrl") - ) - provider_status = response_data.get("status", payment_record.status) - - if transaction_id and redirect_url: - try: - await payment_dal.update_provider_payment_and_status( - session, - payment_record.payment_id, - str(transaction_id), - str(provider_status), - ) - await session.commit() - except Exception as e_status: - await session.rollback() - logging.error( - f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", - exc_info=True, - ) - - try: - await callback.message.edit_text( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - redirect_url, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception as e_edit: - logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.") - try: - await callback.message.answer( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - redirect_url, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - logging.error( - "Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", - payment_record.payment_id, - response_data, - ) - - try: - await payment_dal.update_payment_status_by_db_id( - session, - payment_record.payment_id, - "failed_creation", - ) - await session.commit() - except Exception as e_status: - await session.rollback() - logging.error(f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True) - - try: - await callback.message.edit_text(get_text("error_payment_gateway")) - except Exception: - pass - try: - await callback.answer(get_text("error_payment_gateway"), show_alert=True) - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_severpay:")) -async def pay_severpay_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - severpay_service: SeverPayService, - 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 severpay_service or not severpay_service.configured: - logging.error("SeverPay service is not configured or unavailable.") - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - try: - await callback.message.edit_text(get_text("payment_service_unavailable")) - 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_severpay 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 = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - - payment_record_payload = { - "user_id": user_id, - "amount": price_rub, - "currency": currency_code, - "status": "pending_severpay", - "description": payment_description, - "subscription_duration_months": months, - "provider": "severpay", - } - - try: - payment_record = await payment_dal.create_payment_record(session, payment_record_payload) - await session.commit() - except Exception as e_db_create: - await session.rollback() - logging.error( - f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}", - exc_info=True, - ) - try: - await callback.message.edit_text(get_text("error_creating_payment_record")) - except Exception: - pass - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - success, response_data = await severpay_service.create_payment( - payment_db_id=payment_record.payment_id, - user_id=user_id, - months=months, - amount=price_rub, - currency=currency_code, - description=payment_description, - ) - - if success: - payment_link = ( - response_data.get("url") - or response_data.get("payment_url") - or response_data.get("paymentUrl") - ) - provider_identifier = response_data.get("id") or response_data.get("uid") - - if provider_identifier: - try: - await payment_dal.update_provider_payment_and_status( - session, - payment_record.payment_id, - str(provider_identifier), - payment_record.status, - ) - await session.commit() - except Exception as e_status: - await session.rollback() - logging.error( - f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", - exc_info=True, - ) - - if payment_link: - try: - await callback.message.edit_text( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - payment_link, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception as e_edit: - logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.") - try: - await callback.message.answer( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - payment_link, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - logging.error( - "SeverPay: payment created but missing payment link for payment %s. Response: %s", - payment_record.payment_id, - response_data, - ) - - try: - await payment_dal.update_payment_status_by_db_id( - session, - payment_record.payment_id, - "failed_creation", - ) - await session.commit() - except Exception as e_status: - await session.rollback() - logging.error(f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True) - - try: - await callback.message.edit_text(get_text("error_payment_gateway")) - except Exception: - pass - try: - await callback.answer(get_text("error_payment_gateway"), show_alert=True) - 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, - session: AsyncSession, - cryptopay_service: CryptoPayService, -): - 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 getattr(cryptopay_service, "configured", False): - 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_amount = float(price_str) - except (ValueError, IndexError): - 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) - - invoice_url = await cryptopay_service.create_invoice( - session=session, - user_id=user_id, - months=months, - amount=price_amount, - description=payment_description, - ) - - if invoice_url: - try: - await callback.message.edit_text( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - invoice_url, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - try: - await callback.message.answer( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - invoice_url, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - try: - await callback.answer(get_text("error_payment_gateway"), show_alert=True) - 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, - 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 - - if not settings.STARS_ENABLED: - 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, stars_price_str = data_payload.split(":") - months = int(months_str) - stars_price = int(stars_price_str) - except (ValueError, IndexError): - 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_db_id = await stars_service.create_invoice( - session=session, - user_id=user_id, - months=months, - stars_price=stars_price, - description=payment_description, - ) - - if payment_db_id: - try: - await callback.message.edit_text( - get_text("payment_invoice_sent_message", months=months), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text=get_text("back_to_payment_methods_button"), - callback_data=f"subscribe_period:{months}", - )] - ]), - ) - except Exception as e_edit: - logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})") - try: - await callback.answer() - except Exception: - pass - return - - try: - await callback.answer(get_text("error_payment_gateway"), show_alert=True) - except Exception: - pass - - -@router.pre_checkout_query() -async def handle_pre_checkout_query(query: types.PreCheckoutQuery): - try: - await query.answer(ok=True) - except Exception: - # Nothing else to do here; Telegram will show an error if not answered - pass - - -@router.message(F.successful_payment) -async def handle_successful_stars_payment( - message: types.Message, - settings: Settings, - i18n_data: dict, - session: AsyncSession, - stars_service: StarsService, -): - payload = (message.successful_payment.invoice_payload - if message and message.successful_payment else "") - try: - payment_db_id_str, months_str = (payload or "").split(":", 1) - payment_db_id = int(payment_db_id_str) - months = int(months_str) - except Exception: - return - - stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0 - await stars_service.process_successful_payment( - session=session, - message=message, - payment_db_id=payment_db_id, - months=months, - stars_amount=stars_amount, - i18n_data=i18n_data, - ) +router.include_router(subscription_selection_router) +router.include_router(yookassa_router) +router.include_router(freekassa_router) +router.include_router(platega_router) +router.include_router(severpay_router) +router.include_router(crypto_router) +router.include_router(stars_router) + +__all__ = ["router"] diff --git a/bot/handlers/user/subscription/payments_crypto.py b/bot/handlers/user/subscription/payments_crypto.py new file mode 100644 index 0000000..267f4d4 --- /dev/null +++ b/bot/handlers/user/subscription/payments_crypto.py @@ -0,0 +1,100 @@ +from typing import Optional + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard +from bot.middlewares.i18n import JsonI18n +from bot.services.crypto_pay_service import CryptoPayService +from config.settings import Settings + +router = Router(name="user_subscription_payments_crypto_router") + + +@router.callback_query(F.data.startswith("pay_crypto:")) +async def pay_crypto_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + cryptopay_service: CryptoPayService, +): + 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 getattr(cryptopay_service, "configured", False): + 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_amount = float(price_str) + except (ValueError, IndexError): + 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) + + invoice_url = await cryptopay_service.create_invoice( + session=session, + user_id=user_id, + months=months, + amount=price_amount, + description=payment_description, + ) + + if invoice_url: + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + invoice_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + try: + await callback.message.answer( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + invoice_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py new file mode 100644 index 0000000..5266443 --- /dev/null +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -0,0 +1,200 @@ +import logging +from datetime import datetime +from typing import Optional + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard +from bot.middlewares.i18n import JsonI18n +from bot.services.freekassa_service import FreeKassaService +from config.settings import Settings +from db.dal import payment_dal + +router = Router(name="user_subscription_payments_freekassa_router") + + +@router.callback_query(F.data.startswith("pay_fk:")) +async def pay_fk_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + freekassa_service: FreeKassaService, + 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 freekassa_service or not freekassa_service.configured: + logging.error("FreeKassa service is not configured or unavailable.") + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + 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_fk 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 = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + payment_record_payload = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code, + "status": "pending_freekassa", + "description": payment_description, + "subscription_duration_months": months, + "provider": "freekassa", + } + + try: + payment_record = await payment_dal.create_payment_record(session, payment_record_payload) + await session.commit() + except Exception as e_db_create: + await session.rollback() + logging.error( + f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + success, response_data = await freekassa_service.create_order( + payment_db_id=payment_record.payment_id, + user_id=payment_record.user_id, + months=months, + amount=price_rub, + currency=freekassa_service.default_currency, + payment_method_id=freekassa_service.payment_method_id, + ip_address=freekassa_service.server_ip, + extra_params={ + "us_method": freekassa_service.payment_method_id, + }, + ) + + if success: + location = response_data.get("location") + order_hash = response_data.get("orderHash") + order_id_api = response_data.get("orderId") + provider_identifier = order_hash or order_id_api + + if provider_identifier: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment_record.payment_id, + str(provider_identifier), + payment_record.status, + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error( + f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", + exc_info=True, + ) + + if location: + order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id) + order_info_text = get_text( + "free_kassa_order_info", + order_id=order_identifier_display, + date=datetime.now().strftime("%Y-%m-%d"), + ) + try: + await callback.message.edit_text( + f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + location, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.") + try: + await callback.message.answer( + f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + location, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + logging.error( + "FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", + payment_record.payment_id, + response_data, + ) + else: + logging.error( + "FreeKassa: create_order failed for payment %s with response %s", + payment_record.payment_id, + response_data, + ) + + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_record.payment_id, + "failed_creation", + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True) + + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py new file mode 100644 index 0000000..1da2f29 --- /dev/null +++ b/bot/handlers/user/subscription/payments_platega.py @@ -0,0 +1,195 @@ +import json +import logging +from typing import Optional + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard +from bot.middlewares.i18n import JsonI18n +from bot.services.platega_service import PlategaService +from config.settings import Settings +from db.dal import payment_dal + +router = Router(name="user_subscription_payments_platega_router") + + +@router.callback_query(F.data.startswith("pay_platega:")) +async def pay_platega_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + platega_service: PlategaService, + 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 platega_service or not platega_service.configured: + logging.error("Platega service is not configured or unavailable.") + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + 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_platega 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 = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + payment_record_payload = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code, + "status": "pending_platega", + "description": payment_description, + "subscription_duration_months": months, + "provider": "platega", + } + + try: + payment_record = await payment_dal.create_payment_record(session, payment_record_payload) + await session.commit() + except Exception as e_db_create: + await session.rollback() + logging.error( + f"Platega: failed to create payment record for user {user_id}: {e_db_create}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + payload_meta = json.dumps( + { + "payment_db_id": payment_record.payment_id, + "user_id": user_id, + "months": months, + } + ) + + success, response_data = await platega_service.create_transaction( + payment_db_id=payment_record.payment_id, + user_id=user_id, + months=months, + amount=price_rub, + currency=currency_code, + description=payment_description, + payload=payload_meta, + ) + + if success: + transaction_id = response_data.get("transactionId") or response_data.get("id") + redirect_url = ( + response_data.get("redirect") + or response_data.get("url") + or response_data.get("paymentUrl") + ) + provider_status = response_data.get("status", payment_record.status) + + if transaction_id and redirect_url: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment_record.payment_id, + str(transaction_id), + str(provider_status), + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error( + f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", + exc_info=True, + ) + + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + redirect_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.") + try: + await callback.message.answer( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + redirect_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + logging.error( + "Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", + payment_record.payment_id, + response_data, + ) + + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_record.payment_id, + "failed_creation", + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error(f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True) + + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py new file mode 100644 index 0000000..47df521 --- /dev/null +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -0,0 +1,185 @@ +import logging +from typing import Optional + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard +from bot.middlewares.i18n import JsonI18n +from bot.services.severpay_service import SeverPayService +from config.settings import Settings +from db.dal import payment_dal + +router = Router(name="user_subscription_payments_severpay_router") + + +@router.callback_query(F.data.startswith("pay_severpay:")) +async def pay_severpay_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + severpay_service: SeverPayService, + 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 severpay_service or not severpay_service.configured: + logging.error("SeverPay service is not configured or unavailable.") + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + 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_severpay 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 = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + payment_record_payload = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code, + "status": "pending_severpay", + "description": payment_description, + "subscription_duration_months": months, + "provider": "severpay", + } + + try: + payment_record = await payment_dal.create_payment_record(session, payment_record_payload) + await session.commit() + except Exception as e_db_create: + await session.rollback() + logging.error( + f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + success, response_data = await severpay_service.create_payment( + payment_db_id=payment_record.payment_id, + user_id=user_id, + months=months, + amount=price_rub, + currency=currency_code, + description=payment_description, + ) + + if success: + payment_link = ( + response_data.get("url") + or response_data.get("payment_url") + or response_data.get("paymentUrl") + ) + provider_identifier = response_data.get("id") or response_data.get("uid") + + if provider_identifier: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment_record.payment_id, + str(provider_identifier), + payment_record.status, + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error( + f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", + exc_info=True, + ) + + if payment_link: + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_link, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.") + try: + await callback.message.answer( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_link, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + logging.error( + "SeverPay: payment created but missing payment link for payment %s. Response: %s", + payment_record.payment_id, + response_data, + ) + + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_record.payment_id, + "failed_creation", + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error(f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True) + + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass diff --git a/bot/handlers/user/subscription/payments_stars.py b/bot/handlers/user/subscription/payments_stars.py new file mode 100644 index 0000000..fa4ef47 --- /dev/null +++ b/bot/handlers/user/subscription/payments_stars.py @@ -0,0 +1,123 @@ +import logging +from typing import Optional + +from aiogram import F, Router, types +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.middlewares.i18n import JsonI18n +from bot.services.stars_service import StarsService +from config.settings import Settings + +router = Router(name="user_subscription_payments_stars_router") + + +@router.callback_query(F.data.startswith("pay_stars:")) +async def pay_stars_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + 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 + + if not settings.STARS_ENABLED: + 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, stars_price_str = data_payload.split(":") + months = int(months_str) + stars_price = int(stars_price_str) + except (ValueError, IndexError): + 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_db_id = await stars_service.create_invoice( + session=session, + user_id=user_id, + months=months, + stars_price=stars_price, + description=payment_description, + ) + + if payment_db_id: + try: + await callback.message.edit_text( + get_text("payment_invoice_sent_message", months=months), + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=get_text("back_to_payment_methods_button"), + callback_data=f"subscribe_period:{months}", + )] + ]), + ) + except Exception as e_edit: + logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})") + try: + await callback.answer() + except Exception: + pass + return + + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass + + +@router.pre_checkout_query() +async def handle_pre_checkout_query(query: types.PreCheckoutQuery): + try: + await query.answer(ok=True) + except Exception: + # Nothing else to do here; Telegram will show an error if not answered + pass + + +@router.message(F.successful_payment) +async def handle_successful_stars_payment( + message: types.Message, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + stars_service: StarsService, +): + payload = (message.successful_payment.invoice_payload + if message and message.successful_payment else "") + try: + payment_db_id_str, months_str = (payload or "").split(":", 1) + payment_db_id = int(payment_db_id_str) + months = int(months_str) + except Exception: + return + + stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0 + await stars_service.process_successful_payment( + session=session, + message=message, + payment_db_id=payment_db_id, + months=months, + stars_amount=stars_amount, + i18n_data=i18n_data, + ) diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py new file mode 100644 index 0000000..7116d18 --- /dev/null +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -0,0 +1,78 @@ +import logging +from typing import Optional + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard +from bot.middlewares.i18n import JsonI18n +from config.settings import Settings + +router = Router(name="user_subscription_payments_selection_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 diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py new file mode 100644 index 0000000..e1ed517 --- /dev/null +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -0,0 +1,737 @@ +import logging +from typing import List, Optional, Tuple + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import ( + get_back_to_main_menu_markup, + get_payment_url_keyboard, + get_yk_autopay_choice_keyboard, + get_yk_saved_cards_keyboard, +) +from bot.middlewares.i18n import JsonI18n +from bot.services.yookassa_service import YooKassaService +from config.settings import Settings +from db.dal import payment_dal, user_billing_dal + +router = Router(name="user_subscription_payments_yookassa_router") + + +def _parse_months_and_price(payload: str) -> Optional[Tuple[int, float]]: + try: + months_str, price_str = payload.split(":") + return int(months_str), float(price_str) + except (ValueError, IndexError): + return None + + +def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str: + def _is_yoomoney_network(name: Optional[str]) -> bool: + s = (name 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 + + if _is_yoomoney_network(network): + inferred_last4 = last4 or (_extract_last4(network or "") or "****") + title = get_text("payment_method_wallet_title", last4=inferred_last4) + elif last4: + network_name = network or get_text("payment_network_card") + title = get_text("payment_method_card_title", network=network_name, last4=last4) + else: + network_name = network or get_text("payment_network_generic") + title = get_text("payment_method_generic_title", network=network_name) + return f"⭐ {title}" if is_default else title + + +async def _initiate_yk_payment( + callback: types.CallbackQuery, + *, + settings: Settings, + session: AsyncSession, + yookassa_service: YooKassaService, + i18n: Optional[JsonI18n], + current_lang: str, + get_text, + user_id: int, + months: int, + price_rub: float, + currency_code_for_yk: str, + save_payment_method: bool, + back_callback: str, + payment_method_id: Optional[str] = None, + selected_method_internal_id: Optional[int] = None, +) -> bool: + """Create payment record and initiate YooKassa payment (new card or saved card).""" + if not callback.message: + return False + + payment_description = get_text("payment_description_subscription", months=months) + 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, + ) + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + return False + + if not db_payment_record: + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + return False + + yookassa_metadata = { + "user_id": str(user_id), + "subscription_months": str(months), + "payment_db_id": str(db_payment_record.payment_id), + } + if payment_method_id: + yookassa_metadata["used_saved_payment_method_id"] = payment_method_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=save_payment_method, + payment_method_id=payment_method_id, + ) + + 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=save_payment_method, + ) + 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"), + ) + if selected_method_internal_id is not None: + try: + await user_billing_dal.set_user_default_payment_method( + session, user_id, selected_method_internal_id + ) + except Exception: + logging.exception("Failed to set default payment method after initiating payment") + 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, + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway_link_failed")) + except Exception: + pass + return False + + try: + 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, + back_callback=back_callback, + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning( + f"Edit message for payment link failed: {e_edit}. Sending new one." + ) + try: + await callback.message.answer( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_response_yk["confirmation_url"], + current_lang, + i18n, + back_callback=back_callback, + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + return True + + if payment_response_yk and payment_method_id: + status_to_store = payment_response_yk.get("status", "pending") + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=db_payment_record.payment_id, + new_status=status_to_store, + yk_payment_id=payment_response_yk.get("id"), + ) + if selected_method_internal_id is not None: + try: + await user_billing_dal.set_user_default_payment_method( + session, user_id, selected_method_internal_id + ) + except Exception: + logging.exception("Failed to set default payment method after saved-card payment start") + await session.commit() + except Exception as e_db_update_saved: + await session.rollback() + logging.error( + f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + return False + + message_text = get_text("yookassa_autopay_charge_initiated") + try: + await callback.message.edit_text( + message_text, + reply_markup=get_back_to_main_menu_markup(current_lang, i18n), + ) + except Exception as e_edit: + logging.warning(f"Failed to notify about saved-card charge start: {e_edit}") + try: + await callback.message.answer( + message_text, + reply_markup=get_back_to_main_menu_markup(current_lang, i18n), + ) + except Exception: + pass + return True + + 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}" + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + return False + + +@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) + except ValueError: + 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 + + parsed = _parse_months_and_price(data_payload) + if not parsed: + logging.error(f"Invalid pay_yk payload structure: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + months, price_rub = parsed + user_id = callback.from_user.id + currency_code_for_yk = "RUB" + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_require_binding = bool( + getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) + ) + saved_methods: List = [] + if autopay_enabled: + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + if autopay_enabled and saved_methods: + try: + await callback.message.edit_text( + get_text("yookassa_autopay_flow_prompt"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=True, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.") + try: + await callback.message.answer( + get_text("yookassa_autopay_flow_prompt"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=True, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=autopay_enabled and autopay_require_binding, + back_callback=f"subscribe_period:{months}", + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_new:")) +async def pay_yk_new_card_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 unavailable for pay_yk_new.") + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk_new data in callback: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parsed = _parse_months_and_price(data_payload) + if not parsed: + logging.error(f"Invalid pay_yk_new payload structure: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + months, price_rub = parsed + user_id = callback.from_user.id + currency_code_for_yk = "RUB" + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_require_binding = bool( + getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) + ) + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=autopay_enabled and autopay_require_binding, + back_callback=f"subscribe_period:{months}", + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_saved_list:")) +async def pay_yk_saved_list_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 + + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk_saved_list data: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parts = data_payload.split(":") + if len(parts) < 2: + logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + months = int(parts[0]) + price_rub = float(parts[1]) + page = int(parts[2]) if len(parts) > 2 else 0 + except (ValueError, IndexError): + logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + user_id = callback.from_user.id + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + if not saved_methods: + try: + await callback.message.edit_text( + get_text("yookassa_autopay_no_saved_cards"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=False, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to display no-saved-card notice: {e_edit}") + try: + await callback.message.answer( + get_text("yookassa_autopay_no_saved_cards"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=False, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + cards: List[Tuple[str, str]] = [] + for method in saved_methods: + title = _format_saved_payment_method_title( + get_text, method.card_network, method.card_last4, method.is_default + ) + cards.append((str(method.method_id), title)) + + per_page = 5 + max_page = max(0, (len(cards) - 1) // per_page) + page = max(0, min(page, max_page)) + + try: + await callback.message.edit_text( + get_text("yookassa_autopay_choose_saved_card"), + reply_markup=get_yk_saved_cards_keyboard( + cards, + months, + price_rub, + current_lang, + i18n, + page=page, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to display saved card list: {e_edit}") + try: + await callback.message.answer( + get_text("yookassa_autopay_choose_saved_card"), + reply_markup=get_yk_saved_cards_keyboard( + cards, + months, + price_rub, + current_lang, + i18n, + page=page, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_use_saved:")) +async def pay_yk_use_saved_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 + + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + if not yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service unavailable for pay_yk_use_saved.") + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk_use_saved data: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parts = data_payload.split(":") + if len(parts) < 3: + logging.error(f"pay_yk_use_saved payload missing components: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + months = int(parts[0]) + price_rub = float(parts[1]) + except (ValueError, IndexError): + logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + method_identifier = parts[2] + user_id = callback.from_user.id + + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + selected_method = None + for method in saved_methods: + if method_identifier.isdigit(): + if method.method_id == int(method_identifier): + selected_method = method + break + if method.provider_payment_method_id == method_identifier: + selected_method = method + break + + if not selected_method: + logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + currency_code_for_yk = "RUB" + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=False, + back_callback=f"pay_yk_saved_list:{months}:{price_rub}", + payment_method_id=selected_method.provider_payment_method_id, + selected_method_internal_id=selected_method.method_id, + ) + try: + await callback.answer() + except Exception: + pass diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index c8e649f..b9e75af 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -1,6 +1,7 @@ import aiohttp import logging import json +import re from typing import Optional, List, Dict, Any from datetime import datetime, timedelta, timezone import asyncio @@ -345,18 +346,19 @@ class PanelApiService: status: str = "ACTIVE", log_response: bool = True) -> Optional[Dict[str, Any]]: - if not (6 <= len(username_on_panel) <= 34 and - username_on_panel.replace('_', '').replace('-', '').isalnum()): - if not (username_on_panel.startswith("tg_") - and username_on_panel.split("tg_")[-1].isdigit()): - msg = f"Panel username '{username_on_panel}' does not meet panel requirements." - logging.error(msg) - return { - "error": True, - "status_code": 400, - "message": msg, - "errorCode": "VALIDATION_ERROR_USERNAME" - } + username_is_valid = ( + 3 <= len(username_on_panel) <= 36 + and re.match(r"^[A-Za-z0-9_-]+$", username_on_panel) is not None + ) + if not username_is_valid: + msg = f"Panel username '{username_on_panel}' does not meet panel requirements." + logging.error(msg) + return { + "error": True, + "status_code": 400, + "message": msg, + "errorCode": "VALIDATION_ERROR_USERNAME" + } now = datetime.now(timezone.utc) expire_at_dt = now + timedelta(days=default_expire_days) diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index b22b7b4..8b7b1c7 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -717,7 +717,8 @@ class SubscriptionService: update_payload_local = {} panel_status = panel_user_data.get("status", "UNKNOWN").upper() panel_expire_at_str = panel_user_data.get("expireAt") - panel_traffic_used = panel_user_data.get("usedTrafficBytes") + traffic_stats = panel_user_data.get("userTraffic") or {} + panel_traffic_used = traffic_stats.get("usedTrafficBytes") panel_traffic_limit = panel_user_data.get("trafficLimitBytes") panel_sub_uuid_from_panel = panel_user_data.get( "subscriptionUuid" @@ -781,7 +782,7 @@ class SubscriptionService: "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), "config_link": panel_user_data.get("subscriptionUrl"), "traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"), - "traffic_used_bytes": panel_user_data.get("usedTrafficBytes"), + "traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"), "user_bot_username": db_user.username, "is_panel_data": True, "max_devices": hwid_limit,