From 212783ae3dac0975966d46292c0ae04d12dbcf1a Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 20:26:35 +0300 Subject: [PATCH] Fix auto-purchase subscription refresh after YooKassa top-up --- app/handlers/simple_subscription.py | 24 +- app/handlers/stars_payments.py | 2 +- app/handlers/subscription/purchase.py | 15 +- app/localization/locales/en.json | 2 + app/localization/locales/ru.json | 2 + app/services/payment/stars.py | 694 +++++++++++++----- .../subscription_auto_purchase_service.py | 336 ++++++++- app/services/subscription_purchase_service.py | 20 +- tests/services/test_payment_service_stars.py | 205 ++++++ ...test_subscription_auto_purchase_service.py | 121 ++- 10 files changed, 1213 insertions(+), 208 deletions(-) diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 38d92f1e..43051911 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -677,6 +677,7 @@ async def handle_simple_subscription_payment_method( try: payment_service = PaymentService(callback.bot) + purchase_service = SubscriptionPurchaseService() resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( db, @@ -696,8 +697,23 @@ async def handle_simple_subscription_payment_method( if payment_method == "stars": # Оплата через Telegram Stars + order = await purchase_service.create_subscription_order( + db=db, + user_id=db_user.id, + period_days=subscription_params["period_days"], + device_limit=subscription_params["device_limit"], + traffic_limit_gb=subscription_params["traffic_limit_gb"], + squad_uuid=resolved_squad_uuid, + payment_method="telegram_stars", + total_price_kopeks=price_kopeks, + ) + + if not order: + await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True) + return + stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks)) - + await callback.bot.send_invoice( chat_id=callback.from_user.id, title=f"Подписка на {subscription_params['period_days']} дней", @@ -707,7 +723,9 @@ async def handle_simple_subscription_payment_method( f"Устройства: {subscription_params['device_limit']}\n" f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}" ), - payload=f"simple_sub_{db_user.id}_{subscription_params['period_days']}", + payload=( + f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}" + ), provider_token="", # Пустой токен для Telegram Stars currency="XTR", # Telegram Stars prices=[types.LabeledPrice(label="Подписка", amount=stars_count)] @@ -727,8 +745,6 @@ async def handle_simple_subscription_payment_method( return # Создаем заказ на подписку - purchase_service = SubscriptionPurchaseService() - order = await purchase_service.create_subscription_order( db=db, user_id=db_user.id, diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 254ebd37..3186ca6c 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -20,7 +20,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}") - allowed_prefixes = ("balance_", "admin_stars_test_") + allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes): logger.warning(f"Невалидный payload: {query.invoice_payload}") diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 7b51db85..d9f8bbcc 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1069,12 +1069,16 @@ async def confirm_extend_subscription( # Подготовим данные для сохранения в корзину cart_data = { + 'cart_mode': 'extend', + 'subscription_id': subscription.id, 'period_days': days, 'total_price': price, 'user_id': db_user.id, 'saved_cart': True, 'missing_amount': missing_kopeks, - 'return_to_cart': True + 'return_to_cart': True, + 'description': f"Продление подписки на {days} дней", + 'consume_promo_offer': bool(promo_component["discount"] > 0), } await user_cart_service.save_user_cart(db_user.id, cart_data) @@ -2624,12 +2628,19 @@ async def _extend_existing_subscription( # Подготовим данные для сохранения в корзину from app.services.user_cart_service import user_cart_service cart_data = { + 'cart_mode': 'extend', + 'subscription_id': current_subscription.id, 'period_days': period_days, 'total_price': price_kopeks, 'user_id': db_user.id, 'saved_cart': True, 'missing_amount': missing_kopeks, - 'return_to_cart': True + 'return_to_cart': True, + 'description': f"Продление подписки на {period_days} дней", + 'device_limit': device_limit, + 'traffic_limit_gb': traffic_limit_gb, + 'squad_uuid': squad_uuid, + 'consume_promo_offer': False, } await user_cart_service.save_user_cart(db_user.id, cart_data) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 2ddddbc3..b4d0666e 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -781,6 +781,8 @@ "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_SUBSCRIPTION_SUCCESS": "✅ Your {period} subscription was purchased automatically after topping up your balance.", + "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Subscription automatically extended for {period}.", + "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ New expiration date: {date}.", "AUTO_PURCHASE_SUBSCRIPTION_HINT": "Open the ‘My subscription’ section to access your link and setup instructions.", "BALANCE_TOP_UP": "💳 Top up", "BLOCK_BY_TIME": "⏳ Temporary block", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index bc08172d..aa75bdd6 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -781,6 +781,8 @@ "BALANCE_TOPUP": "💳 Пополнить баланс", "BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 Требуется пополнение баланса\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 Пополните баланс, чтобы завершить покупку.\n\nВыберите способ пополнения:", "AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Подписка на {period} автоматически оформлена после пополнения баланса.", + "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Подписка автоматически продлена на {period}.", + "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ Новая дата окончания: {date}.", "AUTO_PURCHASE_SUBSCRIPTION_HINT": "Перейдите в раздел «Моя подписка», чтобы получить ссылку и инструкции.", "BALANCE_TOP_UP": "💳 Пополнить", "BLOCK_BY_TIME": "⏳ Блокировка по времени", diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index e7f1a051..6d728a47 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass from datetime import datetime from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP from typing import Optional @@ -27,6 +28,14 @@ from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) +@dataclass(slots=True) +class _SimpleSubscriptionPayload: + """Данные для простой подписки, извлечённые из payload звёздного платежа.""" + + subscription_id: Optional[int] + period_days: Optional[int] + + class TelegramStarsMixin: """Mixin с операциями создания и обработки платежей через Telegram Stars.""" @@ -88,7 +97,6 @@ class TelegramStarsMixin: telegram_payment_charge_id: str, ) -> bool: """Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя.""" - del payload # payload пока не используется, но оставляем аргумент для совместимости. try: rubles_amount = TelegramStarsService.calculate_rubles_from_stars( stars_amount @@ -99,12 +107,28 @@ class TelegramStarsMixin: ) ) + simple_payload = self._parse_simple_subscription_payload( + payload, + user_id, + ) + + transaction_description = ( + f"Оплата подписки через Telegram Stars ({stars_amount} ⭐)" + if simple_payload + else f"Пополнение через Telegram Stars ({stars_amount} ⭐)" + ) + transaction_type = ( + TransactionType.SUBSCRIPTION_PAYMENT + if simple_payload + else TransactionType.DEPOSIT + ) + transaction = await create_transaction( db=db, user_id=user_id, - type=TransactionType.DEPOSIT, + type=transaction_type, amount_kopeks=amount_kopeks, - description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)", + description=transaction_description, payment_method=PaymentMethod.TELEGRAM_STARS, external_id=telegram_payment_charge_id, is_completed=True, @@ -118,197 +142,487 @@ class TelegramStarsMixin: ) return False - # Запоминаем старые значения, чтобы корректно построить уведомления. - old_balance = user.balance_kopeks - was_first_topup = not user.has_made_first_topup - - # Обновляем баланс в БД. - user.balance_kopeks += amount_kopeks - user.updated_at = datetime.utcnow() - - promo_group = getattr(user, "promo_group", None) - subscription = getattr(user, "subscription", None) - referrer_info = format_referrer_info(user) - topup_status = ( - "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" - ) - - await db.commit() - - description_for_referral = ( - f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)" - ) - logger.info( - "🔍 Проверка реферальной логики для описания: '%s'", - description_for_referral, - ) - - lower_description = description_for_referral.lower() - contains_allowed_keywords = any( - word in lower_description - for word in ["пополнение", "stars", "yookassa", "topup"] - ) - contains_forbidden_keywords = any( - word in lower_description for word in ["комиссия", "бонус"] - ) - allow_referral = contains_allowed_keywords and not contains_forbidden_keywords - - if allow_referral: - logger.info( - "🔞 Вызов process_referral_topup для пользователя %s", - user_id, - ) - try: - from app.services.referral_service import process_referral_topup - - await process_referral_topup( - db, user_id, amount_kopeks, getattr(self, "bot", None) - ) - except Exception as error: - logger.error( - "Ошибка обработки реферального пополнения: %s", error - ) - else: - logger.info( - "❌ Описание '%s' не подходит для реферальной логики", - description_for_referral, + if simple_payload: + return await self._finalize_simple_subscription_stars_payment( + db=db, + user=user, + transaction=transaction, + amount_kopeks=amount_kopeks, + stars_amount=stars_amount, + payload_data=simple_payload, + telegram_payment_charge_id=telegram_payment_charge_id, ) - if was_first_topup and not user.has_made_first_topup: - user.has_made_first_topup = True - await db.commit() - - await db.refresh(user) - - logger.info( - "💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)", - user.telegram_id, - old_balance, - user.balance_kopeks, - amount_kopeks, + return await self._finalize_stars_balance_topup( + db=db, + user=user, + transaction=transaction, + amount_kopeks=amount_kopeks, + stars_amount=stars_amount, + telegram_payment_charge_id=telegram_payment_charge_id, ) - if getattr(self, "bot", None): - try: - from app.services.admin_notification_service import ( - AdminNotificationService, - ) - - notification_service = AdminNotificationService(self.bot) - await notification_service.send_balance_topup_notification( - user, - transaction, - old_balance, - topup_status=topup_status, - referrer_info=referrer_info, - subscription=subscription, - promo_group=promo_group, - db=db, - ) - except Exception as error: - logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", - error, - exc_info=True - ) - - if getattr(self, "bot", None): - try: - keyboard = await self.build_topup_success_keyboard(user) - - await self.bot.send_message( - user.telegram_id, - ( - "✅ Пополнение успешно!\n\n" - f"⭐ Звезд: {stars_amount}\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - "🦊 Способ: Telegram Stars\n" - f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n" - "Баланс пополнен автоматически!" - ), - parse_mode="HTML", - reply_markup=keyboard, - ) - logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s", - user.telegram_id, - settings.format_price(amount_kopeks), - ) - except Exception as error: - logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", - error, - ) - - # Проверяем наличие сохраненной корзины для возврата к оформлению подписки - try: - from app.services.user_cart_service import user_cart_service - from aiogram import types - has_saved_cart = await user_cart_service.has_user_cart(user.id) - auto_purchase_success = False - if has_saved_cart: - try: - auto_purchase_success = await auto_purchase_saved_cart_after_topup( - db, - user, - bot=getattr(self, "bot", None), - ) - except Exception as auto_error: - logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", - user.id, - auto_error, - exc_info=True, - ) - - if auto_purchase_success: - has_saved_cart = False - - if has_saved_cart and getattr(self, "bot", None): - # Если у пользователя есть сохраненная корзина, - # отправляем ему уведомление с кнопкой вернуться к оформлению - from app.localization.texts import get_texts - - texts = get_texts(user.language) - cart_message = texts.t( - "BALANCE_TOPUP_CART_REMINDER_DETAILED", - "🛒 У вас есть неоформленный заказ.\n\n" - "Вы можете продолжить оформление с теми же параметрами." - ) - - # Создаем клавиатуру с кнопками - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout" - )], - [types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance" - )], - [types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu" - )] - ]) - - await self.bot.send_message( - chat_id=user.telegram_id, - text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}", - reply_markup=keyboard - ) - logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}") - except Exception as e: - logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) - - logger.info( - "✅ Обработан Stars платеж: пользователь %s, %s звезд → %s", - user_id, - stars_amount, - settings.format_price(amount_kopeks), - ) - return True - except Exception as error: logger.error("Ошибка обработки Stars платежа: %s", error, exc_info=True) return False + + @staticmethod + def _parse_simple_subscription_payload( + payload: str, + expected_user_id: int, + ) -> Optional[_SimpleSubscriptionPayload]: + """Пытается извлечь параметры простой подписки из payload звёздного платежа.""" + + prefix = "simple_sub_" + if not payload or not payload.startswith(prefix): + return None + + tail = payload[len(prefix) :] + parts = tail.split("_", 2) + if len(parts) < 3: + logger.warning( + "Payload Stars simple subscription имеет некорректный формат: %s", + payload, + ) + return None + + user_part, subscription_part, period_part = parts + + try: + payload_user_id = int(user_part) + except ValueError: + logger.warning( + "Не удалось разобрать user_id в payload Stars simple subscription: %s", + payload, + ) + return None + + if payload_user_id != expected_user_id: + logger.warning( + "Получен payload Stars simple subscription с чужим user_id: %s (ожидался %s)", + payload_user_id, + expected_user_id, + ) + return None + + try: + subscription_id = int(subscription_part) + except ValueError: + logger.warning( + "Не удалось разобрать subscription_id в payload Stars simple subscription: %s", + payload, + ) + return None + + period_days: Optional[int] = None + try: + period_days = int(period_part) + except ValueError: + logger.warning( + "Не удалось разобрать период в payload Stars simple subscription: %s", + payload, + ) + + return _SimpleSubscriptionPayload( + subscription_id=subscription_id, + period_days=period_days, + ) + + async def _finalize_simple_subscription_stars_payment( + self, + db: AsyncSession, + user, + transaction, + amount_kopeks: int, + stars_amount: int, + payload_data: _SimpleSubscriptionPayload, + telegram_payment_charge_id: str, + ) -> bool: + """Активация простой подписки, оплаченной через Telegram Stars.""" + + period_days = payload_data.period_days or settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS + pending_subscription = None + + if payload_data.subscription_id is not None: + try: + from sqlalchemy import select + from app.database.models import Subscription + + result = await db.execute( + select(Subscription).where( + Subscription.id == payload_data.subscription_id, + Subscription.user_id == user.id, + ) + ) + pending_subscription = result.scalar_one_or_none() + except Exception as lookup_error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка поиска pending подписки %s для пользователя %s: %s", + payload_data.subscription_id, + user.id, + lookup_error, + exc_info=True, + ) + pending_subscription = None + + if not pending_subscription: + logger.error( + "Не найдена pending подписка %s для пользователя %s", + payload_data.subscription_id, + user.id, + ) + return False + + if payload_data.period_days is None: + start_point = pending_subscription.start_date or datetime.utcnow() + end_point = pending_subscription.end_date or start_point + computed_days = max(1, (end_point - start_point).days or 0) + period_days = max(period_days, computed_days) + + try: + from app.database.crud.subscription import activate_pending_subscription + + subscription = await activate_pending_subscription( + db=db, + user_id=user.id, + period_days=period_days, + ) + except Exception as error: + logger.error( + "Ошибка активации pending подписки для пользователя %s: %s", + user.id, + error, + exc_info=True, + ) + return False + + if not subscription: + logger.error( + "Не удалось активировать pending подписку пользователя %s", + user.id, + ) + return False + + try: + from app.services.subscription_service import SubscriptionService + + subscription_service = SubscriptionService() + remnawave_user = await subscription_service.create_remnawave_user( + db, + subscription, + ) + if remnawave_user: + await db.refresh(subscription) + except Exception as sync_error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s", + user.id, + sync_error, + exc_info=True, + ) + + period_display = period_days + if not period_display and getattr(subscription, "start_date", None) and getattr( + subscription, "end_date", None + ): + period_display = max(1, (subscription.end_date - subscription.start_date).days or 0) + if not period_display: + period_display = settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS + + if getattr(self, "bot", None): + try: + from aiogram import types + from app.localization.texts import get_texts + + texts = get_texts(user.language) + traffic_limit = getattr(subscription, "traffic_limit_gb", 0) or 0 + traffic_label = ( + "Безлимит" if traffic_limit == 0 else f"{int(traffic_limit)} ГБ" + ) + + success_message = ( + "✅ Подписка успешно активирована!\n\n" + f"📅 Период: {period_display} дней\n" + f"📱 Устройства: {getattr(subscription, 'device_limit', 1)}\n" + f"📊 Трафик: {traffic_label}\n" + f"⭐ Оплата: {stars_amount} ⭐ ({settings.format_price(amount_kopeks)})\n\n" + "🔗 Для подключения перейдите в раздел 'Моя подписка'" + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="📱 Моя подписка", + callback_data="menu_subscription", + ) + ], + [ + types.InlineKeyboardButton( + text="🏠 Главное меню", + callback_data="back_to_menu", + ) + ], + ] + ) + + await self.bot.send_message( + chat_id=user.telegram_id, + text=success_message, + reply_markup=keyboard, + parse_mode="HTML", + ) + logger.info( + "✅ Пользователь %s получил уведомление об оплате подписки через Stars", + user.telegram_id, + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка отправки уведомления о подписке через Stars: %s", + error, + exc_info=True, + ) + + if getattr(self, "bot", None): + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_subscription_purchase_notification( + db, + user, + subscription, + transaction, + period_display, + was_trial_conversion=False, + ) + except Exception as admin_error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка уведомления администраторов о подписке через Stars: %s", + admin_error, + exc_info=True, + ) + + logger.info( + "✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s", + user.id, + stars_amount, + settings.format_price(amount_kopeks), + ) + return True + + async def _finalize_stars_balance_topup( + self, + db: AsyncSession, + user, + transaction, + amount_kopeks: int, + stars_amount: int, + telegram_payment_charge_id: str, + ) -> bool: + """Начисляет баланс пользователю после оплаты Stars и запускает автопокупку.""" + + # Запоминаем старые значения, чтобы корректно построить уведомления. + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + # Обновляем баланс в БД. + user.balance_kopeks += amount_kopeks + user.updated_at = datetime.utcnow() + + promo_group = getattr(user, "promo_group", None) + subscription = getattr(user, "subscription", None) + referrer_info = format_referrer_info(user) + topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" + + await db.commit() + + description_for_referral = ( + f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)" + ) + logger.info( + "🔍 Проверка реферальной логики для описания: '%s'", + description_for_referral, + ) + + lower_description = description_for_referral.lower() + contains_allowed_keywords = any( + word in lower_description for word in ["пополнение", "stars", "yookassa", "topup"] + ) + contains_forbidden_keywords = any( + word in lower_description for word in ["комиссия", "бонус"] + ) + allow_referral = contains_allowed_keywords and not contains_forbidden_keywords + + if allow_referral: + logger.info( + "🔞 Вызов process_referral_topup для пользователя %s", + user.id, + ) + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + amount_kopeks, + getattr(self, "bot", None), + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка обработки реферального пополнения: %s", + error, + ) + else: + logger.info( + "❌ Описание '%s' не подходит для реферальной логики", + description_for_referral, + ) + + if was_first_topup and not user.has_made_first_topup: + user.has_made_first_topup = True + await db.commit() + + await db.refresh(user) + + logger.info( + "💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)", + user.telegram_id, + old_balance, + user.balance_kopeks, + amount_kopeks, + ) + + if getattr(self, "bot", None): + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка отправки уведомления о пополнении Stars: %s", + error, + exc_info=True, + ) + + if getattr(self, "bot", None): + try: + keyboard = await self.build_topup_success_keyboard(user) + + charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] + + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"⭐ Звезд: {stars_amount}\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + "🦊 Способ: Telegram Stars\n" + f"🆔 Транзакция: {charge_id_short}...\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + logger.info( + "✅ Отправлено уведомление пользователю %s о пополнении на %s", + user.telegram_id, + settings.format_price(amount_kopeks), + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка отправки уведомления о пополнении Stars: %s", + error, + ) + + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки + try: + from aiogram import types + from app.localization.texts import get_texts + from app.services.user_cart_service import user_cart_service + + has_saved_cart = await user_cart_service.has_user_cart(user.id) + auto_purchase_success = False + if has_saved_cart: + try: + auto_purchase_success = await auto_purchase_saved_cart_after_topup( + db, + user, + bot=getattr(self, "bot", None), + ) + except Exception as auto_error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка автоматической покупки подписки для пользователя %s: %s", + user.id, + auto_error, + exc_info=True, + ) + + if auto_purchase_success: + has_saved_cart = False + + if has_saved_cart and getattr(self, "bot", None): + texts = get_texts(user.language) + cart_message = texts.t( + "BALANCE_TOPUP_CART_REMINDER_DETAILED", + "🛒 У вас есть неоформленный заказ.\n\n" + "Вы можете продолжить оформление с теми же параметрами.", + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ], + [ + types.InlineKeyboardButton( + text="💰 Мой баланс", + callback_data="menu_balance", + ) + ], + [ + types.InlineKeyboardButton( + text="🏠 Главное меню", + callback_data="back_to_menu", + ) + ], + ] + ) + + await self.bot.send_message( + chat_id=user.telegram_id, + text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}", + reply_markup=keyboard, + ) + logger.info( + "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + user.id, + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка при работе с сохраненной корзиной для пользователя %s: %s", + user.id, + error, + exc_info=True, + ) + + logger.info( + "✅ Обработан Stars платеж: пользователь %s, %s звезд → %s", + user.id, + stars_amount, + settings.format_price(amount_kopeks), + ) + return True diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 980f7bc4..71b53fd5 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -11,19 +11,22 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User +from app.database.crud.subscription import extend_subscription +from app.database.crud.transaction import create_transaction +from app.database.crud.user import subtract_user_balance +from app.database.models import Subscription, TransactionType, User 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_purchase_service import ( - MiniAppSubscriptionPurchaseService, PurchaseOptionsContext, PurchasePricingResult, PurchaseSelection, PurchaseValidationError, PurchaseBalanceError, - SubscriptionPurchaseService, + MiniAppSubscriptionPurchaseService, ) +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 @@ -37,6 +40,21 @@ class AutoPurchaseContext: context: PurchaseOptionsContext pricing: PurchasePricingResult selection: PurchaseSelection + service: MiniAppSubscriptionPurchaseService + + +@dataclass(slots=True) +class AutoExtendContext: + """Data required to automatically extend an existing subscription.""" + + subscription: Subscription + period_days: int + price_kopeks: int + description: str + device_limit: Optional[int] = None + traffic_limit_gb: Optional[int] = None + squad_uuid: Optional[str] = None + consume_promo_offer: bool = False async def _prepare_auto_purchase( @@ -89,7 +107,311 @@ async def _prepare_auto_purchase( ) pricing = await miniapp_service.calculate_pricing(db, context, selection) - return AutoPurchaseContext(context=context, pricing=pricing, selection=selection) + return AutoPurchaseContext( + context=context, + pricing=pricing, + selection=selection, + service=miniapp_service, + ) + + +def _safe_int(value: Optional[object], default: int = 0) -> int: + try: + return int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return default + + +async def _prepare_auto_extend_context( + user: User, + cart_data: dict, +) -> Optional[AutoExtendContext]: + subscription = getattr(user, "subscription", None) + if subscription is None: + logger.info( + "🔁 Автопокупка: у пользователя %s нет активной подписки для продления", + user.telegram_id, + ) + return None + + saved_subscription_id = cart_data.get("subscription_id") + if saved_subscription_id is not None: + saved_subscription_id = _safe_int(saved_subscription_id, subscription.id) + if saved_subscription_id != subscription.id: + logger.warning( + "🔁 Автопокупка: сохранённая подписка %s не совпадает с текущей %s у пользователя %s", + saved_subscription_id, + subscription.id, + user.telegram_id, + ) + return None + + period_days = _safe_int(cart_data.get("period_days")) + price_kopeks = _safe_int( + cart_data.get("total_price") + or cart_data.get("price") + or cart_data.get("final_price"), + ) + + if period_days <= 0: + logger.warning( + "🔁 Автопокупка: некорректное количество дней продления (%s) у пользователя %s", + period_days, + user.telegram_id, + ) + return None + + if price_kopeks <= 0: + logger.warning( + "🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s", + price_kopeks, + user.telegram_id, + ) + return None + + description = cart_data.get("description") or f"Продление подписки на {period_days} дней" + + device_limit = cart_data.get("device_limit") + if device_limit is not None: + device_limit = _safe_int(device_limit, subscription.device_limit) + + traffic_limit_gb = cart_data.get("traffic_limit_gb") + if traffic_limit_gb is not None: + traffic_limit_gb = _safe_int(traffic_limit_gb, subscription.traffic_limit_gb or 0) + + squad_uuid = cart_data.get("squad_uuid") + consume_promo_offer = bool(cart_data.get("consume_promo_offer")) + + return AutoExtendContext( + subscription=subscription, + period_days=period_days, + price_kopeks=price_kopeks, + description=description, + device_limit=device_limit, + traffic_limit_gb=traffic_limit_gb, + squad_uuid=squad_uuid, + consume_promo_offer=consume_promo_offer, + ) + + +def _apply_extension_updates(context: AutoExtendContext) -> None: + subscription = context.subscription + + if subscription.is_trial: + subscription.is_trial = False + subscription.status = "active" + if context.traffic_limit_gb is not None: + subscription.traffic_limit_gb = context.traffic_limit_gb + if context.device_limit is not None: + subscription.device_limit = max(subscription.device_limit, context.device_limit) + if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []): + subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid] + else: + if context.traffic_limit_gb not in (None, 0): + subscription.traffic_limit_gb = context.traffic_limit_gb + if ( + context.device_limit is not None + and context.device_limit > subscription.device_limit + ): + subscription.device_limit = context.device_limit + if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []): + subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid] + + +async def _auto_extend_subscription( + db: AsyncSession, + user: User, + cart_data: dict, + *, + bot: Optional[Bot] = None, +) -> bool: + try: + prepared = await _prepare_auto_extend_context(user, cart_data) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s", + user.telegram_id, + error, + exc_info=True, + ) + return False + + if prepared is None: + return False + + if user.balance_kopeks < prepared.price_kopeks: + logger.info( + "🔁 Автопокупка: у пользователя %s недостаточно средств для продления (%s < %s)", + user.telegram_id, + user.balance_kopeks, + prepared.price_kopeks, + ) + return False + + try: + deducted = await subtract_user_balance( + db, + user, + prepared.price_kopeks, + prepared.description, + consume_promo_offer=prepared.consume_promo_offer, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "❌ Автопокупка: ошибка списания средств при продлении пользователя %s: %s", + user.telegram_id, + error, + exc_info=True, + ) + return False + + if not deducted: + logger.warning( + "❌ Автопокупка: списание средств для продления подписки пользователя %s не выполнено", + user.telegram_id, + ) + return False + + subscription = prepared.subscription + old_end_date = subscription.end_date + + _apply_extension_updates(prepared) + + try: + updated_subscription = await extend_subscription( + db, + subscription, + prepared.period_days, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "❌ Автопокупка: не удалось продлить подписку пользователя %s: %s", + user.telegram_id, + error, + exc_info=True, + ) + return False + + transaction = None + try: + transaction = await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=prepared.price_kopeks, + description=prepared.description, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "⚠️ Автопокупка: не удалось зафиксировать транзакцию продления для пользователя %s: %s", + user.telegram_id, + error, + exc_info=True, + ) + + subscription_service = SubscriptionService() + try: + await subscription_service.update_remnawave_user( + db, + updated_subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="продление подписки", + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "⚠️ Автопокупка: не удалось обновить RemnaWave пользователя %s после продления: %s", + user.telegram_id, + error, + ) + + await user_cart_service.delete_user_cart(user.id) + await clear_subscription_checkout_draft(user.id) + + texts = get_texts(getattr(user, "language", "ru")) + period_label = format_period_description( + prepared.period_days, + getattr(user, "language", "ru"), + ) + new_end_date = updated_subscription.end_date + end_date_label = new_end_date.strftime("%d.%m.%Y %H:%M") + + if bot: + try: + notification_service = AdminNotificationService(bot) + await notification_service.send_subscription_extension_notification( + db, + user, + updated_subscription, + transaction, + prepared.period_days, + old_end_date, + new_end_date=new_end_date, + balance_after=user.balance_kopeks, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "⚠️ Автопокупка: не удалось уведомить администраторов о продлении пользователя %s: %s", + user.telegram_id, + error, + ) + + try: + auto_message = texts.t( + "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED", + "✅ Subscription automatically extended for {period}.", + ).format(period=period_label) + details_message = texts.t( + "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS", + "New expiration date: {date}.", + ).format(date=end_date_label) + hint_message = texts.t( + "AUTO_PURCHASE_SUBSCRIPTION_HINT", + "Open the ‘My subscription’ section to access your link.", + ) + + full_message = "\n\n".join( + part.strip() + for part in [auto_message, details_message, hint_message] + if part and part.strip() + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 My subscription"), + callback_data="menu_subscription", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Main menu"), + callback_data="back_to_menu", + ) + ], + ] + ) + + await bot.send_message( + chat_id=user.telegram_id, + text=full_message, + reply_markup=keyboard, + parse_mode="HTML", + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "⚠️ Автопокупка: не удалось уведомить пользователя %s о продлении: %s", + user.telegram_id, + error, + ) + + logger.info( + "✅ Автопокупка: подписка продлена на %s дней для пользователя %s", + prepared.period_days, + user.telegram_id, + ) + + return True async def auto_purchase_saved_cart_after_topup( @@ -114,6 +436,10 @@ async def auto_purchase_saved_cart_after_topup( "🔁 Автопокупка: обнаружена сохранённая корзина у пользователя %s", user.telegram_id ) + cart_mode = cart_data.get("cart_mode") or cart_data.get("mode") + if cart_mode == "extend": + return await _auto_extend_subscription(db, user, cart_data, bot=bot) + try: prepared = await _prepare_auto_purchase(db, user, cart_data) except PurchaseValidationError as error: @@ -155,7 +481,7 @@ async def auto_purchase_saved_cart_after_topup( ) return False - purchase_service = SubscriptionPurchaseService() + purchase_service = prepared.service try: purchase_result = await purchase_service.submit_purchase( diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index 3ef4831a..911c4d4c 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -3,6 +3,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Sequence, Tuple +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import PERIOD_PRICES, settings @@ -1060,7 +1061,24 @@ class MiniAppSubscriptionPurchaseService: await db.refresh(user) - subscription = getattr(user, "subscription", None) + subscription = context.subscription + if subscription is not None and getattr(subscription, "id", None): + try: + await db.refresh(subscription) + except Exception as refresh_error: # pragma: no cover - defensive logging + logger.warning( + "Failed to refresh existing subscription %s: %s", + getattr(subscription, "id", None), + refresh_error, + ) + else: + result = await db.execute( + select(Subscription).where(Subscription.user_id == user.id) + ) + subscription = result.scalar_one_or_none() + if subscription is not None: + context.subscription = subscription + was_trial_conversion = False now = datetime.utcnow() diff --git a/tests/services/test_payment_service_stars.py b/tests/services/test_payment_service_stars.py index 4ff8d456..d0770ea5 100644 --- a/tests/services/test_payment_service_stars.py +++ b/tests/services/test_payment_service_stars.py @@ -1,5 +1,7 @@ """Тесты для Telegram Stars-сценариев внутри PaymentService.""" +from datetime import datetime, timedelta +from decimal import Decimal from pathlib import Path from typing import Any, Dict, Optional import sys @@ -25,12 +27,17 @@ class DummyBot: def __init__(self) -> None: self.calls: list[Dict[str, Any]] = [] + self.sent_messages: list[Dict[str, Any]] = [] async def create_invoice_link(self, **kwargs: Any) -> str: """Эмулируем создание платежной ссылки и сохраняем параметры вызова.""" self.calls.append(kwargs) return "https://t.me/invoice/stars" + async def send_message(self, **kwargs: Any) -> None: + """Фиксируем отправленные сообщения пользователю.""" + self.sent_messages.append(kwargs) + def _make_service(bot: Optional[DummyBot]) -> PaymentService: """Создаёт экземпляр PaymentService без выполнения полного конструктора.""" @@ -41,6 +48,80 @@ def _make_service(bot: Optional[DummyBot]) -> PaymentService: return service +class DummySession: + """Минимальная заглушка AsyncSession для проверки сценариев Stars.""" + + def __init__(self, pending_subscription: "DummySubscription") -> None: + self.pending_subscription = pending_subscription + self.commits: int = 0 + self.refreshed: list[Any] = [] + + async def execute(self, *_args: Any, **_kwargs: Any) -> Any: + class _Result: + def __init__(self, subscription: "DummySubscription") -> None: + self._subscription = subscription + + def scalar_one_or_none(self) -> "DummySubscription": + return self._subscription + + return _Result(self.pending_subscription) + + async def commit(self) -> None: + self.commits += 1 + + async def refresh(self, obj: Any) -> None: + self.refreshed.append(obj) + + +class DummySubscription: + """Упрощённая модель подписки для тестов.""" + + def __init__( + self, + subscription_id: int, + *, + traffic_limit_gb: int = 0, + device_limit: int = 1, + period_days: int = 30, + ) -> None: + self.id = subscription_id + self.traffic_limit_gb = traffic_limit_gb + self.device_limit = device_limit + self.status = "pending" + self.start_date = datetime(2024, 1, 1) + self.end_date = self.start_date + timedelta(days=period_days) + + +class DummyUser: + """Минимальные данные пользователя для тестов Stars-покупки.""" + + def __init__(self, user_id: int = 501, telegram_id: int = 777) -> None: + self.id = user_id + self.telegram_id = telegram_id + self.language = "ru" + self.balance_kopeks = 0 + self.has_made_first_topup = False + self.promo_group = None + self.subscription = None + + +class DummyTransaction: + """Локальная транзакция, созданная в тестах.""" + + def __init__(self, external_id: str) -> None: + self.external_id = external_id + + +class DummySubscriptionService: + """Заглушка SubscriptionService, запоминающая вызовы.""" + + def __init__(self) -> None: + self.calls: list[tuple[Any, Any]] = [] + + async def create_remnawave_user(self, db: Any, subscription: Any) -> object: + self.calls.append((db, subscription)) + return object() + @pytest.mark.anyio("asyncio") async def test_create_stars_invoice_calculates_stars(monkeypatch: pytest.MonkeyPatch) -> None: """Количество звёзд должно рассчитываться по курсу с округлением вниз и нижним порогом 1.""" @@ -140,3 +221,127 @@ async def test_create_stars_invoice_requires_bot() -> None: amount_kopeks=1000, description="Пополнение", ) + + +@pytest.mark.anyio("asyncio") +async def test_process_stars_payment_simple_subscription_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Оплата простой подписки через Stars активирует pending подписку и уведомляет пользователя.""" + + bot = DummyBot() + service = _make_service(bot) + + pending_subscription = DummySubscription(subscription_id=321, device_limit=2) + db = DummySession(pending_subscription) + user = DummyUser(user_id=900, telegram_id=123456) + activated_subscription = DummySubscription(subscription_id=321, device_limit=2) + + transaction_holder: Dict[str, DummyTransaction] = {} + + async def fake_create_transaction(**kwargs: Any) -> DummyTransaction: + transaction = DummyTransaction(external_id=kwargs.get("external_id", "")) + transaction_holder["value"] = transaction + return transaction + + async def fake_get_user_by_id(_db: Any, _user_id: int) -> DummyUser: + return user + + async def fake_activate_pending_subscription( + db: Any, + user_id: int, + period_days: Optional[int] = None, + ) -> DummySubscription: + activated_subscription.start_date = pending_subscription.start_date + activated_subscription.end_date = activated_subscription.start_date + timedelta( + days=period_days or 30 + ) + return activated_subscription + + subscription_service_stub = DummySubscriptionService() + admin_calls: list[Dict[str, Any]] = [] + + class AdminNotificationStub: + def __init__(self, _bot: Any) -> None: + self.bot = _bot + + async def send_subscription_purchase_notification( + self, + db: Any, + user_obj: Any, + subscription: Any, + transaction: Any, + period_days: int, + was_trial_conversion: bool, + ) -> None: + admin_calls.append( + { + "user": user_obj, + "subscription": subscription, + "transaction": transaction, + "period": period_days, + "was_trial": was_trial_conversion, + } + ) + + monkeypatch.setattr( + "app.services.payment.stars.create_transaction", + fake_create_transaction, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.stars.get_user_by_id", + fake_get_user_by_id, + raising=False, + ) + monkeypatch.setattr( + "app.database.crud.subscription.activate_pending_subscription", + fake_activate_pending_subscription, + raising=False, + ) + monkeypatch.setattr( + "app.services.subscription_service.SubscriptionService", + lambda: subscription_service_stub, + raising=False, + ) + monkeypatch.setattr( + "app.services.admin_notification_service.AdminNotificationService", + AdminNotificationStub, + raising=False, + ) + monkeypatch.setattr( + type(settings), + "format_price", + lambda self, amount: f"{amount / 100:.0f}₽", + raising=False, + ) + monkeypatch.setattr( + settings, + "SIMPLE_SUBSCRIPTION_PERIOD_DAYS", + 30, + raising=False, + ) + monkeypatch.setattr( + "app.services.payment.stars.TelegramStarsService.calculate_rubles_from_stars", + lambda stars: Decimal("100"), + raising=False, + ) + + payload = f"simple_sub_{user.id}_{pending_subscription.id}_30" + result = await service.process_stars_payment( + db=db, + user_id=user.id, + stars_amount=5, + payload=payload, + telegram_payment_charge_id="charge12345", + ) + + assert result is True + assert user.balance_kopeks == 0, "Баланс не должен меняться при оплате подписки" + assert subscription_service_stub.calls == [(db, activated_subscription)] + assert len(admin_calls) == 1 + assert admin_calls[0]["subscription"] is activated_subscription + assert admin_calls[0]["period"] == 30 + assert bot.sent_messages, "Пользователь должен получить уведомление" + assert "Подписка успешно активирована" in bot.sent_messages[0]["text"] + assert transaction_holder["value"].external_id == "charge12345" diff --git a/tests/services/test_subscription_auto_purchase_service.py b/tests/services/test_subscription_auto_purchase_service.py index d2a7dc9f..a4d464d6 100644 --- a/tests/services/test_subscription_auto_purchase_service.py +++ b/tests/services/test_subscription_auto_purchase_service.py @@ -1,4 +1,5 @@ import pytest +from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock from app.config import settings @@ -130,7 +131,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): details=base_pricing.details, ) - class DummyPurchaseService: async def submit_purchase(self, db, prepared_context, pricing): return { "subscription": MagicMock(), @@ -143,10 +143,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): "app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService", lambda: DummyMiniAppService(), ) - monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.SubscriptionPurchaseService", - lambda: DummyPurchaseService(), - ) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", AsyncMock(return_value=cart_data), @@ -187,3 +183,118 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): clear_draft_mock.assert_awaited_once_with(user.id) bot.send_message.assert_awaited() admin_service_mock.send_subscription_purchase_notification.assert_awaited() + + +@pytest.mark.asyncio +async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch): + monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + + subscription = MagicMock() + subscription.id = 99 + subscription.is_trial = False + subscription.status = "active" + subscription.end_date = datetime.utcnow() + subscription.device_limit = 1 + subscription.traffic_limit_gb = 100 + subscription.connected_squads = ["squad-a"] + + user = MagicMock(spec=User) + user.id = 7 + user.telegram_id = 7007 + user.balance_kopeks = 200_000 + user.language = "ru" + user.subscription = subscription + + cart_data = { + "cart_mode": "extend", + "subscription_id": subscription.id, + "period_days": 30, + "total_price": 31_000, + "description": "Продление подписки на 30 дней", + "device_limit": 2, + "traffic_limit_gb": 500, + "squad_uuid": "squad-b", + "consume_promo_offer": True, + } + + subtract_mock = AsyncMock(return_value=True) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.subtract_user_balance", + subtract_mock, + ) + + async def extend_stub(db, current_subscription, days): + current_subscription.end_date = current_subscription.end_date + timedelta(days=days) + return current_subscription + + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.extend_subscription", + extend_stub, + ) + + create_transaction_mock = AsyncMock(return_value=MagicMock()) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.create_transaction", + create_transaction_mock, + ) + + service_mock = MagicMock() + service_mock.update_remnawave_user = AsyncMock() + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.SubscriptionService", + lambda: service_mock, + ) + + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + AsyncMock(return_value=cart_data), + ) + delete_cart_mock = AsyncMock() + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart", + delete_cart_mock, + ) + clear_draft_mock = AsyncMock() + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft", + clear_draft_mock, + ) + + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.get_texts", + lambda lang: DummyTexts(), + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.format_period_description", + lambda days, lang: f"{days} дней", + ) + + admin_service_mock = MagicMock() + admin_service_mock.send_subscription_extension_notification = AsyncMock() + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.AdminNotificationService", + lambda bot: admin_service_mock, + ) + + bot = AsyncMock() + db_session = AsyncMock(spec=AsyncSession) + + result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot) + + assert result is True + subtract_mock.assert_awaited_once_with( + db_session, + user, + cart_data["total_price"], + cart_data["description"], + consume_promo_offer=True, + ) + assert subscription.device_limit == 2 + assert subscription.traffic_limit_gb == 500 + assert "squad-b" in subscription.connected_squads + delete_cart_mock.assert_awaited_once_with(user.id) + clear_draft_mock.assert_awaited_once_with(user.id) + admin_service_mock.send_subscription_extension_notification.assert_awaited() + bot.send_message.assert_awaited() + service_mock.update_remnawave_user.assert_awaited() + create_transaction_mock.assert_awaited()