diff --git a/app/config.py b/app/config.py index aed79f7f..788bc8e6 100644 --- a/app/config.py +++ b/app/config.py @@ -187,7 +187,9 @@ class Settings(BaseSettings): DISABLE_TOPUP_BUTTONS: bool = False PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10 - + + AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False + # Настройки простой покупки SIMPLE_SUBSCRIPTION_ENABLED: bool = False SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30 @@ -626,6 +628,15 @@ class Settings(BaseSettings): return normalized in {"1", "true", "yes", "on"} return bool(value) + + def is_auto_purchase_after_topup_enabled(self) -> bool: + value = getattr(self, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", False) + + if isinstance(value, str): + normalized = value.strip().lower() + return normalized in {"1", "true", "yes", "on"} + + return bool(value) def get_available_languages(self) -> List[str]: try: diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 4e9083e4..2ddddbc3 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -780,6 +780,8 @@ "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", "BALANCE_TOPUP": "💳 Top up balance", "BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 Balance top-up required\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 Top up your balance to complete the purchase.\\n\\nChoose a top-up method:", + "AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Your {period} subscription was purchased automatically after topping up your balance.", + "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", "BLOCK_FOREVER": "🚫 Block permanently", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 81d732a4..bc08172d 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -780,6 +780,8 @@ "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", "BALANCE_TOPUP": "💳 Пополнить баланс", "BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 Требуется пополнение баланса\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 Пополните баланс, чтобы завершить покупку.\n\nВыберите способ пополнения:", + "AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Подписка на {period} автоматически оформлена после пополнения баланса.", + "AUTO_PURCHASE_SUBSCRIPTION_HINT": "Перейдите в раздел «Моя подписка», чтобы получить ссылку и инструкции.", "BALANCE_TOP_UP": "💳 Пополнить", "BLOCK_BY_TIME": "⏳ Блокировка по времени", "BLOCK_FOREVER": "🚫 Заблокировать", diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index aab5adb5..bc97ff1c 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.subscription_auto_purchase_service import ( + auto_purchase_saved_cart_after_topup, +) from app.utils.currency_converter import currency_converter from app.utils.user_utils import format_referrer_info @@ -294,9 +297,29 @@ class CryptoBotPaymentMixin: 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 @@ -326,7 +349,10 @@ class CryptoBotPaymentMixin: text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}", reply_markup=keyboard ) - logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}") + logger.info( + "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + user.id, + ) except Exception as e: logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 71a9dd8b..f142440a 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.subscription_auto_purchase_service import ( + auto_purchase_saved_cart_after_topup, +) from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -325,9 +328,29 @@ class MulenPayPaymentMixin: 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 @@ -359,7 +382,10 @@ class MulenPayPaymentMixin: text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}", reply_markup=keyboard ) - logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}") + logger.info( + "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + user.id, + ) except Exception as e: logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 7cae4325..e876d6da 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -13,6 +13,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.pal24_service import Pal24APIError +from app.services.subscription_auto_purchase_service import ( + auto_purchase_saved_cart_after_topup, +) from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -434,6 +437,25 @@ class Pal24PaymentMixin: 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 @@ -475,6 +497,11 @@ class Pal24PaymentMixin: "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", user.id, ) + else: + logger.info( + "У пользователя %s нет сохраненной корзины или автопокупка выполнена", + user.id, + ) except Exception as error: logger.error( "Ошибка при работе с сохраненной корзиной для пользователя %s: %s", diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index ea710427..e7f1a051 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -19,6 +19,9 @@ from app.database.crud.transaction import create_transaction from app.database.crud.user import get_user_by_id from app.database.models import PaymentMethod, TransactionType from app.external.telegram_stars import TelegramStarsService +from app.services.subscription_auto_purchase_service import ( + auto_purchase_saved_cart_after_topup, +) from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -242,8 +245,27 @@ class TelegramStarsMixin: 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 diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 134c1ca0..b97ea43e 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -12,6 +12,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.subscription_auto_purchase_service import ( + auto_purchase_saved_cart_after_topup, +) from app.services.wata_service import WataAPIError, WataService from app.utils.user_utils import format_referrer_info @@ -524,6 +527,25 @@ class WataPaymentMixin: 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 diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index b4210899..1c675ed4 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -16,6 +16,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.subscription_auto_purchase_service import ( + auto_purchase_saved_cart_after_topup, +) from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -465,7 +468,31 @@ class YooKassaPaymentMixin: from app.services.user_cart_service import user_cart_service try: has_saved_cart = await user_cart_service.has_user_cart(user.id) - logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}") + logger.info( + "Результат проверки корзины для пользователя %s: %s", + user.id, + has_saved_cart, + ) + + 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): # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению @@ -510,7 +537,10 @@ class YooKassaPaymentMixin: f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}" ) else: - logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен") + logger.info( + "У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена", + user.id, + ) except Exception as e: logger.error( f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py new file mode 100644 index 00000000..980f7bc4 --- /dev/null +++ b/app/services/subscription_auto_purchase_service.py @@ -0,0 +1,275 @@ +"""Automatic subscription purchase from a saved cart after balance top-up.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +from aiogram import Bot +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.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, + SubscriptionPurchaseService, +) +from app.services.user_cart_service import user_cart_service +from app.utils.pricing_utils import format_period_description + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class AutoPurchaseContext: + """Aggregated data prepared for automatic checkout processing.""" + + context: PurchaseOptionsContext + pricing: PurchasePricingResult + selection: PurchaseSelection + + +async def _prepare_auto_purchase( + db: AsyncSession, + user: User, + cart_data: dict, +) -> Optional[AutoPurchaseContext]: + """Builds purchase context and pricing for a saved cart.""" + + period_days = int(cart_data.get("period_days") or 0) + if period_days <= 0: + logger.info( + "🔁 Автопокупка: у пользователя %s нет корректного периода в сохранённой корзине", + user.telegram_id, + ) + return None + + miniapp_service = MiniAppSubscriptionPurchaseService() + context = await miniapp_service.build_options(db, user) + + period_config = context.period_map.get(f"days:{period_days}") + if not period_config: + logger.warning( + "🔁 Автопокупка: период %s дней недоступен для пользователя %s", + period_days, + user.telegram_id, + ) + return None + + traffic_value = cart_data.get("traffic_gb") + if traffic_value is None: + traffic_value = ( + period_config.traffic.current_value + if period_config.traffic.current_value is not None + else period_config.traffic.default_value or 0 + ) + else: + traffic_value = int(traffic_value) + + devices = int(cart_data.get("devices") or period_config.devices.current or 1) + servers = list(cart_data.get("countries") or []) + if not servers: + servers = list(period_config.servers.default_selection) + + selection = PurchaseSelection( + period=period_config, + traffic_value=traffic_value, + servers=servers, + devices=devices, + ) + + pricing = await miniapp_service.calculate_pricing(db, context, selection) + return AutoPurchaseContext(context=context, pricing=pricing, selection=selection) + + +async def auto_purchase_saved_cart_after_topup( + db: AsyncSession, + user: User, + *, + bot: Optional[Bot] = None, +) -> bool: + """Attempts to automatically purchase a subscription from a saved cart.""" + + if not settings.is_auto_purchase_after_topup_enabled(): + return False + + if not user or not getattr(user, "id", None): + return False + + cart_data = await user_cart_service.get_user_cart(user.id) + if not cart_data: + return False + + logger.info( + "🔁 Автопокупка: обнаружена сохранённая корзина у пользователя %s", user.telegram_id + ) + + try: + prepared = await _prepare_auto_purchase(db, user, cart_data) + except PurchaseValidationError as error: + logger.error( + "❌ Автопокупка: ошибка валидации корзины пользователя %s: %s", + user.telegram_id, + error, + ) + return False + 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 + + pricing = prepared.pricing + selection = prepared.selection + + if pricing.final_total <= 0: + logger.warning( + "❌ Автопокупка: итоговая сумма для пользователя %s некорректна (%s)", + user.telegram_id, + pricing.final_total, + ) + return False + + if user.balance_kopeks < pricing.final_total: + logger.info( + "🔁 Автопокупка: у пользователя %s недостаточно средств (%s < %s)", + user.telegram_id, + user.balance_kopeks, + pricing.final_total, + ) + return False + + purchase_service = SubscriptionPurchaseService() + + try: + purchase_result = await purchase_service.submit_purchase( + db, + prepared.context, + pricing, + ) + except PurchaseBalanceError: + logger.info( + "🔁 Автопокупка: баланс пользователя %s изменился и стал недостаточным", + user.telegram_id, + ) + return False + except PurchaseValidationError as error: + logger.error( + "❌ Автопокупка: не удалось подтвердить корзину пользователя %s: %s", + user.telegram_id, + error, + ) + return False + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "❌ Автопокупка: ошибка оформления подписки для пользователя %s: %s", + user.telegram_id, + error, + exc_info=True, + ) + return False + + await user_cart_service.delete_user_cart(user.id) + await clear_subscription_checkout_draft(user.id) + + subscription = purchase_result.get("subscription") + transaction = purchase_result.get("transaction") + was_trial_conversion = purchase_result.get("was_trial_conversion", False) + texts = get_texts(getattr(user, "language", "ru")) + + if bot: + try: + notification_service = AdminNotificationService(bot) + await notification_service.send_subscription_purchase_notification( + db, + user, + subscription, + transaction, + selection.period.days, + was_trial_conversion, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "⚠️ Автопокупка: не удалось отправить уведомление админам (%s): %s", + user.telegram_id, + error, + ) + + try: + period_label = format_period_description( + selection.period.days, + getattr(user, "language", "ru"), + ) + auto_message = texts.t( + "AUTO_PURCHASE_SUBSCRIPTION_SUCCESS", + "✅ Subscription purchased automatically after balance top-up ({period}).", + ).format(period=period_label) + + hint_message = texts.t( + "AUTO_PURCHASE_SUBSCRIPTION_HINT", + "Open the ‘My subscription’ section to access your link.", + ) + + purchase_message = purchase_result.get("message", "") + full_message = "\n\n".join( + part.strip() + for part in [auto_message, purchase_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", + selection.period.days, + user.telegram_id, + ) + + return True + + +__all__ = ["auto_purchase_saved_cart_after_topup"] diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 673a8cc0..ef0921bc 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -236,6 +236,7 @@ class BotConfigurationService: "PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT", "PAYMENT_BALANCE_TEMPLATE": "PAYMENT", "PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT", + "AUTO_PURCHASE_AFTER_TOPUP_ENABLED": "PAYMENT", "SIMPLE_SUBSCRIPTION_ENABLED": "SIMPLE_SUBSCRIPTION", "SIMPLE_SUBSCRIPTION_PERIOD_DAYS": "SIMPLE_SUBSCRIPTION", "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": "SIMPLE_SUBSCRIPTION", @@ -474,6 +475,16 @@ class BotConfigurationService: "warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.", "dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED", }, + "AUTO_PURCHASE_AFTER_TOPUP_ENABLED": { + "description": ( + "При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения." + ), + "format": "Булево значение.", + "example": "true", + "warning": ( + "Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена." + ), + }, "SUPPORT_TICKET_SLA_MINUTES": { "description": "Лимит времени для ответа модераторов на тикет в минутах.", "format": "Целое число от 1 до 1440.", diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 79f7b975..7e9108bb 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -14,6 +14,9 @@ from app.database.crud.transaction import ( from app.database.crud.user import get_user_by_telegram_id from app.external.tribute import TributeService as TributeAPI from app.services.payment_service import PaymentService +from app.services.subscription_auto_purchase_service import ( + auto_purchase_saved_cart_after_topup, +) from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -267,17 +270,36 @@ class TributeService: # Проверяем наличие сохраненной корзины для возврата к оформлению подписки 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( + session, + user, + bot=self.bot, + ) + 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 self.bot: - # Если у пользователя есть сохраненная корзина, + # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from app.localization.texts import get_texts from aiogram import types - + texts = get_texts(user.language) cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format( total_amount=settings.format_price(amount_kopeks) ) - + # Создаем клавиатуру с кнопками keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton( @@ -293,13 +315,16 @@ class TributeService: callback_data="back_to_menu" )] ]) - + await self.bot.send_message( chat_id=user_id, text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}", reply_markup=keyboard ) - logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user_id}") + logger.info( + "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + user_id, + ) except Exception as e: logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}") diff --git a/tests/services/test_subscription_auto_purchase_service.py b/tests/services/test_subscription_auto_purchase_service.py new file mode 100644 index 00000000..d2a7dc9f --- /dev/null +++ b/tests/services/test_subscription_auto_purchase_service.py @@ -0,0 +1,189 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from app.config import settings +from app.database.models import User +from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup +from app.services.subscription_purchase_service import ( + PurchaseDevicesConfig, + PurchaseOptionsContext, + PurchasePeriodConfig, + PurchasePricingResult, + PurchaseSelection, + PurchaseServersConfig, + PurchaseTrafficConfig, +) +from sqlalchemy.ext.asyncio import AsyncSession + + +class DummyTexts: + def t(self, key: str, default: str): + return default + + def format_price(self, value: int) -> str: + return f"{value / 100:.0f} ₽" + + +@pytest.mark.asyncio +async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): + monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + + user = MagicMock(spec=User) + user.id = 42 + user.telegram_id = 4242 + user.balance_kopeks = 200_000 + user.language = "ru" + user.subscription = None + + cart_data = { + "period_days": 30, + "countries": ["ru"], + "traffic_gb": 0, + "devices": 1, + } + + traffic_config = PurchaseTrafficConfig( + selectable=False, + mode="fixed", + options=[], + default_value=0, + current_value=0, + ) + servers_config = PurchaseServersConfig( + options=[], + min_selectable=0, + max_selectable=0, + default_selection=["ru"], + ) + devices_config = PurchaseDevicesConfig( + minimum=1, + maximum=5, + default=1, + current=1, + price_per_device=0, + discounted_price_per_device=0, + price_label="0 ₽", + ) + + period_config = PurchasePeriodConfig( + id="days:30", + days=30, + months=1, + label="30 дней", + base_price=100_000, + base_price_label="1000 ₽", + base_price_original=100_000, + base_price_original_label=None, + discount_percent=0, + per_month_price=100_000, + per_month_price_label="1000 ₽", + traffic=traffic_config, + servers=servers_config, + devices=devices_config, + ) + + context = PurchaseOptionsContext( + user=user, + subscription=None, + currency="RUB", + balance_kopeks=user.balance_kopeks, + periods=[period_config], + default_period=period_config, + period_map={"days:30": period_config}, + server_uuid_to_id={"ru": 1}, + payload={}, + ) + + base_pricing = PurchasePricingResult( + selection=PurchaseSelection( + period=period_config, + traffic_value=0, + servers=["ru"], + devices=1, + ), + server_ids=[1], + server_prices_for_period=[100_000], + base_original_total=100_000, + discounted_total=100_000, + promo_discount_value=0, + promo_discount_percent=0, + final_total=100_000, + months=1, + details={"servers_individual_prices": [100_000]}, + ) + + class DummyMiniAppService: + async def build_options(self, db, user): + return context + + async def calculate_pricing(self, db, ctx, selection): + return PurchasePricingResult( + selection=selection, + server_ids=base_pricing.server_ids, + server_prices_for_period=base_pricing.server_prices_for_period, + base_original_total=base_pricing.base_original_total, + discounted_total=base_pricing.discounted_total, + promo_discount_value=base_pricing.promo_discount_value, + promo_discount_percent=base_pricing.promo_discount_percent, + final_total=base_pricing.final_total, + months=base_pricing.months, + details=base_pricing.details, + ) + + class DummyPurchaseService: + async def submit_purchase(self, db, prepared_context, pricing): + return { + "subscription": MagicMock(), + "transaction": MagicMock(), + "was_trial_conversion": False, + "message": "🎉 Subscription purchased", + } + + monkeypatch.setattr( + "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), + ) + 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_purchase_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 + delete_cart_mock.assert_awaited_once_with(user.id) + clear_draft_mock.assert_awaited_once_with(user.id) + bot.send_message.assert_awaited() + admin_service_mock.send_subscription_purchase_notification.assert_awaited()