diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 074814a6..0c6485b5 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -254,6 +254,12 @@ class YooKassaWebhookHandler: logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}") logger.debug(f"🔍 Полные данные webhook: {webhook_data}") + # Извлекаем ID платежа из вебхука для предотвращения дублирования + yookassa_payment_id = webhook_data.get("object", {}).get("id") + if not yookassa_payment_id: + logger.warning("⚠️ Webhook YooKassa без ID платежа") + return web.Response(status=400, text="No payment ID") + event_type = webhook_data.get("event") if not event_type: logger.warning("⚠️ Webhook YooKassa без типа события") @@ -263,40 +269,17 @@ class YooKassaWebhookHandler: logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}") return web.Response(status=200, text="OK") - yookassa_payment_id = webhook_data.get("object", {}).get("id") - async for db in get_db(): try: - if not yookassa_payment_id: - logger.warning("⚠️ Webhook YooKassa без ID платежа") - success = await self.payment_service.process_yookassa_webhook(db, webhook_data) - if success: - return web.Response(status=200, text="OK") - return web.Response(status=500, text="Processing error") - # Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования) - if event_type == "payment.succeeded": - existing_transaction = None - try: - from app.database.models import PaymentMethod - from app.database.crud.transaction import get_transaction_by_external_id - - existing_transaction = await get_transaction_by_external_id( - db, - yookassa_payment_id, - PaymentMethod.YOOKASSA, - ) - except (ImportError, AttributeError): # pragma: no cover - fallback for tests - logger.debug( - "🔁 Пропускаем проверку дубликатов YooKassa из-за отсутствия метода get_transaction_by_external_id", - ) - - if existing_transaction: - logger.info( - f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук." - ) - return web.Response(status=200, text="OK") - + from app.database.models import PaymentMethod + from app.database.crud.transaction import get_transaction_by_external_id + existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA) + + if existing_transaction and event_type == "payment.succeeded": + logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.") + return web.Response(status=200, text="OK") + success = await self.payment_service.process_yookassa_webhook(db, webhook_data) if success: diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index de700e1f..711a71ef 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -61,12 +61,10 @@ from app.services.subscription_service import SubscriptionService from app.services.trial_activation_service import ( TrialPaymentChargeFailed, TrialPaymentInsufficientFunds, - clear_trial_activation_intent, charge_trial_activation_if_required, preview_trial_activation_charge, revert_trial_activation, rollback_trial_subscription_activation, - save_trial_activation_intent, ) @@ -406,7 +404,6 @@ async def show_trial_offer( texts = get_texts(db_user.language) if db_user.subscription or db_user.has_had_paid_subscription: - await clear_trial_activation_intent(db_user.id) await callback.message.edit_text( texts.TRIAL_ALREADY_USED, reply_markup=get_back_keyboard(db_user.language) @@ -511,12 +508,6 @@ async def activate_trial( amount_kopeks=error.required_amount, ), ) - await save_trial_activation_intent( - db_user.id, - required_amount=error.required_amount, - balance_amount=error.balance_amount, - missing_amount=error.missing_amount, - ) await callback.answer() return @@ -547,7 +538,6 @@ async def activate_trial( rollback_success = await rollback_trial_subscription_activation(db, subscription) await db.refresh(db_user) if not rollback_success: - await clear_trial_activation_intent(db_user.id) await callback.answer( texts.t( "TRIAL_ROLLBACK_FAILED", @@ -583,19 +573,12 @@ async def activate_trial( amount_kopeks=error.required_amount, ), ) - await save_trial_activation_intent( - db_user.id, - required_amount=error.required_amount, - balance_amount=error.balance_amount, - missing_amount=error.missing_amount, - ) await callback.answer() return except TrialPaymentChargeFailed: rollback_success = await rollback_trial_subscription_activation(db, subscription) await db.refresh(db_user) if not rollback_success: - await clear_trial_activation_intent(db_user.id) await callback.answer( texts.t( "TRIAL_ROLLBACK_FAILED", @@ -612,7 +595,6 @@ async def activate_trial( ), show_alert=True, ) - await clear_trial_activation_intent(db_user.id) return subscription_service = SubscriptionService() @@ -650,7 +632,6 @@ async def activate_trial( failure_text, reply_markup=get_back_keyboard(db_user.language), ) - await clear_trial_activation_intent(db_user.id) await callback.answer() return except Exception as error: @@ -686,7 +667,6 @@ async def activate_trial( failure_text, reply_markup=get_back_keyboard(db_user.language), ) - await clear_trial_activation_intent(db_user.id) await callback.answer() return @@ -871,7 +851,6 @@ async def activate_trial( reply_markup=get_back_keyboard(db_user.language), ) - await clear_trial_activation_intent(db_user.id) logger.info( f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}" ) @@ -908,7 +887,6 @@ async def activate_trial( failure_text, reply_markup=get_back_keyboard(db_user.language) ) - await clear_trial_activation_intent(db_user.id) await callback.answer() return diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index 1300e62a..6c4b26c6 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -16,7 +16,6 @@ from app.database.models import PaymentMethod, TransactionType from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) -from app.services.trial_activation_service import auto_activate_trial_after_topup from app.services.subscription_renewal_service import ( SubscriptionRenewalChargeError, SubscriptionRenewalPricing, @@ -314,23 +313,6 @@ class CryptoBotPaymentMixin: await db.refresh(user) - trial_activated = False - try: - trial_activated = await auto_activate_trial_after_topup( - db, - user, - bot=getattr(self, "bot", None), - ) - if trial_activated: - await db.refresh(user) - except Exception as trial_error: # pragma: no cover - defensive logging - logger.error( - "Ошибка автоматической активации триала после пополнения для пользователя %s: %s", - user.id, - trial_error, - exc_info=True, - ) - admin_notification: Optional[_AdminNotificationContext] = None user_notification: Optional[_UserNotificationPayload] = None saved_cart_notification: Optional[_SavedCartNotificationPayload] = None diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index 23b7254e..c032f990 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType -from app.services.trial_activation_service import auto_activate_trial_after_topup from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -332,23 +331,6 @@ class HeleketPaymentMixin: await db.commit() await db.refresh(user) - trial_activated = False - try: - trial_activated = await auto_activate_trial_after_topup( - db, - user, - bot=getattr(self, "bot", None), - ) - if trial_activated: - await db.refresh(user) - except Exception as trial_error: # pragma: no cover - defensive logging - logger.error( - "Ошибка автоматической активации триала после пополнения для пользователя %s: %s", - user.id, - trial_error, - exc_info=True, - ) - if getattr(self, "bot", None): topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" referrer_info = format_referrer_info(user) diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 689b6e46..7b4ffcf3 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -14,7 +14,6 @@ from app.database.models import PaymentMethod, TransactionType from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) -from app.services.trial_activation_service import auto_activate_trial_after_topup from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -289,23 +288,6 @@ class MulenPayPaymentMixin: "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" ) - trial_activated = False - try: - trial_activated = await auto_activate_trial_after_topup( - db, - user, - bot=getattr(self, "bot", None), - ) - if trial_activated: - user = await payment_module.get_user_by_id(db, user.id) - except Exception as trial_error: # pragma: no cover - defensive logging - logger.error( - "Ошибка автоматической активации триала после пополнения для пользователя %s: %s", - user.id, - trial_error, - exc_info=True, - ) - if getattr(self, "bot", None): try: from app.services.admin_notification_service import ( diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 54b18215..6dbe3dc5 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -16,7 +16,6 @@ from app.services.pal24_service import Pal24APIError from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) -from app.services.trial_activation_service import auto_activate_trial_after_topup from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -395,24 +394,6 @@ class Pal24PaymentMixin: await db.commit() await db.refresh(user) - - trial_activated = False - try: - trial_activated = await auto_activate_trial_after_topup( - db, - user, - bot=getattr(self, "bot", None), - ) - if trial_activated: - await db.refresh(user) - except Exception as trial_error: # pragma: no cover - defensive logging - logger.error( - "Ошибка автоматической активации триала после пополнения для пользователя %s: %s", - user.id, - trial_error, - exc_info=True, - ) - await db.refresh(payment) if getattr(self, "bot", None): diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index 18bd11eb..fc7fe4d8 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -16,7 +16,6 @@ from app.services.platega_service import PlategaService from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) -from app.services.trial_activation_service import auto_activate_trial_after_topup from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -383,23 +382,6 @@ class PlategaPaymentMixin: referrer_info = format_referrer_info(user) topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" - trial_activated = False - try: - trial_activated = await auto_activate_trial_after_topup( - db, - user, - bot=getattr(self, "bot", None), - ) - if trial_activated: - await db.refresh(user) - except Exception as trial_error: # pragma: no cover - defensive logging - logger.error( - "Ошибка автоматической активации триала после пополнения для пользователя %s: %s", - user.id, - trial_error, - exc_info=True, - ) - try: from app.services.referral_service import process_referral_topup diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 3e63b2a7..b7fd5942 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -23,7 +23,6 @@ from app.external.telegram_stars import TelegramStarsService from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) -from app.services.trial_activation_service import auto_activate_trial_after_topup from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -494,23 +493,6 @@ class TelegramStarsMixin: amount_kopeks, ) - trial_activated = False - try: - trial_activated = await auto_activate_trial_after_topup( - db, - user, - bot=getattr(self, "bot", None), - ) - if trial_activated: - await db.refresh(user) - except Exception as trial_error: # pragma: no cover - defensive logging - logger.error( - "Ошибка автоматической активации триала после пополнения для пользователя %s: %s", - user.id, - trial_error, - exc_info=True, - ) - if getattr(self, "bot", None): try: from app.services.admin_notification_service import AdminNotificationService diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 94bc2b28..d8bc789a 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -15,7 +15,6 @@ from app.database.models import PaymentMethod, TransactionType from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) -from app.services.trial_activation_service import auto_activate_trial_after_topup from app.services.wata_service import WataAPIError, WataService from app.utils.user_utils import format_referrer_info @@ -470,23 +469,6 @@ class WataPaymentMixin: referrer_info = format_referrer_info(user) topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" - trial_activated = False - try: - trial_activated = await auto_activate_trial_after_topup( - db, - user, - bot=getattr(self, "bot", None), - ) - if trial_activated: - await db.refresh(user) - except Exception as trial_error: # pragma: no cover - defensive logging - logger.error( - "Ошибка автоматической активации триала после пополнения для пользователя %s: %s", - user.id, - trial_error, - exc_info=True, - ) - try: from app.services.referral_service import process_referral_topup diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 343883e2..181ab94d 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -19,7 +19,6 @@ from app.database.models import PaymentMethod, TransactionType from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) -from app.services.trial_activation_service import auto_activate_trial_after_topup from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -384,18 +383,12 @@ class YooKassaPaymentMixin: payment_module = import_module("app.services.payment_service") # Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования) - existing_transaction = None - try: - existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] - db, - payment.yookassa_payment_id, - PaymentMethod.YOOKASSA, - ) - except AttributeError: # pragma: no cover - fallback for tests - logger.debug( - "🔁 Пропускаем проверку дубликатов YooKassa в модуле сервиса оплаты из-за отсутствия метода get_transaction_by_external_id", - ) - + existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] + db, + payment.yookassa_payment_id, + PaymentMethod.YOOKASSA, + ) + if existing_transaction: # Если транзакция уже существует, просто завершаем обработку logger.info( @@ -479,18 +472,12 @@ class YooKassaPaymentMixin: ) if transaction is None: - existing_transaction = None - try: - existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] - db, - payment.yookassa_payment_id, - PaymentMethod.YOOKASSA, - ) - except AttributeError: # pragma: no cover - fallback for tests - logger.debug( - "🔁 Пропускаем проверку дубликатов YooKassa в сервисе оплаты из-за отсутствия метода get_transaction_by_external_id", - ) - + existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] + db, + payment.yookassa_payment_id, + PaymentMethod.YOOKASSA, + ) + if existing_transaction: # Если транзакция уже существует, пропускаем обработку logger.info( @@ -640,23 +627,6 @@ class YooKassaPaymentMixin: await db.refresh(user) - trial_activated = False - try: - trial_activated = await auto_activate_trial_after_topup( - db, - user, - bot=getattr(self, "bot", None), - ) - if trial_activated: - await db.refresh(user) - except Exception as trial_error: # pragma: no cover - defensive logging - logger.error( - "Ошибка автоматической активации триала после пополнения для пользователя %s: %s", - user.id, - trial_error, - exc_info=True, - ) - # Отправляем уведомления админам if getattr(self, "bot", None): try: diff --git a/app/services/trial_activation_service.py b/app/services/trial_activation_service.py index e9954cc8..e335c5a2 100644 --- a/app/services/trial_activation_service.py +++ b/app/services/trial_activation_service.py @@ -1,26 +1,15 @@ from __future__ import annotations import logging -import json from dataclasses import dataclass -from datetime import datetime -from typing import Any, Dict, Optional +from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.subscription import ( - create_trial_subscription, - decrement_subscription_server_counts, -) +from app.database.crud.subscription import decrement_subscription_server_counts from app.database.crud.user import add_user_balance, 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.remnawave_service import RemnaWaveConfigurationError -from app.services.subscription_service import SubscriptionService -from app.services.user_cart_service import user_cart_service -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup logger = logging.getLogger(__name__) @@ -50,25 +39,6 @@ class TrialActivationReversionResult: subscription_rolled_back: bool = True -@dataclass(slots=True) -class TrialActivationResult: - subscription: Subscription - charged_amount: int - remnawave_user: Optional[object] - - -class TrialActivationProvisioningError(Exception): - """Raised when trial provisioning fails after initial subscription creation.""" - - def __init__(self, reason: str, message: str = "") -> None: - super().__init__(message or reason) - self.reason = reason - - -INTENT_KEY_TEMPLATE = "trial_activation_intent:{user_id}" -INTENT_TTL_SECONDS = 24 * 60 * 60 # 24 hours - - def get_trial_activation_charge_amount() -> int: """Returns the configured activation charge in kopeks if payment is enabled.""" @@ -229,507 +199,3 @@ async def revert_trial_activation( refunded=refund_success, subscription_rolled_back=rollback_success, ) - - -def _build_intent_key(user_id: int) -> str: - return INTENT_KEY_TEMPLATE.format(user_id=user_id) - - -async def save_trial_activation_intent( - user_id: int, - *, - required_amount: Optional[int] = None, - balance_amount: Optional[int] = None, - missing_amount: Optional[int] = None, - ttl: Optional[int] = None, -) -> bool: - """Persist the user's intention to activate a trial after balance top-up.""" - - client = getattr(user_cart_service, "redis_client", None) - if client is None: - logger.warning( - "Redis client is not available when saving trial activation intent for user %s", - user_id, - ) - return False - - payload: Dict[str, Any] = { - "user_id": user_id, - "required_amount": required_amount, - "balance_amount": balance_amount, - "missing_amount": missing_amount, - "timestamp": datetime.utcnow().isoformat(), - } - - payload = {key: value for key, value in payload.items() if value is not None} - - key = _build_intent_key(user_id) - try: - await client.setex( - key, - ttl or INTENT_TTL_SECONDS, - json.dumps(payload, ensure_ascii=False), - ) - logger.debug("Saved trial activation intent for user %s", user_id) - return True - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Failed to store trial activation intent for user %s: %s", - user_id, - error, - ) - return False - - -async def get_trial_activation_intent(user_id: int) -> Optional[Dict[str, Any]]: - client = getattr(user_cart_service, "redis_client", None) - if client is None: - return None - - key = _build_intent_key(user_id) - - try: - raw_value = await client.get(key) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Failed to load trial activation intent for user %s: %s", - user_id, - error, - ) - return None - - if not raw_value: - return None - - if isinstance(raw_value, bytes): - raw_value = raw_value.decode("utf-8") - - try: - data = json.loads(raw_value) - except (TypeError, ValueError) as error: - logger.warning( - "Corrupted trial activation intent for user %s: %s", user_id, error - ) - await clear_trial_activation_intent(user_id) - return None - - if not isinstance(data, dict): - await clear_trial_activation_intent(user_id) - return None - - return data - - -async def clear_trial_activation_intent(user_id: int) -> bool: - client = getattr(user_cart_service, "redis_client", None) - if client is None: - return False - - key = _build_intent_key(user_id) - - try: - await client.delete(key) - logger.debug("Cleared trial activation intent for user %s", user_id) - return True - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Failed to clear trial activation intent for user %s: %s", - user_id, - error, - ) - return False - - -def _determine_revert_failure_reason( - revert_result: TrialActivationReversionResult, - charged_amount: int, -) -> str: - if not revert_result.subscription_rolled_back: - return "rollback_failed" - if charged_amount > 0 and not revert_result.refunded: - return "refund_failed" - return "provisioning_failed" - - -def _build_default_keyboard(texts: Any) -> InlineKeyboardMarkup: - return InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), - callback_data="menu_subscription", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ] - ) - - -def _build_insufficient_balance_keyboard(texts: Any) -> InlineKeyboardMarkup: - return InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("BALANCE_TOPUP_BUTTON", "💳 Пополнить баланс"), - callback_data="balance_topup", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ] - ) - - -def _format_insufficient_funds_message( - texts: Any, error: TrialPaymentInsufficientFunds -) -> str: - required_label = settings.format_price(error.required_amount) - balance_label = settings.format_price(error.balance_amount) - missing_label = settings.format_price(error.missing_amount) - - return texts.t( - "TRIAL_PAYMENT_INSUFFICIENT_FUNDS", - "⚠️ Недостаточно средств для активации триала.\n" - "Необходимо: {required}\nНа балансе: {balance}\n" - "Не хватает: {missing}\n\nПополните баланс и попробуйте снова.", - ).format( - required=required_label, - balance=balance_label, - missing=missing_label, - ) - - -def _get_failure_message(texts: Any, reason: str) -> str: - if reason == "rollback_failed": - return texts.t( - "TRIAL_ROLLBACK_FAILED", - "Не удалось отменить активацию триала. Попробуйте позже.", - ) - if reason == "refund_failed": - return texts.t( - "TRIAL_REFUND_FAILED", - "Не удалось вернуть оплату за активацию триала. Свяжитесь с поддержкой.", - ) - return texts.t( - "TRIAL_PROVISIONING_FAILED", - "Не удалось завершить активацию триала. Попробуйте позже.", - ) - - -async def _notify_insufficient_funds( - bot: Optional[Any], - user: User, - texts: Any, - error: TrialPaymentInsufficientFunds, -) -> None: - if not bot: - return - - message = _format_insufficient_funds_message(texts, error) - keyboard = _build_insufficient_balance_keyboard(texts) - - try: - await bot.send_message( - chat_id=user.telegram_id, - text=message, - reply_markup=keyboard, - parse_mode="HTML", - ) - except Exception as send_error: # pragma: no cover - defensive logging - logger.error( - "Failed to send insufficient funds notification to user %s: %s", - getattr(user, "telegram_id", ""), - send_error, - ) - - -async def _notify_failure( - bot: Optional[Any], - user: User, - texts: Any, - reason: str, -) -> None: - if not bot: - return - - message = _get_failure_message(texts, reason) - keyboard = _build_default_keyboard(texts) - - try: - await bot.send_message( - chat_id=user.telegram_id, - text=message, - reply_markup=keyboard, - parse_mode="HTML", - ) - except Exception as send_error: # pragma: no cover - defensive logging - logger.error( - "Failed to send trial failure notification to user %s: %s", - getattr(user, "telegram_id", ""), - send_error, - ) - - -async def _notify_payment_failure( - bot: Optional[Any], - user: User, - texts: Any, -) -> None: - if not bot: - return - - message = texts.t( - "TRIAL_PAYMENT_FAILED", - "Не удалось списать средства для активации триала. Попробуйте позже.", - ) - keyboard = _build_default_keyboard(texts) - - try: - await bot.send_message( - chat_id=user.telegram_id, - text=message, - reply_markup=keyboard, - parse_mode="HTML", - ) - except Exception as send_error: # pragma: no cover - defensive logging - logger.error( - "Failed to send trial payment failure notification to user %s: %s", - getattr(user, "telegram_id", ""), - send_error, - ) - - -async def _notify_success( - bot: Optional[Any], - user: User, - texts: Any, - charged_amount: int, -) -> None: - if not bot: - return - - message_parts = [ - texts.t( - "TRIAL_AUTO_ACTIVATED_SUCCESS", - "🎉 Триальная подписка активирована автоматически после пополнения баланса!", - ), - texts.t( - "TRIAL_AUTO_ACTIVATED_HINT", - "Откройте раздел \"Моя подписка\", чтобы получить ссылку и инструкции по подключению.", - ), - ] - - if charged_amount > 0: - message_parts.append( - texts.t( - "TRIAL_PAYMENT_CHARGED_NOTE", - "💳 С вашего баланса списано {amount}.", - ).format(amount=settings.format_price(charged_amount)) - ) - - keyboard = _build_default_keyboard(texts) - - try: - await bot.send_message( - chat_id=user.telegram_id, - text="\n\n".join(message_parts), - reply_markup=keyboard, - parse_mode="HTML", - ) - except Exception as send_error: # pragma: no cover - defensive logging - logger.error( - "Failed to send trial success notification to user %s: %s", - getattr(user, "telegram_id", ""), - send_error, - ) - - -async def auto_activate_trial_after_topup( - db: AsyncSession, - user: User, - *, - bot: Optional[Any] = None, -) -> bool: - """Automatically activates a trial for users who attempted activation before top-up.""" - - user_id = getattr(user, "id", None) - if not user_id: - return False - - intent = await get_trial_activation_intent(user_id) - if not intent: - return False - - if getattr(user, "subscription", None) or getattr(user, "has_had_paid_subscription", False): - await clear_trial_activation_intent(user_id) - return False - - texts = get_texts(getattr(user, "language", "ru")) - - try: - preview_trial_activation_charge(user) - except TrialPaymentInsufficientFunds as error: - await save_trial_activation_intent( - user_id, - required_amount=error.required_amount, - balance_amount=error.balance_amount, - missing_amount=error.missing_amount, - ) - await _notify_insufficient_funds(bot, user, texts, error) - return False - - forced_devices = None - if not settings.is_devices_selection_enabled(): - forced_devices = settings.get_disabled_mode_device_limit() - - subscription: Optional[Subscription] = None - charged_amount = 0 - - try: - subscription = await create_trial_subscription( - db, - user_id, - device_limit=forced_devices, - ) - await db.refresh(user) - - charged_amount = await charge_trial_activation_if_required( - db, - user, - description="Активация триала после пополнения баланса", - ) - except TrialPaymentInsufficientFunds as error: - rollback_success = await rollback_trial_subscription_activation(db, subscription) - await db.refresh(user) - - if not rollback_success: - await clear_trial_activation_intent(user_id) - await _notify_failure(bot, user, texts, "rollback_failed") - return False - - await save_trial_activation_intent( - user_id, - required_amount=error.required_amount, - balance_amount=error.balance_amount, - missing_amount=error.missing_amount, - ) - await _notify_insufficient_funds(bot, user, texts, error) - return False - except TrialPaymentChargeFailed: - rollback_success = await rollback_trial_subscription_activation(db, subscription) - await db.refresh(user) - await clear_trial_activation_intent(user_id) - - if rollback_success: - await _notify_payment_failure(bot, user, texts) - else: - await _notify_failure(bot, user, texts, "rollback_failed") - return False - except Exception as error: - logger.error( - "Failed to create trial subscription automatically for user %s: %s", - user_id, - error, - exc_info=True, - ) - if subscription is not None: - revert_result = await revert_trial_activation( - db, - user, - subscription, - charged_amount, - refund_description="Возврат оплаты за автоматическую активацию триала", - ) - await clear_trial_activation_intent(user_id) - reason = _determine_revert_failure_reason(revert_result, charged_amount) - await _notify_failure(bot, user, texts, reason) - else: - await clear_trial_activation_intent(user_id) - await _notify_failure(bot, user, texts, "provisioning_failed") - return False - - subscription_service = SubscriptionService() - - try: - remnawave_user = await subscription_service.create_remnawave_user( - db, - subscription, - ) - except RemnaWaveConfigurationError as error: - logger.error( - "RemnaWave configuration error during auto trial activation for user %s: %s", - user_id, - error, - ) - revert_result = await revert_trial_activation( - db, - user, - subscription, - charged_amount, - refund_description="Возврат оплаты за автоматическую активацию триала", - ) - await clear_trial_activation_intent(user_id) - reason = _determine_revert_failure_reason(revert_result, charged_amount) - await _notify_failure(bot, user, texts, reason) - return False - except Exception as error: - logger.error( - "Failed to provision RemnaWave user during auto trial activation for user %s: %s", - user_id, - error, - exc_info=True, - ) - revert_result = await revert_trial_activation( - db, - user, - subscription, - charged_amount, - refund_description="Возврат оплаты за автоматическую активацию триала", - ) - await clear_trial_activation_intent(user_id) - reason = _determine_revert_failure_reason(revert_result, charged_amount) - await _notify_failure(bot, user, texts, reason) - return False - - await db.refresh(user) - await db.refresh(subscription) - - try: - user.subscription = subscription - except Exception: # pragma: no cover - relationship safety - pass - - await clear_trial_activation_intent(user_id) - - if bot: - try: - notification_service = AdminNotificationService(bot) - await notification_service.send_trial_activation_notification( - db, - user, - subscription, - charged_amount_kopeks=charged_amount, - ) - except Exception as notify_error: # pragma: no cover - defensive logging - logger.error( - "Failed to send admin notification for auto trial activation (user %s): %s", - user_id, - notify_error, - ) - - await _notify_success(bot, user, texts, charged_amount) - - logger.info( - "✅ Trial subscription activated automatically after top-up for user %s", - user_id, - ) - return True diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index fb6e99a9..bef09a91 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -17,7 +17,6 @@ from app.services.payment_service import PaymentService from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) -from app.services.trial_activation_service import auto_activate_trial_after_topup from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) @@ -151,23 +150,6 @@ class TributeService: await session.refresh(user) - trial_activated = False - try: - trial_activated = await auto_activate_trial_after_topup( - session, - user, - bot=self.bot, - ) - if trial_activated: - await session.refresh(user) - except Exception as trial_error: # pragma: no cover - defensive logging - logger.error( - "Ошибка автоматической активации триала после пополнения для пользователя %s: %s", - user.id, - trial_error, - exc_info=True, - ) - logger.info( f"✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})" ) diff --git a/tests/services/test_trial_activation_service.py b/tests/services/test_trial_activation_service.py deleted file mode 100644 index 3ac792ce..00000000 --- a/tests/services/test_trial_activation_service.py +++ /dev/null @@ -1,119 +0,0 @@ -import pytest -from types import SimpleNamespace -import pytest -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from app.services.trial_activation_service import ( - auto_activate_trial_after_topup, - clear_trial_activation_intent, - get_trial_activation_intent, - save_trial_activation_intent, -) - - -class MockRedis: - def __init__(self): - self.storage = {} - - async def setex(self, key, ttl, value): - self.storage[key] = value - return True - - async def get(self, key): - return self.storage.get(key) - - async def delete(self, key): - return 1 if self.storage.pop(key, None) is not None else 0 - - -@pytest.mark.asyncio -async def test_trial_activation_intent_storage(monkeypatch): - mock_redis = MockRedis() - monkeypatch.setattr( - "app.services.trial_activation_service.user_cart_service", # type: ignore[attr-defined] - SimpleNamespace(redis_client=mock_redis), - ) - - await save_trial_activation_intent( - 1, - required_amount=1500, - balance_amount=500, - missing_amount=1000, - ) - - intent = await get_trial_activation_intent(1) - assert intent is not None - assert intent["required_amount"] == 1500 - assert intent["missing_amount"] == 1000 - - await clear_trial_activation_intent(1) - assert await get_trial_activation_intent(1) is None - - -@pytest.mark.asyncio -async def test_auto_activate_trial_after_topup_success(monkeypatch): - mock_redis = MockRedis() - monkeypatch.setattr( - "app.services.trial_activation_service.user_cart_service", # type: ignore[attr-defined] - SimpleNamespace(redis_client=mock_redis), - ) - - user = SimpleNamespace( - id=10, - telegram_id=12345, - language="ru", - balance_kopeks=20000, - subscription=None, - has_had_paid_subscription=False, - ) - db = AsyncMock() - bot = AsyncMock() - - await save_trial_activation_intent(10, required_amount=1000, balance_amount=0, missing_amount=1000) - - subscription_obj = SimpleNamespace(id=55, user_id=user.id) - - monkeypatch.setattr( - "app.services.trial_activation_service.preview_trial_activation_charge", - lambda _user: 1000, - ) - monkeypatch.setattr( - "app.services.trial_activation_service.create_trial_subscription", - AsyncMock(return_value=subscription_obj), - ) - monkeypatch.setattr( - "app.services.trial_activation_service.charge_trial_activation_if_required", - AsyncMock(return_value=1000), - ) - - subscription_service_mock = SimpleNamespace( - create_remnawave_user=AsyncMock(return_value=object()) - ) - monkeypatch.setattr( - "app.services.trial_activation_service.SubscriptionService", - lambda: subscription_service_mock, - ) - - admin_notification_mock = SimpleNamespace( - send_trial_activation_notification=AsyncMock() - ) - monkeypatch.setattr( - "app.services.trial_activation_service.AdminNotificationService", - lambda _bot: admin_notification_mock, - ) - - texts_stub = SimpleNamespace( - t=lambda key, default, **kwargs: default, - ) - monkeypatch.setattr( - "app.services.trial_activation_service.get_texts", - lambda _lang: texts_stub, - ) - - result = await auto_activate_trial_after_topup(db, user, bot=bot) - - assert result is True - assert await get_trial_activation_intent(user.id) is None - bot.send_message.assert_awaited() - admin_notification_mock.send_trial_activation_notification.assert_awaited() diff --git a/tests/test_trial_activation_paid.py b/tests/test_trial_activation_paid.py index d9ab198e..ba104229 100644 --- a/tests/test_trial_activation_paid.py +++ b/tests/test_trial_activation_paid.py @@ -1,6 +1,6 @@ -import pytest from unittest.mock import AsyncMock, MagicMock, patch +import pytest from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message from sqlalchemy.ext.asyncio import AsyncSession @@ -21,7 +21,6 @@ def trial_callback_query(): @pytest.fixture def trial_user(): user = MagicMock(spec=User) - user.id = 42 user.subscription = None user.has_had_paid_subscription = False user.language = "ru" @@ -58,10 +57,6 @@ async def test_activate_trial_uses_trial_price_for_topup_redirect( "app.handlers.subscription.purchase.get_insufficient_balance_keyboard", return_value=mock_keyboard, ) as insufficient_keyboard, - patch( - "app.handlers.subscription.purchase.save_trial_activation_intent", - new_callable=AsyncMock, - ) as save_intent, ): await activate_trial(trial_callback_query, trial_user, trial_db) @@ -69,11 +64,5 @@ async def test_activate_trial_uses_trial_price_for_topup_redirect( trial_user.language, amount_kopeks=error.required_amount, ) - save_intent.assert_awaited_once_with( - trial_user.id, - required_amount=error.required_amount, - balance_amount=error.balance_amount, - missing_amount=error.missing_amount, - ) trial_callback_query.message.edit_text.assert_called_once() trial_callback_query.answer.assert_called_once()