Enable automatic trial activation after balance top-up

This commit is contained in:
Egor
2025-11-12 04:45:03 +03:00
parent ef309c3101
commit 8f33eb0cc6
14 changed files with 907 additions and 29 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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 (

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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", "<unknown>"),
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", "<unknown>"),
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", "<unknown>"),
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", "<unknown>"),
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

View File

@@ -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})"
)

View File

@@ -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()

View File

@@ -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()