mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
Revert "Enable automatic trial activation after balance top-up"
This commit is contained in:
45
app/external/yookassa_webhook.py
vendored
45
app/external/yookassa_webhook.py
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", "<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
|
||||
|
||||
@@ -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})"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user