diff --git a/app/database/models.py b/app/database/models.py index b4e9bc80..3fb28fb5 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -529,6 +529,7 @@ class User(Base): vless_uuid = Column(String(255), nullable=True) ss_password = Column(String(255), nullable=True) has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + auto_purchase_after_topup_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") poll_responses = relationship("PollResponse", back_populates="user") diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 7b51db85..4de323ca 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -21,7 +21,7 @@ from app.database.crud.subscription import ( update_subscription_autopay ) from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance +from app.database.crud.user import subtract_user_balance, update_user from app.database.models import ( User, TransactionType, SubscriptionStatus, Subscription @@ -650,6 +650,22 @@ async def _edit_message_text_or_caption( raise + +def _get_auto_purchase_status_lines(texts, enabled: bool) -> tuple[str, str]: + status_text = texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS.format( + status=( + texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS_ENABLED + if enabled + else texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS_DISABLED + ) + ) + status_hint = ( + texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_ON + if enabled + else texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_OFF + ) + return status_text, status_hint + async def save_cart_and_redirect_to_topup( callback: types.CallbackQuery, state: FSMContext, @@ -670,20 +686,157 @@ async def save_cart_and_redirect_to_topup( await user_cart_service.save_user_cart(db_user.id, cart_data) + auto_purchase_enabled = getattr(db_user, "auto_purchase_after_topup_enabled", False) + status_text, status_hint = _get_auto_purchase_status_lines(texts, auto_purchase_enabled) + await callback.message.edit_text( f"💰 Недостаточно средств для оформления подписки\n\n" f"Требуется: {texts.format_price(missing_amount)}\n" f"У вас: {texts.format_price(db_user.balance_kopeks)}\n\n" f"🛒 Ваша корзина сохранена!\n" + f"{status_text}\n" + f"{status_hint}\n\n" f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n" f"Выберите способ пополнения:", reply_markup=get_payment_methods_keyboard_with_cart( db_user.language, missing_amount, + auto_purchase_enabled=auto_purchase_enabled, ), parse_mode="HTML" ) + +def _rebuild_topup_prompt_text( + texts, + missing_amount: int, + balance: int, + *, + status_text: str, + status_hint: str, +) -> str: + return ( + f"💰 Недостаточно средств для оформления подписки\n\n" + f"Требуется: {texts.format_price(missing_amount)}\n" + f"У вас: {texts.format_price(balance)}\n\n" + f"🛒 Ваша корзина сохранена!\n" + f"{status_text}\n" + f"{status_hint}\n\n" + f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n" + f"Выберите способ пополнения:" + ) + + +def _rebuild_insufficient_text( + texts, + total_price: int, + balance: int, + missing_amount: int, + *, + status_text: str, + status_hint: str, +) -> str: + return ( + f"❌ Все еще недостаточно средств\n\n" + f"Требуется: {texts.format_price(total_price)}\n" + f"У вас: {texts.format_price(balance)}\n" + f"Не хватает: {texts.format_price(missing_amount)}\n\n" + f"{status_text}\n{status_hint}" + ) + + +async def toggle_auto_purchase_after_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + enable = callback.data == "auto_purchase_topup_toggle_on" + + try: + if enable != getattr(db_user, "auto_purchase_after_topup_enabled", False): + db_user = await update_user( + db, + db_user, + auto_purchase_after_topup_enabled=enable, + ) + except Exception as error: + logger.error("Не удалось обновить настройку автопокупки: %s", error) + await callback.answer("⚠️ Не удалось обновить настройку", show_alert=True) + return + + texts = get_texts(db_user.language) + notice = ( + texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_ON + if enable + else texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_OFF + ) + await callback.answer(notice, show_alert=False) + + cart_data = await user_cart_service.get_user_cart(db_user.id) + if not cart_data: + # Обновляем только клавиатуру, если корзина не найдена + try: + await callback.message.edit_reply_markup( + reply_markup=get_payment_methods_keyboard_with_cart( + db_user.language, + 0, + auto_purchase_enabled=enable, + ) + ) + except Exception: + pass + return + + total_price = cart_data.get('total_price', 0) + missing_amount = cart_data.get('missing_amount', 0) + + if total_price: + recalculated_missing = max(0, total_price - db_user.balance_kopeks) + missing_amount = recalculated_missing + cart_data['missing_amount'] = missing_amount + await user_cart_service.save_user_cart(db_user.id, cart_data) + + status_text, status_hint = _get_auto_purchase_status_lines(texts, enable) + + message_text = callback.message.text or callback.message.caption or "" + reply_markup: InlineKeyboardMarkup + + if "Ваша корзина сохранена" in message_text: + new_text = _rebuild_topup_prompt_text( + texts, + missing_amount, + db_user.balance_kopeks, + status_text=status_text, + status_hint=status_hint, + ) + reply_markup = get_payment_methods_keyboard_with_cart( + db_user.language, + missing_amount, + auto_purchase_enabled=enable, + ) + else: + total_value = total_price or (db_user.balance_kopeks + missing_amount) + new_text = _rebuild_insufficient_text( + texts, + total_value, + db_user.balance_kopeks, + missing_amount, + status_text=status_text, + status_hint=status_hint, + ) + reply_markup = get_insufficient_balance_keyboard_with_cart( + db_user.language, + missing_amount, + auto_purchase_enabled=enable, + ) + + await _edit_message_text_or_caption( + callback.message, + new_text, + reply_markup=reply_markup, + parse_mode="HTML", + ) + async def return_to_saved_cart( callback: types.CallbackQuery, state: FSMContext, @@ -702,14 +855,18 @@ async def return_to_saved_cart( if db_user.balance_kopeks < total_price: missing_amount = total_price - db_user.balance_kopeks + auto_purchase_enabled = getattr(db_user, "auto_purchase_after_topup_enabled", False) + status_text, status_hint = _get_auto_purchase_status_lines(texts, auto_purchase_enabled) await callback.message.edit_text( f"❌ Все еще недостаточно средств\n\n" f"Требуется: {texts.format_price(total_price)}\n" f"У вас: {texts.format_price(db_user.balance_kopeks)}\n" - f"Не хватает: {texts.format_price(missing_amount)}", + f"Не хватает: {texts.format_price(missing_amount)}\n\n" + f"{status_text}\n{status_hint}", reply_markup=get_insufficient_balance_keyboard_with_cart( db_user.language, missing_amount, + auto_purchase_enabled=auto_purchase_enabled, ) ) return @@ -1597,6 +1754,11 @@ async def confirm_purchase( missing=texts.format_price(missing_kopeks), ) + auto_purchase_enabled = getattr(db_user, "auto_purchase_after_topup_enabled", False) + status_text, status_hint = _get_auto_purchase_status_lines(texts, auto_purchase_enabled) + + message_text = f"{message_text}\n\n{status_text}\n{status_hint}" + # Сохраняем данные корзины в Redis перед переходом к пополнению cart_data = { **data, @@ -1614,7 +1776,8 @@ async def confirm_purchase( db_user.language, resume_callback=resume_callback, amount_kopeks=missing_kopeks, - has_saved_cart=True # Указываем, что есть сохраненная корзина + has_saved_cart=True, # Указываем, что есть сохраненная корзина + auto_purchase_enabled=auto_purchase_enabled, ), parse_mode="HTML", ) @@ -1649,12 +1812,18 @@ async def confirm_purchase( missing=texts.format_price(missing_kopeks), ) + auto_purchase_enabled = getattr(db_user, "auto_purchase_after_topup_enabled", False) + status_text, status_hint = _get_auto_purchase_status_lines(texts, auto_purchase_enabled) + + message_text = f"{message_text}\n\n{status_text}\n{status_hint}" + await callback.message.edit_text( message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, resume_callback=resume_callback, amount_kopeks=missing_kopeks, + auto_purchase_enabled=auto_purchase_enabled, ), parse_mode="HTML", ) @@ -2212,6 +2381,11 @@ def register_handlers(dp: Dispatcher): F.data == "clear_saved_cart", ) + dp.callback_query.register( + toggle_auto_purchase_after_topup, + F.data.in_(["auto_purchase_topup_toggle_on", "auto_purchase_topup_toggle_off"]), + ) + dp.callback_query.register( handle_autopay_menu, F.data == "subscription_autopay" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 46a64f05..94731644 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -643,6 +643,8 @@ def get_insufficient_balance_keyboard( resume_callback: str | None = None, amount_kopeks: int | None = None, has_saved_cart: bool = False, # Новый параметр для указания наличия сохраненной корзины + *, + auto_purchase_enabled: bool | None = None, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -663,25 +665,44 @@ def get_insufficient_balance_keyboard( ) back_row_index = len(keyboard.inline_keyboard) - 1 + insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard) + rows_to_insert: list[list[InlineKeyboardButton]] = [] + + if auto_purchase_enabled is not None: + toggle_row = [ + InlineKeyboardButton( + text=( + texts.AUTO_PURCHASE_AFTER_TOPUP_DISABLE_BUTTON + if auto_purchase_enabled + else texts.AUTO_PURCHASE_AFTER_TOPUP_ENABLE_BUTTON + ), + callback_data=( + "auto_purchase_topup_toggle_off" + if auto_purchase_enabled + else "auto_purchase_topup_toggle_on" + ), + ) + ] + rows_to_insert.append(toggle_row) + # Если есть сохраненная корзина, добавляем кнопку возврата к оформлению if has_saved_cart: - return_row = [ + rows_to_insert.append([ InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, callback_data="subscription_resume_checkout", ) - ] - insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard) - keyboard.inline_keyboard.insert(insert_index, return_row) + ]) elif resume_callback: - return_row = [ + rows_to_insert.append([ InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, callback_data=resume_callback, ) - ] - insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard) - keyboard.inline_keyboard.insert(insert_index, return_row) + ]) + + for offset, row in enumerate(rows_to_insert): + keyboard.inline_keyboard.insert(insert_index + offset, row) return keyboard @@ -784,10 +805,34 @@ def get_subscription_keyboard( def get_payment_methods_keyboard_with_cart( language: str = "ru", amount_kopeks: int = 0, + *, + auto_purchase_enabled: bool | None = None, ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = get_payment_methods_keyboard(amount_kopeks, language) + toggle_row_insert_index = 0 + + if auto_purchase_enabled is not None: + keyboard.inline_keyboard.insert( + toggle_row_insert_index, + [ + InlineKeyboardButton( + text=( + texts.AUTO_PURCHASE_AFTER_TOPUP_DISABLE_BUTTON + if auto_purchase_enabled + else texts.AUTO_PURCHASE_AFTER_TOPUP_ENABLE_BUTTON + ), + callback_data=( + "auto_purchase_topup_toggle_off" + if auto_purchase_enabled + else "auto_purchase_topup_toggle_on" + ), + ) + ], + ) + toggle_row_insert_index += 1 + # Добавляем кнопку "Очистить корзину" keyboard.inline_keyboard.append([ InlineKeyboardButton( diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 4e9083e4..29e30c58 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -780,6 +780,16 @@ "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", "BALANCE_TOPUP": "💳 Top up balance", "BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 Balance top-up required\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 Top up your balance to complete the purchase.\\n\\nChoose a top-up method:", + "AUTO_PURCHASE_AFTER_TOPUP_STATUS": "🤖 Auto purchase after top-up: {status}", + "AUTO_PURCHASE_AFTER_TOPUP_STATUS_ENABLED": "enabled", + "AUTO_PURCHASE_AFTER_TOPUP_STATUS_DISABLED": "disabled", + "AUTO_PURCHASE_AFTER_TOPUP_ENABLE_BUTTON": "🤖 Enable auto purchase", + "AUTO_PURCHASE_AFTER_TOPUP_DISABLE_BUTTON": "🚫 Disable auto purchase", + "AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_ON": "🤖 Auto purchase enabled. Your subscription will be paid automatically after topping up.", + "AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_OFF": "🤖 Auto purchase disabled.", + "AUTO_PURCHASE_AFTER_TOPUP_SUCCESS": "🤖 Auto purchase completed\\n\\nYour subscription for {period} was paid automatically. Charged: {amount}.", + "AUTO_PURCHASE_AFTER_TOPUP_SUCCESS_WITH_DISCOUNT": "🤖 Auto purchase completed\\n\\nYour subscription for {period} was paid automatically. Charged: {amount}. Discount: {discount}.", + "AUTO_PURCHASE_AFTER_TOPUP_INSUFFICIENT": "🤖 Auto purchase failed — balance is still insufficient.", "BALANCE_TOP_UP": "💳 Top up", "BLOCK_BY_TIME": "⏳ Temporary block", "BLOCK_FOREVER": "🚫 Block permanently", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 81d732a4..7e08eaf8 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -780,6 +780,16 @@ "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", "BALANCE_TOPUP": "💳 Пополнить баланс", "BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 Требуется пополнение баланса\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 Пополните баланс, чтобы завершить покупку.\n\nВыберите способ пополнения:", + "AUTO_PURCHASE_AFTER_TOPUP_STATUS": "🤖 Автопокупка после пополнения: {status}", + "AUTO_PURCHASE_AFTER_TOPUP_STATUS_ENABLED": "включена", + "AUTO_PURCHASE_AFTER_TOPUP_STATUS_DISABLED": "выключена", + "AUTO_PURCHASE_AFTER_TOPUP_ENABLE_BUTTON": "🤖 Включить автопокупку", + "AUTO_PURCHASE_AFTER_TOPUP_DISABLE_BUTTON": "🚫 Отключить автопокупку", + "AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_ON": "🤖 Автопокупка включена. После пополнения баланс будет списан автоматически.", + "AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_OFF": "🤖 Автопокупка отключена.", + "AUTO_PURCHASE_AFTER_TOPUP_SUCCESS": "🤖 Автопокупка выполнена\n\nПодписка на {period} оплачена автоматически. Списано: {amount}.", + "AUTO_PURCHASE_AFTER_TOPUP_SUCCESS_WITH_DISCOUNT": "🤖 Автопокупка выполнена\n\nПодписка на {period} оплачена автоматически. Списано: {amount}. Скидка: {discount}.", + "AUTO_PURCHASE_AFTER_TOPUP_INSUFFICIENT": "🤖 Автопокупка не выполнена — на балансе все еще недостаточно средств.", "BALANCE_TOP_UP": "💳 Пополнить", "BLOCK_BY_TIME": "⏳ Блокировка по времени", "BLOCK_FOREVER": "🚫 Заблокировать", diff --git a/app/services/auto_purchase_service.py b/app/services/auto_purchase_service.py new file mode 100644 index 00000000..b8bce2f0 --- /dev/null +++ b/app/services/auto_purchase_service.py @@ -0,0 +1,307 @@ +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import List, Optional + +from aiogram import Bot +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.server_squad import add_user_to_servers, get_server_ids_by_uuids +from app.database.crud.subscription import add_subscription_servers, create_paid_subscription +from app.database.crud.transaction import create_transaction +from app.database.crud.user import subtract_user_balance +from app.database.models import SubscriptionStatus, TransactionType, User +from app.keyboards.inline import get_insufficient_balance_keyboard_with_cart +from app.localization.texts import get_texts +from app.services.admin_notification_service import AdminNotificationService +from app.services.subscription_checkout_service import clear_subscription_checkout_draft +from app.services.subscription_service import SubscriptionService +from app.services.user_cart_service import user_cart_service +from app.utils.pricing_utils import format_period_description +from app.utils.subscription_utils import get_display_subscription_link +from app.utils.user_utils import mark_user_as_had_paid_subscription + +logger = logging.getLogger(__name__) + + +@dataclass +class AutoPurchaseResult: + triggered: bool + success: bool + + +def _get_auto_purchase_status_lines(texts, enabled: bool) -> tuple[str, str]: + status_text = texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS.format( + status=( + texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS_ENABLED + if enabled + else texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS_DISABLED + ) + ) + status_hint = ( + texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_ON + if enabled + else texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_OFF + ) + return status_text, status_hint + + +def _build_autopurchase_failure_text(texts, total_price: int, balance: int, missing: int, enabled: bool) -> str: + status_text, status_hint = _get_auto_purchase_status_lines(texts, enabled) + return ( + f"{texts.AUTO_PURCHASE_AFTER_TOPUP_INSUFFICIENT}\n\n" + f"Стоимость: {texts.format_price(total_price)}\n" + f"На балансе: {texts.format_price(balance)}\n" + f"Не хватает: {texts.format_price(missing)}\n\n" + f"{status_text}\n{status_hint}" + ) + + +def _build_autopurchase_success_prefix(texts, language: str, period_days: int, final_price: int, discount_value: int) -> str: + period_display = format_period_description(period_days, language) + if discount_value > 0: + return texts.AUTO_PURCHASE_AFTER_TOPUP_SUCCESS_WITH_DISCOUNT.format( + period=period_display, + amount=texts.format_price(final_price), + discount=texts.format_price(discount_value), + ) + return texts.AUTO_PURCHASE_AFTER_TOPUP_SUCCESS.format( + period=period_display, + amount=texts.format_price(final_price), + ) + + +async def try_auto_purchase_after_topup( + db: AsyncSession, + user: User, + bot: Optional[Bot], +) -> AutoPurchaseResult: + if not getattr(user, "auto_purchase_after_topup_enabled", False): + return AutoPurchaseResult(triggered=False, success=False) + + cart_data = await user_cart_service.get_user_cart(user.id) + if not cart_data: + return AutoPurchaseResult(triggered=False, success=False) + + texts = get_texts(user.language) + + from app.handlers.subscription.pricing import _prepare_subscription_summary + + try: + _, prepared_data = await _prepare_subscription_summary(user, cart_data, texts) + except ValueError as error: + logger.error("Не удалось восстановить корзину для автопокупки: %s", error) + await user_cart_service.delete_user_cart(user.id) + return AutoPurchaseResult(triggered=True, success=False) + + final_price = prepared_data.get('total_price', 0) + promo_offer_discount_value = prepared_data.get('promo_offer_discount_value', 0) + promo_offer_discount_percent = prepared_data.get('promo_offer_discount_percent', 0) + + await db.refresh(user) + + if user.balance_kopeks < final_price: + missing = final_price - user.balance_kopeks + failure_text = _build_autopurchase_failure_text( + texts, + final_price, + user.balance_kopeks, + missing, + True, + ) + if bot: + try: + await bot.send_message( + chat_id=user.telegram_id, + text=failure_text, + parse_mode="HTML", + reply_markup=get_insufficient_balance_keyboard_with_cart( + user.language, + missing, + auto_purchase_enabled=True, + ), + ) + except Exception as send_error: + logger.error("Не удалось отправить уведомление об автопокупке: %s", send_error) + return AutoPurchaseResult(triggered=True, success=False) + + success = await subtract_user_balance( + db, + user, + final_price, + f"Покупка подписки на {prepared_data['period_days']} дней (авто)", + consume_promo_offer=promo_offer_discount_value > 0, + ) + + if not success: + await db.refresh(user) + missing = max(0, final_price - user.balance_kopeks) + failure_text = _build_autopurchase_failure_text( + texts, + final_price, + user.balance_kopeks, + missing, + True, + ) + if bot: + try: + await bot.send_message( + chat_id=user.telegram_id, + text=failure_text, + parse_mode="HTML", + reply_markup=get_insufficient_balance_keyboard_with_cart( + user.language, + missing, + auto_purchase_enabled=True, + ), + ) + except Exception as send_error: + logger.error("Не удалось отправить уведомление об автопокупке: %s", send_error) + return AutoPurchaseResult(triggered=True, success=False) + + final_traffic_gb = prepared_data.get('final_traffic_gb', prepared_data.get('traffic_gb', 0)) + server_prices = prepared_data.get('server_prices_for_period', []) + + existing_subscription = user.subscription + was_trial_conversion = False + current_time = datetime.utcnow() + + if existing_subscription: + bonus_period = timedelta() + if existing_subscription.is_trial: + 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 + + existing_subscription.is_trial = False + existing_subscription.status = SubscriptionStatus.ACTIVE.value + existing_subscription.traffic_limit_gb = final_traffic_gb + existing_subscription.device_limit = prepared_data['devices'] + existing_subscription.connected_squads = prepared_data['countries'] + existing_subscription.start_date = current_time + existing_subscription.end_date = current_time + timedelta(days=prepared_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: + subscription = await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=prepared_data['period_days'], + traffic_limit_gb=final_traffic_gb, + device_limit=prepared_data['devices'], + connected_squads=prepared_data['countries'], + ) + + await mark_user_as_had_paid_subscription(db, user) + + server_ids = await get_server_ids_by_uuids(db, prepared_data['countries']) + if server_ids: + await add_subscription_servers(db, subscription, server_ids, server_prices) + await add_user_to_servers(db, server_ids) + + await db.refresh(user) + + subscription_service = SubscriptionService() + if 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: + 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=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=final_price, + description=f"Подписка на {prepared_data['period_days']} дней (авто)", + ) + + if bot: + try: + notification_service = AdminNotificationService(bot) + await notification_service.send_subscription_purchase_notification( + db, + user, + subscription, + transaction, + prepared_data['period_days'], + was_trial_conversion, + ) + except Exception as notify_error: + logger.error("Ошибка отправки уведомления админам об автопокупке: %s", notify_error) + + await db.refresh(user) + await db.refresh(subscription) + + subscription_link = get_display_subscription_link(subscription) + hide_subscription_link = settings.should_hide_subscription_link() + + auto_prefix = _build_autopurchase_success_prefix( + texts, + user.language, + prepared_data['period_days'], + final_price, + promo_offer_discount_value, + ) + + instruction_text = texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) + success_text = f"{texts.SUBSCRIPTION_PURCHASED}\n\n{auto_prefix}\n\n{instruction_text}" + + if bot: + rows: List[List[InlineKeyboardButton]] = [] + if subscription_link and not hide_subscription_link: + rows.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=subscription_link, + ) + ]) + rows.append([ + InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription") + ]) + rows.append([ + InlineKeyboardButton(text=texts.BACK_TO_MAIN_MENU_BUTTON, callback_data="back_to_menu") + ]) + + try: + await bot.send_message( + chat_id=user.telegram_id, + text=success_text, + parse_mode="HTML", + reply_markup=InlineKeyboardMarkup(inline_keyboard=rows), + ) + except Exception as send_error: + logger.error("Не удалось отправить сообщение об автопокупке: %s", send_error) + + await clear_subscription_checkout_draft(user.id) + await user_cart_service.delete_user_cart(user.id) + + return AutoPurchaseResult(triggered=True, success=True) diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index aab5adb5..f007b5e6 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.auto_purchase_service import try_auto_purchase_after_topup +from app.services.user_cart_service import user_cart_service from app.utils.currency_converter import currency_converter from app.utils.user_utils import format_referrer_info @@ -292,20 +294,25 @@ class CryptoBotPaymentMixin: # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: - from app.services.user_cart_service import user_cart_service + autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None)) + if autopurchase_result.triggered: + logger.info( + "Автопокупка после пополнения %s для пользователя %s", + "успешна" if autopurchase_result.success else "не выполнена", + user.id, + ) + return True + from aiogram import types + from app.localization.texts import get_texts + has_saved_cart = await user_cart_service.has_user_cart(user.id) if has_saved_cart and getattr(self, "bot", None): - # Если у пользователя есть сохраненная корзина, - # отправляем ему уведомление с кнопкой вернуться к оформлению - from app.localization.texts import get_texts - texts = get_texts(user.language) cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format( total_amount=settings.format_price(payment.amount_kopeks) ) - - # Создаем клавиатуру с кнопками + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, @@ -320,7 +327,7 @@ class CryptoBotPaymentMixin: callback_data="back_to_menu" )] ]) - + await self.bot.send_message( chat_id=user.telegram_id, text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}", diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 71a9dd8b..904b7a27 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.auto_purchase_service import try_auto_purchase_after_topup +from app.services.user_cart_service import user_cart_service from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -323,11 +325,19 @@ class MulenPayPaymentMixin: # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: - from app.services.user_cart_service import user_cart_service + autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None)) + if autopurchase_result.triggered: + logger.info( + "Автопокупка после пополнения %s для пользователя %s", + "успешна" if autopurchase_result.success else "не выполнена", + user.id, + ) + return True + from aiogram import types has_saved_cart = await user_cart_service.has_user_cart(user.id) if has_saved_cart and getattr(self, "bot", None): - # Если у пользователя есть сохраненная корзина, + # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from app.localization.texts import get_texts diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 7cae4325..ad2f0b4d 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -12,7 +12,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.auto_purchase_service import try_auto_purchase_after_topup from app.services.pal24_service import Pal24APIError +from app.services.user_cart_service import user_cart_service from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -434,6 +436,15 @@ class Pal24PaymentMixin: from aiogram import types has_saved_cart = await user_cart_service.has_user_cart(user.id) + autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None)) + if autopurchase_result.triggered: + logger.info( + "Автопокупка после пополнения %s для пользователя %s", + "успешна" if autopurchase_result.success else "не выполнена", + user.id, + ) + return True + if has_saved_cart and getattr(self, "bot", None): from app.localization.texts import get_texts diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index ea710427..cc8597f3 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -19,6 +19,8 @@ from app.database.crud.transaction import create_transaction from app.database.crud.user import get_user_by_id from app.database.models import PaymentMethod, TransactionType from app.external.telegram_stars import TelegramStarsService +from app.services.auto_purchase_service import try_auto_purchase_after_topup +from app.services.user_cart_service import user_cart_service from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -239,7 +241,15 @@ class TelegramStarsMixin: # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: - from app.services.user_cart_service import user_cart_service + autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None)) + if autopurchase_result.triggered: + logger.info( + "Автопокупка после пополнения %s для пользователя %s", + "успешна" if autopurchase_result.success else "не выполнена", + user.id, + ) + return True + from aiogram import types has_saved_cart = await user_cart_service.has_user_cart(user.id) if has_saved_cart and getattr(self, "bot", None): diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 134c1ca0..378e99d3 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -12,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.auto_purchase_service import try_auto_purchase_after_topup +from app.services.user_cart_service import user_cart_service from app.services.wata_service import WataAPIError, WataService from app.utils.user_utils import format_referrer_info @@ -520,7 +522,15 @@ class WataPaymentMixin: logger.error("Ошибка отправки уведомления пользователю WATA: %s", error) try: - from app.services.user_cart_service import user_cart_service + autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None)) + if autopurchase_result.triggered: + logger.info( + "Автопокупка после пополнения %s для пользователя %s", + "успешна" if autopurchase_result.success else "не выполнена", + user.id, + ) + return payment + from aiogram import types has_saved_cart = await user_cart_service.has_user_cart(user.id) diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index b4210899..67a699b5 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.auto_purchase_service import try_auto_purchase_after_topup +from app.services.user_cart_service import user_cart_service from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -462,8 +464,16 @@ class YooKassaPaymentMixin: # Проверяем наличие сохраненной корзины для возврата к оформлению подписки # ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}") - from app.services.user_cart_service import user_cart_service try: + autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None)) + if autopurchase_result.triggered: + logger.info( + "Автопокупка после пополнения %s для пользователя %s", + "успешна" if autopurchase_result.success else "не выполнена", + user.id, + ) + return True + has_saved_cart = await user_cart_service.has_user_cart(user.id) logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}") if has_saved_cart and getattr(self, "bot", None): diff --git a/migrations/alembic/versions/f2acb8b40cb5_add_auto_purchase_after_topup.py b/migrations/alembic/versions/f2acb8b40cb5_add_auto_purchase_after_topup.py new file mode 100644 index 00000000..c244ba19 --- /dev/null +++ b/migrations/alembic/versions/f2acb8b40cb5_add_auto_purchase_after_topup.py @@ -0,0 +1,36 @@ +"""add auto purchase after topup flag""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "f2acb8b40cb5" +down_revision: Union[str, None] = "9f0f2d5a1c7b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +USERS_TABLE = "users" +COLUMN_NAME = "auto_purchase_after_topup_enabled" + + +def upgrade() -> None: + op.add_column( + USERS_TABLE, + sa.Column( + COLUMN_NAME, + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.alter_column( + USERS_TABLE, + COLUMN_NAME, + server_default=None, + ) + + +def downgrade() -> None: + op.drop_column(USERS_TABLE, COLUMN_NAME) diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index f003d765..1f97c024 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -762,6 +762,40 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) - ) monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub) + autopurchase_mock = AsyncMock( + return_value=SimpleNamespace(triggered=False, success=False) + ) + monkeypatch.setattr( + "app.services.payment.pal24.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.wata.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.yookassa.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.cryptobot.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.mulenpay.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.stars.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + class DummyTypes: class InlineKeyboardMarkup: def __init__(self, inline_keyboard=None, **kwargs): @@ -928,6 +962,40 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey ) monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub) + autopurchase_mock = AsyncMock( + return_value=SimpleNamespace(triggered=False, success=False) + ) + monkeypatch.setattr( + "app.services.payment.pal24.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.wata.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.yookassa.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.cryptobot.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.mulenpay.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.stars.try_auto_purchase_after_topup", + autopurchase_mock, + raising=False, + ) + class DummyTypes: class InlineKeyboardMarkup: def __init__(self, inline_keyboard=None, **kwargs):