diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 0c6485b5..074814a6 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -254,12 +254,6 @@ 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 без типа события") @@ -269,17 +263,40 @@ 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") + # Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования) - 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") - + 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") + 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 711a71ef..de700e1f 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -61,10 +61,12 @@ 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, ) @@ -404,6 +406,7 @@ 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) @@ -508,6 +511,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 @@ -538,6 +547,7 @@ 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", @@ -573,12 +583,19 @@ 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", @@ -595,6 +612,7 @@ async def activate_trial( ), show_alert=True, ) + await clear_trial_activation_intent(db_user.id) return subscription_service = SubscriptionService() @@ -632,6 +650,7 @@ 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: @@ -667,6 +686,7 @@ 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 @@ -851,6 +871,7 @@ 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}" ) @@ -887,6 +908,7 @@ 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 6c4b26c6..1300e62a 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -16,6 +16,7 @@ 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, @@ -313,6 +314,23 @@ 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 c032f990..23b7254e 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -13,6 +13,7 @@ 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__) @@ -331,6 +332,23 @@ 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 7b4ffcf3..689b6e46 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -14,6 +14,7 @@ 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__) @@ -288,6 +289,23 @@ 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 6dbe3dc5..54b18215 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -16,6 +16,7 @@ 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__) @@ -394,6 +395,24 @@ 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 fc7fe4d8..18bd11eb 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -16,6 +16,7 @@ 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__) @@ -382,6 +383,23 @@ 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 b7fd5942..3e63b2a7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -23,6 +23,7 @@ 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__) @@ -493,6 +494,23 @@ 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 d8bc789a..94bc2b28 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -15,6 +15,7 @@ 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 @@ -469,6 +470,23 @@ 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 181ab94d..343883e2 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -19,6 +19,7 @@ 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__) @@ -383,12 +384,18 @@ class YooKassaPaymentMixin: payment_module = import_module("app.services.payment_service") # Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования) - existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] - db, - payment.yookassa_payment_id, - PaymentMethod.YOOKASSA, - ) - + 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", + ) + if existing_transaction: # Если транзакция уже существует, просто завершаем обработку logger.info( @@ -472,12 +479,18 @@ class YooKassaPaymentMixin: ) if transaction is None: - existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] - db, - payment.yookassa_payment_id, - PaymentMethod.YOOKASSA, - ) - + 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", + ) + if existing_transaction: # Если транзакция уже существует, пропускаем обработку logger.info( @@ -627,6 +640,23 @@ 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 e335c5a2..e9954cc8 100644 --- a/app/services/trial_activation_service.py +++ b/app/services/trial_activation_service.py @@ -1,15 +1,26 @@ from __future__ import annotations import logging +import json from dataclasses import dataclass -from typing import Optional +from datetime import datetime +from typing import Any, Dict, Optional from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.subscription import decrement_subscription_server_counts +from app.database.crud.subscription import ( + create_trial_subscription, + 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__) @@ -39,6 +50,25 @@ 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.""" @@ -199,3 +229,507 @@ 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 bef09a91..fb6e99a9 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -17,6 +17,7 @@ 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__) @@ -150,6 +151,23 @@ 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 new file mode 100644 index 00000000..3ac792ce --- /dev/null +++ b/tests/services/test_trial_activation_service.py @@ -0,0 +1,119 @@ +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 ba104229..d9ab198e 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,6 +21,7 @@ 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" @@ -57,6 +58,10 @@ 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) @@ -64,5 +69,11 @@ 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()