diff --git a/app/config.py b/app/config.py index aed79f7f..52b13789 100644 --- a/app/config.py +++ b/app/config.py @@ -187,6 +187,8 @@ class Settings(BaseSettings): DISABLE_TOPUP_BUTTONS: bool = False PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10 + + AUTOBUY_AFTER_TOPUP_ENABLED: bool = False # Настройки простой покупки SIMPLE_SUBSCRIPTION_ENABLED: bool = False @@ -786,9 +788,12 @@ class Settings(BaseSettings): def is_traffic_fixed(self) -> bool: return self.TRAFFIC_SELECTION_MODE.lower() == "fixed" - + def get_fixed_traffic_limit(self) -> int: return self.FIXED_TRAFFIC_LIMIT_GB + + def is_autobuy_after_topup_enabled(self) -> bool: + return bool(self.AUTOBUY_AFTER_TOPUP_ENABLED) def is_yookassa_enabled(self) -> bool: return (self.YOOKASSA_ENABLED and diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 7b51db85..cee2c5f7 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1,10 +1,12 @@ import base64 import json import logging +from dataclasses import dataclass from datetime import datetime, timedelta from typing import Dict, List, Any, Tuple, Optional from urllib.parse import quote from aiogram import Dispatcher, types, F +from aiogram import Bot from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton @@ -141,6 +143,17 @@ from .traffic import ( select_traffic, ) + +@dataclass(slots=True) +class PurchaseExecutionResult: + success: bool + message: Optional[str] = None + keyboard: Optional[InlineKeyboardMarkup] = None + error_message: Optional[str] = None + missing_amount: Optional[int] = None + purchase_completed: bool = False + + async def show_subscription_info( callback: types.CallbackQuery, db_user: User, @@ -1339,6 +1352,332 @@ async def devices_continue( await state.set_state(SubscriptionStates.confirming_purchase) await callback.answer() +async def _complete_purchase_workflow( + *, + db: AsyncSession, + db_user: User, + data: Dict[str, Any], + final_price: int, + final_traffic_gb: int, + months_in_period: int, + promo_offer_discount_value: int, + promo_offer_discount_percent: int, + server_prices: List[int], + texts, + bot: Optional[Bot], + purchase_reason: str, +) -> PurchaseExecutionResult: + purchase_completed = False + + try: + success = await subtract_user_balance( + db, + db_user, + final_price, + purchase_reason, + consume_promo_offer=promo_offer_discount_value > 0, + ) + + if not success: + missing_kopeks = max(0, final_price - db_user.balance_kopeks) + return PurchaseExecutionResult( + success=False, + missing_amount=missing_kopeks, + ) + + existing_subscription = db_user.subscription + was_trial_conversion = False + current_time = datetime.utcnow() + + if existing_subscription: + logger.info(f"Обновляем существующую подписку пользователя {db_user.telegram_id}") + + bonus_period = timedelta() + + if existing_subscription.is_trial: + logger.info(f"Конверсия из триала в платную для пользователя {db_user.telegram_id}") + was_trial_conversion = True + + trial_duration = (current_time - existing_subscription.start_date).days + + if settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID and existing_subscription.end_date: + remaining_trial_delta = existing_subscription.end_date - current_time + if remaining_trial_delta.total_seconds() > 0: + bonus_period = remaining_trial_delta + logger.info( + "Добавляем оставшееся время триала (%s) к новой подписке пользователя %s", + bonus_period, + db_user.telegram_id, + ) + + try: + from app.database.crud.subscription_conversion import create_subscription_conversion + + await create_subscription_conversion( + db=db, + user_id=db_user.id, + trial_duration_days=trial_duration, + payment_method="balance", + first_payment_amount_kopeks=final_price, + first_paid_period_days=data['period_days'] + ) + logger.info( + "Записана конверсия: %s дн. триал → %s дн. платная за %s₽", + trial_duration, + data['period_days'], + final_price / 100, + ) + except Exception as conversion_error: + logger.error(f"Ошибка записи конверсии: {conversion_error}") + + existing_subscription.is_trial = False + existing_subscription.status = SubscriptionStatus.ACTIVE.value + existing_subscription.traffic_limit_gb = final_traffic_gb + existing_subscription.device_limit = data['devices'] + existing_subscription.connected_squads = data['countries'] + + existing_subscription.start_date = current_time + existing_subscription.end_date = current_time + timedelta(days=data['period_days']) + bonus_period + existing_subscription.updated_at = current_time + + existing_subscription.traffic_used_gb = 0.0 + + await db.commit() + await db.refresh(existing_subscription) + subscription = existing_subscription + + else: + logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}") + subscription = await create_paid_subscription_with_traffic_mode( + db=db, + user_id=db_user.id, + duration_days=data['period_days'], + device_limit=data['devices'], + connected_squads=data['countries'], + traffic_gb=final_traffic_gb + ) + + from app.utils.user_utils import mark_user_as_had_paid_subscription + + await mark_user_as_had_paid_subscription(db, db_user) + + from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers + from app.database.crud.subscription import add_subscription_servers + + server_ids = await get_server_ids_by_uuids(db, data['countries']) + + if server_ids: + await add_subscription_servers(db, subscription, server_ids, server_prices) + await add_user_to_servers(db, server_ids) + + logger.info(f"Сохранены цены серверов за весь период: {server_prices}") + + await db.refresh(db_user) + + subscription_service = SubscriptionService() + + if db_user.remnawave_uuid: + remnawave_user = await subscription_service.update_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="покупка подписки", + ) + else: + remnawave_user = await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="покупка подписки", + ) + + if not remnawave_user: + logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}") + remnawave_user = await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="покупка подписки (повторная попытка)", + ) + + transaction = await create_transaction( + db=db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=final_price, + description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)" + ) + + if bot: + try: + notification_service = AdminNotificationService(bot) + await notification_service.send_subscription_purchase_notification( + db, db_user, subscription, transaction, data['period_days'], was_trial_conversion + ) + except Exception as error: + logger.error(f"Ошибка отправки уведомления о покупке: {error}") + + await db.refresh(db_user) + await db.refresh(subscription) + + subscription_link = get_display_subscription_link(subscription) + hide_subscription_link = settings.should_hide_subscription_link() + + discount_note = "" + if promo_offer_discount_value > 0: + discount_note = texts.t( + "SUBSCRIPTION_PROMO_DISCOUNT_NOTE", + "⚡ Доп. скидка {percent}%: -{amount}", + ).format( + percent=promo_offer_discount_percent, + amount=texts.format_price(promo_offer_discount_value), + ) + + connect_keyboard: InlineKeyboardMarkup + + if remnawave_user and subscription_link: + if settings.is_happ_cryptolink_mode(): + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) + ) + elif hide_subscription_link: + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + + texts.t( + "SUBSCRIPTION_LINK_HIDDEN_NOTICE", + "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) + ) + else: + import_link_section = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) + + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + f"{import_link_section}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + ) + + if discount_note: + success_text = f"{success_text}\n\n{discount_note}" + + connect_mode = settings.CONNECT_BUTTON_MODE + + if connect_mode == "miniapp_subscription": + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + web_app=types.WebAppInfo(url=subscription_link), + ) + ], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], + ]) + elif connect_mode == "miniapp_custom": + target_url = settings.MINIAPP_CUSTOM_URL + if not target_url: + logger.warning("Кастомная ссылка MiniApp не настроена, используем стандартную клавиатуру подключения") + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], + ]) + else: + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + web_app=types.WebAppInfo(url=target_url), + ) + ], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], + ]) + elif connect_mode == "link": + rows = [ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + else: + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], + ]) + else: + purchase_text = texts.SUBSCRIPTION_PURCHASED + if discount_note: + purchase_text = f"{purchase_text}\n\n{discount_note}" + success_text = texts.t( + "SUBSCRIPTION_LINK_GENERATING_NOTICE", + "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", + ).format(purchase_text=purchase_text) + connect_keyboard = get_back_keyboard(db_user.language) + + purchase_completed = True + logger.info( + "Пользователь %s купил подписку на %s дней за %s₽", + db_user.telegram_id, + data['period_days'], + final_price / 100, + ) + + await user_cart_service.delete_user_cart(db_user.id) + + return PurchaseExecutionResult( + success=True, + message=success_text, + keyboard=connect_keyboard, + purchase_completed=purchase_completed, + ) + + except Exception as error: + logger.error(f"Ошибка покупки подписки: {error}") + return PurchaseExecutionResult( + success=False, + error_message=texts.ERROR, + purchase_completed=purchase_completed, + ) + async def confirm_purchase( callback: types.CallbackQuery, state: FSMContext, @@ -1621,19 +1960,25 @@ async def confirm_purchase( await callback.answer() return - purchase_completed = False + purchase_reason = f"Покупка подписки на {data['period_days']} дней" + result = await _complete_purchase_workflow( + db=db, + db_user=db_user, + data=data, + final_price=final_price, + final_traffic_gb=final_traffic_gb, + months_in_period=months_in_period, + promo_offer_discount_value=promo_offer_discount_value, + promo_offer_discount_percent=promo_offer_discount_percent, + server_prices=server_prices, + texts=texts, + bot=getattr(callback, "bot", None), + purchase_reason=purchase_reason, + ) - try: - success = await subtract_user_balance( - db, - db_user, - final_price, - f"Покупка подписки на {data['period_days']} дней", - consume_promo_offer=promo_offer_discount_value > 0, - ) - - if not success: - missing_kopeks = final_price - db_user.balance_kopeks + if not result.success: + if result.missing_amount is not None: + missing_kopeks = result.missing_amount message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1658,285 +2003,22 @@ async def confirm_purchase( ), parse_mode="HTML", ) - await callback.answer() - return - - existing_subscription = db_user.subscription - was_trial_conversion = False - current_time = datetime.utcnow() - - if existing_subscription: - logger.info(f"Обновляем существующую подписку пользователя {db_user.telegram_id}") - - bonus_period = timedelta() - - if existing_subscription.is_trial: - logger.info(f"Конверсия из триала в платную для пользователя {db_user.telegram_id}") - was_trial_conversion = True - - trial_duration = (current_time - existing_subscription.start_date).days - - if settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID and existing_subscription.end_date: - remaining_trial_delta = existing_subscription.end_date - current_time - if remaining_trial_delta.total_seconds() > 0: - bonus_period = remaining_trial_delta - logger.info( - "Добавляем оставшееся время триала (%s) к новой подписке пользователя %s", - bonus_period, - db_user.telegram_id, - ) - - try: - from app.database.crud.subscription_conversion import create_subscription_conversion - await create_subscription_conversion( - db=db, - user_id=db_user.id, - trial_duration_days=trial_duration, - payment_method="balance", - first_payment_amount_kopeks=final_price, - first_paid_period_days=data['period_days'] - ) - logger.info( - f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price / 100}₽") - except Exception as conversion_error: - logger.error(f"Ошибка записи конверсии: {conversion_error}") - - existing_subscription.is_trial = False - existing_subscription.status = SubscriptionStatus.ACTIVE.value - existing_subscription.traffic_limit_gb = final_traffic_gb - existing_subscription.device_limit = data['devices'] - existing_subscription.connected_squads = data['countries'] - - existing_subscription.start_date = current_time - existing_subscription.end_date = current_time + timedelta(days=data['period_days']) + bonus_period - existing_subscription.updated_at = current_time - - existing_subscription.traffic_used_gb = 0.0 - - await db.commit() - await db.refresh(existing_subscription) - subscription = existing_subscription - else: - logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}") - subscription = await create_paid_subscription_with_traffic_mode( - db=db, - user_id=db_user.id, - duration_days=data['period_days'], - device_limit=data['devices'], - connected_squads=data['countries'], - traffic_gb=final_traffic_gb - ) - - from app.utils.user_utils import mark_user_as_had_paid_subscription - await mark_user_as_had_paid_subscription(db, db_user) - - from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers - from app.database.crud.subscription import add_subscription_servers - - server_ids = await get_server_ids_by_uuids(db, data['countries']) - - if server_ids: - await add_subscription_servers(db, subscription, server_ids, server_prices) - await add_user_to_servers(db, server_ids) - - logger.info(f"Сохранены цены серверов за весь период: {server_prices}") - - await db.refresh(db_user) - - subscription_service = SubscriptionService() - - if db_user.remnawave_uuid: - remnawave_user = await subscription_service.update_remnawave_user( - db, - subscription, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="покупка подписки", - ) - else: - remnawave_user = await subscription_service.create_remnawave_user( - db, - subscription, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="покупка подписки", - ) - - if not remnawave_user: - logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}") - remnawave_user = await subscription_service.create_remnawave_user( - db, - subscription, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="покупка подписки (повторная попытка)", - ) - - transaction = await create_transaction( - db=db, - user_id=db_user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=final_price, - description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)" - ) - - try: - notification_service = AdminNotificationService(callback.bot) - await notification_service.send_subscription_purchase_notification( - db, db_user, subscription, transaction, data['period_days'], was_trial_conversion - ) - except Exception as e: - logger.error(f"Ошибка отправки уведомления о покупке: {e}") - - await db.refresh(db_user) - await db.refresh(subscription) - - subscription_link = get_display_subscription_link(subscription) - hide_subscription_link = settings.should_hide_subscription_link() - - discount_note = "" - if promo_offer_discount_value > 0: - discount_note = texts.t( - "SUBSCRIPTION_PROMO_DISCOUNT_NOTE", - "⚡ Доп. скидка {percent}%: -{amount}", - ).format( - percent=promo_offer_discount_percent, - amount=texts.format_price(promo_offer_discount_value), - ) - - if remnawave_user and subscription_link: - if settings.is_happ_cryptolink_mode(): - success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) - ) - elif hide_subscription_link: - success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) - ) - else: - import_link_section = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) - - success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - f"{import_link_section}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" - ) - - if discount_note: - success_text = f"{success_text}\n\n{discount_note}" - - connect_mode = settings.CONNECT_BUTTON_MODE - - if connect_mode == "miniapp_subscription": - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription_link), - ) - ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu")], - ]) - elif connect_mode == "miniapp_custom": - if not settings.MINIAPP_CUSTOM_URL: - await callback.answer( - texts.t( - "CUSTOM_MINIAPP_URL_NOT_SET", - "⚠ Кастомная ссылка для мини-приложения не настроена", - ), - show_alert=True, - ) - return - - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), - ) - ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu")], - ]) - elif connect_mode == "link": - rows = [ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu")]) - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - elif connect_mode == "happ_cryptolink": - rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", - ) - ] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu")]) - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - else: - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu")], - ]) - await callback.message.edit_text( - success_text, - reply_markup=connect_keyboard, - parse_mode="HTML" - ) - else: - purchase_text = texts.SUBSCRIPTION_PURCHASED - if discount_note: - purchase_text = f"{purchase_text}\n\n{discount_note}" - await callback.message.edit_text( - texts.t( - "SUBSCRIPTION_LINK_GENERATING_NOTICE", - "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", - ).format(purchase_text=purchase_text), + result.error_message or texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - purchase_completed = True - logger.info( - f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price / 100}₽") + await callback.answer() + return - except Exception as e: - logger.error(f"Ошибка покупки подписки: {e}") + purchase_completed = result.purchase_completed + + if result.message: await callback.message.edit_text( - texts.ERROR, - reply_markup=get_back_keyboard(db_user.language) + result.message, + reply_markup=result.keyboard or get_back_keyboard(db_user.language), + parse_mode="HTML", ) if purchase_completed: @@ -1945,6 +2027,97 @@ async def confirm_purchase( await state.clear() await callback.answer() + +async def attempt_auto_purchase_after_topup( + db: AsyncSession, + db_user: User, + bot: Optional[Bot], +) -> bool: + """Пробует автоматически завершить покупку подписки после пополнения баланса.""" + + if not settings.is_autobuy_after_topup_enabled(): + return False + + cart_data = await user_cart_service.get_user_cart(db_user.id) + + if not cart_data: + return False + + texts = get_texts(db_user.language) + + try: + _, prepared_data = await _prepare_subscription_summary(db_user, cart_data, texts) + except ValueError as error: + logger.error( + "Не удалось пересобрать сохраненную корзину пользователя %s: %s", + db_user.telegram_id, + error, + ) + await user_cart_service.delete_user_cart(db_user.id) + return False + + final_price = prepared_data.get('total_price', 0) + + if db_user.balance_kopeks < final_price or final_price <= 0: + return False + + months_in_period = prepared_data.get( + 'months_in_period', calculate_months_from_days(prepared_data['period_days']) + ) + promo_offer_discount_value = prepared_data.get('promo_offer_discount_value', 0) + promo_offer_discount_percent = prepared_data.get('promo_offer_discount_percent', 0) + + if settings.is_traffic_fixed(): + final_traffic_gb = settings.get_fixed_traffic_limit() + else: + final_traffic_gb = prepared_data.get('final_traffic_gb', prepared_data.get('traffic_gb', 0)) + + server_prices = prepared_data.get('server_prices_for_period', []) + + result = await _complete_purchase_workflow( + db=db, + db_user=db_user, + data=prepared_data, + final_price=final_price, + final_traffic_gb=final_traffic_gb, + months_in_period=months_in_period, + promo_offer_discount_value=promo_offer_discount_value, + promo_offer_discount_percent=promo_offer_discount_percent, + server_prices=server_prices, + texts=texts, + bot=bot, + purchase_reason=f"Автопокупка подписки на {prepared_data['period_days']} дней", + ) + + if not result.success: + if result.error_message: + logger.warning( + "Автопокупка подписки для пользователя %s не выполнена: %s", + db_user.telegram_id, + result.error_message, + ) + return False + + if result.message and bot: + try: + await bot.send_message( + chat_id=db_user.telegram_id, + text=result.message, + parse_mode="HTML", + reply_markup=result.keyboard or get_back_keyboard(db_user.language), + ) + except Exception as error: + logger.error( + "Не удалось отправить сообщение об автопокупке пользователю %s: %s", + db_user.telegram_id, + error, + ) + + if result.purchase_completed: + await clear_subscription_checkout_draft(db_user.id) + + return result.purchase_completed + async def resume_subscription_checkout( callback: types.CallbackQuery, state: FSMContext, diff --git a/app/services/payment/common.py b/app/services/payment/common.py index d9b949da..d4759fd0 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -101,6 +101,58 @@ class PaymentCommonMixin: db=db, ) + if settings.is_autobuy_after_topup_enabled(): + full_user = user + session_for_purchase: AsyncSession | None = db + + if full_user is None and db is not None: + try: + full_user = await get_user_by_telegram_id(db, telegram_id) + except Exception as fetch_error: + logger.warning( + "Не удалось получить пользователя %s из переданной сессии для автопокупки: %s", + telegram_id, + fetch_error, + ) + + if full_user is None: + try: + async for db_session in get_db(): + try: + full_user = await get_user_by_telegram_id(db_session, telegram_id) + session_for_purchase = db_session + except Exception as fetch_error: + logger.warning( + "Не удалось получить пользователя %s из новой сессии: %s", + telegram_id, + fetch_error, + ) + full_user = None + else: + break + except Exception as fetch_error: + logger.warning( + "Ошибка открытия сессии для автопокупки пользователя %s: %s", + telegram_id, + fetch_error, + ) + + if full_user is not None and session_for_purchase is not None: + try: + from app.handlers.subscription.purchase import attempt_auto_purchase_after_topup + + await attempt_auto_purchase_after_topup( + session_for_purchase, + full_user, + getattr(self, "bot", None), + ) + except Exception as auto_error: + logger.error( + "Ошибка автопокупки подписки после пополнения для пользователя %s: %s", + telegram_id, + auto_error, + ) + try: keyboard = await self.build_topup_success_keyboard(user_snapshot) diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 673a8cc0..8a927c04 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -198,6 +198,7 @@ class BotConfigurationService: "BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE", "DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC", "RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC", + "AUTOBUY_AFTER_TOPUP_ENABLED": "PAYMENT", "TRAFFIC_SELECTION_MODE": "TRAFFIC", "FIXED_TRAFFIC_LIMIT_GB": "TRAFFIC", "AVAILABLE_SUBSCRIPTION_PERIODS": "PERIODS", @@ -474,6 +475,15 @@ class BotConfigurationService: "warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.", "dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED", }, + "AUTOBUY_AFTER_TOPUP_ENABLED": { + "description": ( + "Включает автоматическое завершение сохранённой покупки подписки после пополнения" + " баланса, если средств стало достаточно." + ), + "format": "Булево значение.", + "example": "true", + "warning": "Перед включением убедитесь, что расчёт корзины корректен и пользователи понимают поведение.", + }, "SUPPORT_TICKET_SLA_MINUTES": { "description": "Лимит времени для ответа модераторов на тикет в минутах.", "format": "Целое число от 1 до 1440.", diff --git a/tests/test_autobuy_after_topup.py b/tests/test_autobuy_after_topup.py new file mode 100644 index 00000000..4a1fc6e4 --- /dev/null +++ b/tests/test_autobuy_after_topup.py @@ -0,0 +1,106 @@ +import sys +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from app.handlers.subscription.purchase import ( # noqa: E402 + attempt_auto_purchase_after_topup, + PurchaseExecutionResult, +) +from app.services.payment.common import PaymentCommonMixin # noqa: E402 +from app.config import settings # noqa: E402 + + +@pytest.mark.asyncio +async def test_attempt_auto_purchase_after_topup_disabled(monkeypatch): + monkeypatch.setattr(settings, "AUTOBUY_AFTER_TOPUP_ENABLED", False) + + with patch("app.handlers.subscription.purchase.user_cart_service") as cart_service: + cart_service.get_user_cart = AsyncMock() + + result = await attempt_auto_purchase_after_topup( + AsyncMock(), + AsyncMock(), + AsyncMock(), + ) + + assert result is False + cart_service.get_user_cart.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_attempt_auto_purchase_after_topup_success(monkeypatch): + monkeypatch.setattr(settings, "AUTOBUY_AFTER_TOPUP_ENABLED", True) + + user = AsyncMock() + user.language = "ru" + user.telegram_id = 123 + user.id = 123 + user.balance_kopeks = 20000 + + prepared_data = { + "total_price": 10000, + "period_days": 30, + "months_in_period": 1, + "final_traffic_gb": 0, + "server_prices_for_period": [], + } + + bot = AsyncMock() + + with patch("app.handlers.subscription.purchase.user_cart_service") as cart_service, \ + patch("app.handlers.subscription.purchase._prepare_subscription_summary") as prepare_summary, \ + patch("app.handlers.subscription.purchase._complete_purchase_workflow") as complete_purchase, \ + patch("app.handlers.subscription.purchase.clear_subscription_checkout_draft") as clear_draft: + + cart_service.get_user_cart = AsyncMock(return_value={"period_days": 30, "total_price": 10000}) + prepare_summary.return_value = (None, prepared_data) + complete_purchase.return_value = PurchaseExecutionResult( + success=True, + message="done", + keyboard=None, + purchase_completed=True, + ) + + result = await attempt_auto_purchase_after_topup( + AsyncMock(), + user, + bot, + ) + + assert result is True + bot.send_message.assert_awaited() + clear_draft.assert_awaited_with(user.id) + + +class _DummyPayment(PaymentCommonMixin): + def __init__(self): + self.bot = AsyncMock() + + async def build_topup_success_keyboard(self, user): + return AsyncMock() + + +@pytest.mark.asyncio +async def test_send_payment_notification_triggers_autobuy(monkeypatch): + monkeypatch.setattr(settings, "AUTOBUY_AFTER_TOPUP_ENABLED", True) + + dummy = _DummyPayment() + + user = AsyncMock() + user.telegram_id = 42 + + with patch("app.handlers.subscription.purchase.attempt_auto_purchase_after_topup", new=AsyncMock(return_value=True)) as autopurchase: + + await dummy._send_payment_success_notification( + telegram_id=user.telegram_id, + amount_kopeks=1000, + user=user, + db=AsyncMock(), + payment_method_title="Test", + ) + + autopurchase.assert_awaited()