diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 43051911..38d92f1e 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -677,7 +677,6 @@ 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, @@ -697,23 +696,8 @@ 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']} дней", @@ -723,9 +707,7 @@ 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}_{order.id}_{subscription_params['period_days']}" - ), + payload=f"simple_sub_{db_user.id}_{subscription_params['period_days']}", provider_token="", # Пустой токен для Telegram Stars currency="XTR", # Telegram Stars prices=[types.LabeledPrice(label="Подписка", amount=stars_count)] @@ -745,6 +727,8 @@ 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 3186ca6c..254ebd37 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_", "simple_sub_") + allowed_prefixes = ("balance_", "admin_stars_test_") 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 d9f8bbcc..7b51db85 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1069,16 +1069,12 @@ 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, - 'description': f"Продление подписки на {days} дней", - 'consume_promo_offer': bool(promo_component["discount"] > 0), + 'return_to_cart': True } await user_cart_service.save_user_cart(db_user.id, cart_data) @@ -2628,19 +2624,12 @@ 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, - 'description': f"Продление подписки на {period_days} дней", - 'device_limit': device_limit, - 'traffic_limit_gb': traffic_limit_gb, - 'squad_uuid': squad_uuid, - 'consume_promo_offer': False, + 'return_to_cart': True } 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 b4d0666e..2ddddbc3 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -781,8 +781,6 @@ "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 aa75bdd6..bc08172d 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -781,8 +781,6 @@ "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 6d728a47..e7f1a051 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -7,7 +7,6 @@ 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 @@ -28,14 +27,6 @@ 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.""" @@ -97,6 +88,7 @@ class TelegramStarsMixin: telegram_payment_charge_id: str, ) -> bool: """Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя.""" + del payload # payload пока не используется, но оставляем аргумент для совместимости. try: rubles_amount = TelegramStarsService.calculate_rubles_from_stars( stars_amount @@ -107,28 +99,12 @@ 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=transaction_type, + type=TransactionType.DEPOSIT, amount_kopeks=amount_kopeks, - description=transaction_description, + description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)", payment_method=PaymentMethod.TELEGRAM_STARS, external_id=telegram_payment_charge_id, is_completed=True, @@ -142,487 +118,197 @@ class TelegramStarsMixin: ) return False - 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, + # Запоминаем старые значения, чтобы корректно построить уведомления. + 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, ) - 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 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: + 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 71b53fd5..980f7bc4 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -11,22 +11,19 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -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.database.models import 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, - MiniAppSubscriptionPurchaseService, + SubscriptionPurchaseService, ) -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 @@ -40,21 +37,6 @@ 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( @@ -107,311 +89,7 @@ async def _prepare_auto_purchase( ) pricing = await miniapp_service.calculate_pricing(db, context, 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 + return AutoPurchaseContext(context=context, pricing=pricing, selection=selection) async def auto_purchase_saved_cart_after_topup( @@ -436,10 +114,6 @@ 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: @@ -481,7 +155,7 @@ async def auto_purchase_saved_cart_after_topup( ) return False - purchase_service = prepared.service + purchase_service = SubscriptionPurchaseService() try: purchase_result = await purchase_service.submit_purchase( diff --git a/tests/services/test_payment_service_stars.py b/tests/services/test_payment_service_stars.py index d0770ea5..4ff8d456 100644 --- a/tests/services/test_payment_service_stars.py +++ b/tests/services/test_payment_service_stars.py @@ -1,7 +1,5 @@ """Тесты для Telegram Stars-сценариев внутри PaymentService.""" -from datetime import datetime, timedelta -from decimal import Decimal from pathlib import Path from typing import Any, Dict, Optional import sys @@ -27,17 +25,12 @@ 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 без выполнения полного конструктора.""" @@ -48,80 +41,6 @@ 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.""" @@ -221,127 +140,3 @@ 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 a4d464d6..d2a7dc9f 100644 --- a/tests/services/test_subscription_auto_purchase_service.py +++ b/tests/services/test_subscription_auto_purchase_service.py @@ -1,5 +1,4 @@ import pytest -from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock from app.config import settings @@ -131,6 +130,7 @@ 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,6 +143,10 @@ 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), @@ -183,118 +187,3 @@ 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()