mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-21 20:01:47 +00:00
Merge pull request #1554 from Fr1ngg/0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle auto-purchase for subscription extensions after top-up
This commit is contained in:
@@ -677,6 +677,7 @@ async def handle_simple_subscription_payment_method(
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(callback.bot)
|
||||
purchase_service = SubscriptionPurchaseService()
|
||||
|
||||
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
|
||||
db,
|
||||
@@ -696,8 +697,23 @@ async def handle_simple_subscription_payment_method(
|
||||
|
||||
if payment_method == "stars":
|
||||
# Оплата через Telegram Stars
|
||||
order = await purchase_service.create_subscription_order(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
period_days=subscription_params["period_days"],
|
||||
device_limit=subscription_params["device_limit"],
|
||||
traffic_limit_gb=subscription_params["traffic_limit_gb"],
|
||||
squad_uuid=resolved_squad_uuid,
|
||||
payment_method="telegram_stars",
|
||||
total_price_kopeks=price_kopeks,
|
||||
)
|
||||
|
||||
if not order:
|
||||
await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
|
||||
|
||||
|
||||
await callback.bot.send_invoice(
|
||||
chat_id=callback.from_user.id,
|
||||
title=f"Подписка на {subscription_params['period_days']} дней",
|
||||
@@ -707,7 +723,9 @@ async def handle_simple_subscription_payment_method(
|
||||
f"Устройства: {subscription_params['device_limit']}\n"
|
||||
f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}"
|
||||
),
|
||||
payload=f"simple_sub_{db_user.id}_{subscription_params['period_days']}",
|
||||
payload=(
|
||||
f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}"
|
||||
),
|
||||
provider_token="", # Пустой токен для Telegram Stars
|
||||
currency="XTR", # Telegram Stars
|
||||
prices=[types.LabeledPrice(label="Подписка", amount=stars_count)]
|
||||
@@ -727,8 +745,6 @@ async def handle_simple_subscription_payment_method(
|
||||
return
|
||||
|
||||
# Создаем заказ на подписку
|
||||
purchase_service = SubscriptionPurchaseService()
|
||||
|
||||
order = await purchase_service.create_subscription_order(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
|
||||
@@ -20,7 +20,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_")
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
|
||||
@@ -1069,12 +1069,16 @@ async def confirm_extend_subscription(
|
||||
|
||||
# Подготовим данные для сохранения в корзину
|
||||
cart_data = {
|
||||
'cart_mode': 'extend',
|
||||
'subscription_id': subscription.id,
|
||||
'period_days': days,
|
||||
'total_price': price,
|
||||
'user_id': db_user.id,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing_kopeks,
|
||||
'return_to_cart': True
|
||||
'return_to_cart': True,
|
||||
'description': f"Продление подписки на {days} дней",
|
||||
'consume_promo_offer': bool(promo_component["discount"] > 0),
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
@@ -2624,12 +2628,19 @@ async def _extend_existing_subscription(
|
||||
# Подготовим данные для сохранения в корзину
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
cart_data = {
|
||||
'cart_mode': 'extend',
|
||||
'subscription_id': current_subscription.id,
|
||||
'period_days': period_days,
|
||||
'total_price': price_kopeks,
|
||||
'user_id': db_user.id,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing_kopeks,
|
||||
'return_to_cart': True
|
||||
'return_to_cart': True,
|
||||
'description': f"Продление подписки на {period_days} дней",
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'squad_uuid': squad_uuid,
|
||||
'consume_promo_offer': False,
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
|
||||
@@ -781,6 +781,8 @@
|
||||
"BALANCE_TOPUP": "💳 Top up balance",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Balance top-up required</b>\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 <b>Top up your balance</b> to complete the purchase.\\n\\nChoose a top-up method:",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Your {period} subscription was purchased automatically after topping up your balance.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Subscription automatically extended for {period}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ New expiration date: {date}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Open the ‘My subscription’ section to access your link and setup instructions.",
|
||||
"BALANCE_TOP_UP": "💳 Top up",
|
||||
"BLOCK_BY_TIME": "⏳ Temporary block",
|
||||
|
||||
@@ -781,6 +781,8 @@
|
||||
"BALANCE_TOPUP": "💳 Пополнить баланс",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Требуется пополнение баланса</b>\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\n\nВыберите способ пополнения:",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Подписка автоматически продлена на {period}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ Новая дата окончания: {date}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Перейдите в раздел «Моя подписка», чтобы получить ссылку и инструкции.",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP
|
||||
from typing import Optional
|
||||
@@ -27,6 +28,14 @@ from app.utils.user_utils import format_referrer_info
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _SimpleSubscriptionPayload:
|
||||
"""Данные для простой подписки, извлечённые из payload звёздного платежа."""
|
||||
|
||||
subscription_id: Optional[int]
|
||||
period_days: Optional[int]
|
||||
|
||||
|
||||
class TelegramStarsMixin:
|
||||
"""Mixin с операциями создания и обработки платежей через Telegram Stars."""
|
||||
|
||||
@@ -88,7 +97,6 @@ class TelegramStarsMixin:
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя."""
|
||||
del payload # payload пока не используется, но оставляем аргумент для совместимости.
|
||||
try:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(
|
||||
stars_amount
|
||||
@@ -99,12 +107,28 @@ class TelegramStarsMixin:
|
||||
)
|
||||
)
|
||||
|
||||
simple_payload = self._parse_simple_subscription_payload(
|
||||
payload,
|
||||
user_id,
|
||||
)
|
||||
|
||||
transaction_description = (
|
||||
f"Оплата подписки через Telegram Stars ({stars_amount} ⭐)"
|
||||
if simple_payload
|
||||
else f"Пополнение через Telegram Stars ({stars_amount} ⭐)"
|
||||
)
|
||||
transaction_type = (
|
||||
TransactionType.SUBSCRIPTION_PAYMENT
|
||||
if simple_payload
|
||||
else TransactionType.DEPOSIT
|
||||
)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=telegram_payment_charge_id,
|
||||
is_completed=True,
|
||||
@@ -118,197 +142,487 @@ class TelegramStarsMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Запоминаем старые значения, чтобы корректно построить уведомления.
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Обновляем баланс в БД.
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
description_for_referral = (
|
||||
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
|
||||
)
|
||||
logger.info(
|
||||
"🔍 Проверка реферальной логики для описания: '%s'",
|
||||
description_for_referral,
|
||||
)
|
||||
|
||||
lower_description = description_for_referral.lower()
|
||||
contains_allowed_keywords = any(
|
||||
word in lower_description
|
||||
for word in ["пополнение", "stars", "yookassa", "topup"]
|
||||
)
|
||||
contains_forbidden_keywords = any(
|
||||
word in lower_description for word in ["комиссия", "бонус"]
|
||||
)
|
||||
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
|
||||
|
||||
if allow_referral:
|
||||
logger.info(
|
||||
"🔞 Вызов process_referral_topup для пользователя %s",
|
||||
user_id,
|
||||
)
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user_id, amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения: %s", error
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"❌ Описание '%s' не подходит для реферальной логики",
|
||||
description_for_referral,
|
||||
if simple_payload:
|
||||
return await self._finalize_simple_subscription_stars_payment(
|
||||
db=db,
|
||||
user=user,
|
||||
transaction=transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
payload_data=simple_payload,
|
||||
telegram_payment_charge_id=telegram_payment_charge_id,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)",
|
||||
user.telegram_id,
|
||||
old_balance,
|
||||
user.balance_kopeks,
|
||||
amount_kopeks,
|
||||
return await self._finalize_stars_balance_topup(
|
||||
db=db,
|
||||
user=user,
|
||||
transaction=transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
telegram_payment_charge_id=telegram_payment_charge_id,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"⭐ Звезд: {stars_amount}\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
||||
user.telegram_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами."
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
|
||||
user_id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Stars платежа: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_simple_subscription_payload(
|
||||
payload: str,
|
||||
expected_user_id: int,
|
||||
) -> Optional[_SimpleSubscriptionPayload]:
|
||||
"""Пытается извлечь параметры простой подписки из payload звёздного платежа."""
|
||||
|
||||
prefix = "simple_sub_"
|
||||
if not payload or not payload.startswith(prefix):
|
||||
return None
|
||||
|
||||
tail = payload[len(prefix) :]
|
||||
parts = tail.split("_", 2)
|
||||
if len(parts) < 3:
|
||||
logger.warning(
|
||||
"Payload Stars simple subscription имеет некорректный формат: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
user_part, subscription_part, period_part = parts
|
||||
|
||||
try:
|
||||
payload_user_id = int(user_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать user_id в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
if payload_user_id != expected_user_id:
|
||||
logger.warning(
|
||||
"Получен payload Stars simple subscription с чужим user_id: %s (ожидался %s)",
|
||||
payload_user_id,
|
||||
expected_user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
subscription_id = int(subscription_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать subscription_id в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
period_days: Optional[int] = None
|
||||
try:
|
||||
period_days = int(period_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать период в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
|
||||
return _SimpleSubscriptionPayload(
|
||||
subscription_id=subscription_id,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
async def _finalize_simple_subscription_stars_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
transaction,
|
||||
amount_kopeks: int,
|
||||
stars_amount: int,
|
||||
payload_data: _SimpleSubscriptionPayload,
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Активация простой подписки, оплаченной через Telegram Stars."""
|
||||
|
||||
period_days = payload_data.period_days or settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
|
||||
pending_subscription = None
|
||||
|
||||
if payload_data.subscription_id is not None:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.database.models import Subscription
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.id == payload_data.subscription_id,
|
||||
Subscription.user_id == user.id,
|
||||
)
|
||||
)
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
except Exception as lookup_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка поиска pending подписки %s для пользователя %s: %s",
|
||||
payload_data.subscription_id,
|
||||
user.id,
|
||||
lookup_error,
|
||||
exc_info=True,
|
||||
)
|
||||
pending_subscription = None
|
||||
|
||||
if not pending_subscription:
|
||||
logger.error(
|
||||
"Не найдена pending подписка %s для пользователя %s",
|
||||
payload_data.subscription_id,
|
||||
user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
if payload_data.period_days is None:
|
||||
start_point = pending_subscription.start_date or datetime.utcnow()
|
||||
end_point = pending_subscription.end_date or start_point
|
||||
computed_days = max(1, (end_point - start_point).days or 0)
|
||||
period_days = max(period_days, computed_days)
|
||||
|
||||
try:
|
||||
from app.database.crud.subscription import activate_pending_subscription
|
||||
|
||||
subscription = await activate_pending_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
period_days=period_days,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка активации pending подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if not subscription:
|
||||
logger.error(
|
||||
"Не удалось активировать pending подписку пользователя %s",
|
||||
user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
)
|
||||
if remnawave_user:
|
||||
await db.refresh(subscription)
|
||||
except Exception as sync_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
|
||||
user.id,
|
||||
sync_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
period_display = period_days
|
||||
if not period_display and getattr(subscription, "start_date", None) and getattr(
|
||||
subscription, "end_date", None
|
||||
):
|
||||
period_display = max(1, (subscription.end_date - subscription.start_date).days or 0)
|
||||
if not period_display:
|
||||
period_display = settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
traffic_limit = getattr(subscription, "traffic_limit_gb", 0) or 0
|
||||
traffic_label = (
|
||||
"Безлимит" if traffic_limit == 0 else f"{int(traffic_limit)} ГБ"
|
||||
)
|
||||
|
||||
success_message = (
|
||||
"✅ <b>Подписка успешно активирована!</b>\n\n"
|
||||
f"📅 Период: {period_display} дней\n"
|
||||
f"📱 Устройства: {getattr(subscription, 'device_limit', 1)}\n"
|
||||
f"📊 Трафик: {traffic_label}\n"
|
||||
f"⭐ Оплата: {stars_amount} ⭐ ({settings.format_price(amount_kopeks)})\n\n"
|
||||
"🔗 Для подключения перейдите в раздел 'Моя подписка'"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📱 Моя подписка",
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
logger.info(
|
||||
"✅ Пользователь %s получил уведомление об оплате подписки через Stars",
|
||||
user.telegram_id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о подписке через Stars: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
period_display,
|
||||
was_trial_conversion=False,
|
||||
)
|
||||
except Exception as admin_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка уведомления администраторов о подписке через Stars: %s",
|
||||
admin_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s",
|
||||
user.id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
async def _finalize_stars_balance_topup(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
transaction,
|
||||
amount_kopeks: int,
|
||||
stars_amount: int,
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Начисляет баланс пользователю после оплаты Stars и запускает автопокупку."""
|
||||
|
||||
# Запоминаем старые значения, чтобы корректно построить уведомления.
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Обновляем баланс в БД.
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
|
||||
description_for_referral = (
|
||||
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
|
||||
)
|
||||
logger.info(
|
||||
"🔍 Проверка реферальной логики для описания: '%s'",
|
||||
description_for_referral,
|
||||
)
|
||||
|
||||
lower_description = description_for_referral.lower()
|
||||
contains_allowed_keywords = any(
|
||||
word in lower_description for word in ["пополнение", "stars", "yookassa", "topup"]
|
||||
)
|
||||
contains_forbidden_keywords = any(
|
||||
word in lower_description for word in ["комиссия", "бонус"]
|
||||
)
|
||||
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
|
||||
|
||||
if allow_referral:
|
||||
logger.info(
|
||||
"🔞 Вызов process_referral_topup для пользователя %s",
|
||||
user.id,
|
||||
)
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения: %s",
|
||||
error,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"❌ Описание '%s' не подходит для реферальной логики",
|
||||
description_for_referral,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)",
|
||||
user.telegram_id,
|
||||
old_balance,
|
||||
user.balance_kopeks,
|
||||
amount_kopeks,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8]
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"⭐ Звезд: {stars_amount}\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {charge_id_short}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
||||
user.telegram_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
|
||||
user.id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -11,19 +11,22 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import 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.subscription_checkout_service import clear_subscription_checkout_draft
|
||||
from app.services.subscription_purchase_service import (
|
||||
MiniAppSubscriptionPurchaseService,
|
||||
PurchaseOptionsContext,
|
||||
PurchasePricingResult,
|
||||
PurchaseSelection,
|
||||
PurchaseValidationError,
|
||||
PurchaseBalanceError,
|
||||
SubscriptionPurchaseService,
|
||||
MiniAppSubscriptionPurchaseService,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
|
||||
@@ -37,6 +40,21 @@ class AutoPurchaseContext:
|
||||
context: PurchaseOptionsContext
|
||||
pricing: PurchasePricingResult
|
||||
selection: PurchaseSelection
|
||||
service: MiniAppSubscriptionPurchaseService
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AutoExtendContext:
|
||||
"""Data required to automatically extend an existing subscription."""
|
||||
|
||||
subscription: Subscription
|
||||
period_days: int
|
||||
price_kopeks: int
|
||||
description: str
|
||||
device_limit: Optional[int] = None
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
squad_uuid: Optional[str] = None
|
||||
consume_promo_offer: bool = False
|
||||
|
||||
|
||||
async def _prepare_auto_purchase(
|
||||
@@ -89,7 +107,311 @@ async def _prepare_auto_purchase(
|
||||
)
|
||||
|
||||
pricing = await miniapp_service.calculate_pricing(db, context, selection)
|
||||
return AutoPurchaseContext(context=context, pricing=pricing, selection=selection)
|
||||
return AutoPurchaseContext(
|
||||
context=context,
|
||||
pricing=pricing,
|
||||
selection=selection,
|
||||
service=miniapp_service,
|
||||
)
|
||||
|
||||
|
||||
def _safe_int(value: Optional[object], default: int = 0) -> int:
|
||||
try:
|
||||
return int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
async def _prepare_auto_extend_context(
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
) -> Optional[AutoExtendContext]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if subscription is None:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s нет активной подписки для продления",
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
saved_subscription_id = cart_data.get("subscription_id")
|
||||
if saved_subscription_id is not None:
|
||||
saved_subscription_id = _safe_int(saved_subscription_id, subscription.id)
|
||||
if saved_subscription_id != subscription.id:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: сохранённая подписка %s не совпадает с текущей %s у пользователя %s",
|
||||
saved_subscription_id,
|
||||
subscription.id,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
period_days = _safe_int(cart_data.get("period_days"))
|
||||
price_kopeks = _safe_int(
|
||||
cart_data.get("total_price")
|
||||
or cart_data.get("price")
|
||||
or cart_data.get("final_price"),
|
||||
)
|
||||
|
||||
if period_days <= 0:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: некорректное количество дней продления (%s) у пользователя %s",
|
||||
period_days,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if price_kopeks <= 0:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s",
|
||||
price_kopeks,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
description = cart_data.get("description") or f"Продление подписки на {period_days} дней"
|
||||
|
||||
device_limit = cart_data.get("device_limit")
|
||||
if device_limit is not None:
|
||||
device_limit = _safe_int(device_limit, subscription.device_limit)
|
||||
|
||||
traffic_limit_gb = cart_data.get("traffic_limit_gb")
|
||||
if traffic_limit_gb is not None:
|
||||
traffic_limit_gb = _safe_int(traffic_limit_gb, subscription.traffic_limit_gb or 0)
|
||||
|
||||
squad_uuid = cart_data.get("squad_uuid")
|
||||
consume_promo_offer = bool(cart_data.get("consume_promo_offer"))
|
||||
|
||||
return AutoExtendContext(
|
||||
subscription=subscription,
|
||||
period_days=period_days,
|
||||
price_kopeks=price_kopeks,
|
||||
description=description,
|
||||
device_limit=device_limit,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
squad_uuid=squad_uuid,
|
||||
consume_promo_offer=consume_promo_offer,
|
||||
)
|
||||
|
||||
|
||||
def _apply_extension_updates(context: AutoExtendContext) -> None:
|
||||
subscription = context.subscription
|
||||
|
||||
if subscription.is_trial:
|
||||
subscription.is_trial = False
|
||||
subscription.status = "active"
|
||||
if context.traffic_limit_gb is not None:
|
||||
subscription.traffic_limit_gb = context.traffic_limit_gb
|
||||
if context.device_limit is not None:
|
||||
subscription.device_limit = max(subscription.device_limit, context.device_limit)
|
||||
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
|
||||
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
|
||||
else:
|
||||
if context.traffic_limit_gb not in (None, 0):
|
||||
subscription.traffic_limit_gb = context.traffic_limit_gb
|
||||
if (
|
||||
context.device_limit is not None
|
||||
and context.device_limit > subscription.device_limit
|
||||
):
|
||||
subscription.device_limit = context.device_limit
|
||||
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
|
||||
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
|
||||
|
||||
|
||||
async def _auto_extend_subscription(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
*,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
prepared = await _prepare_auto_extend_context(user, cart_data)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if prepared is None:
|
||||
return False
|
||||
|
||||
if user.balance_kopeks < prepared.price_kopeks:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s недостаточно средств для продления (%s < %s)",
|
||||
user.telegram_id,
|
||||
user.balance_kopeks,
|
||||
prepared.price_kopeks,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
prepared.price_kopeks,
|
||||
prepared.description,
|
||||
consume_promo_offer=prepared.consume_promo_offer,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка списания средств при продлении пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if not deducted:
|
||||
logger.warning(
|
||||
"❌ Автопокупка: списание средств для продления подписки пользователя %s не выполнено",
|
||||
user.telegram_id,
|
||||
)
|
||||
return False
|
||||
|
||||
subscription = prepared.subscription
|
||||
old_end_date = subscription.end_date
|
||||
|
||||
_apply_extension_updates(prepared)
|
||||
|
||||
try:
|
||||
updated_subscription = await extend_subscription(
|
||||
db,
|
||||
subscription,
|
||||
prepared.period_days,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: не удалось продлить подписку пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
transaction = None
|
||||
try:
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=prepared.price_kopeks,
|
||||
description=prepared.description,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось зафиксировать транзакцию продления для пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
updated_subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="продление подписки",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось обновить RemnaWave пользователя %s после продления: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
await clear_subscription_checkout_draft(user.id)
|
||||
|
||||
texts = get_texts(getattr(user, "language", "ru"))
|
||||
period_label = format_period_description(
|
||||
prepared.period_days,
|
||||
getattr(user, "language", "ru"),
|
||||
)
|
||||
new_end_date = updated_subscription.end_date
|
||||
end_date_label = new_end_date.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_extension_notification(
|
||||
db,
|
||||
user,
|
||||
updated_subscription,
|
||||
transaction,
|
||||
prepared.period_days,
|
||||
old_end_date,
|
||||
new_end_date=new_end_date,
|
||||
balance_after=user.balance_kopeks,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось уведомить администраторов о продлении пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
auto_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED",
|
||||
"✅ Subscription automatically extended for {period}.",
|
||||
).format(period=period_label)
|
||||
details_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS",
|
||||
"New expiration date: {date}.",
|
||||
).format(date=end_date_label)
|
||||
hint_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
|
||||
"Open the ‘My subscription’ section to access your link.",
|
||||
)
|
||||
|
||||
full_message = "\n\n".join(
|
||||
part.strip()
|
||||
for part in [auto_message, details_message, hint_message]
|
||||
if part and part.strip()
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 My subscription"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Main menu"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=full_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось уведомить пользователя %s о продлении: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Автопокупка: подписка продлена на %s дней для пользователя %s",
|
||||
prepared.period_days,
|
||||
user.telegram_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def auto_purchase_saved_cart_after_topup(
|
||||
@@ -114,6 +436,10 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
"🔁 Автопокупка: обнаружена сохранённая корзина у пользователя %s", user.telegram_id
|
||||
)
|
||||
|
||||
cart_mode = cart_data.get("cart_mode") or cart_data.get("mode")
|
||||
if cart_mode == "extend":
|
||||
return await _auto_extend_subscription(db, user, cart_data, bot=bot)
|
||||
|
||||
try:
|
||||
prepared = await _prepare_auto_purchase(db, user, cart_data)
|
||||
except PurchaseValidationError as error:
|
||||
@@ -155,7 +481,7 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
)
|
||||
return False
|
||||
|
||||
purchase_service = SubscriptionPurchaseService()
|
||||
purchase_service = prepared.service
|
||||
|
||||
try:
|
||||
purchase_result = await purchase_service.submit_purchase(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Тесты для Telegram Stars-сценариев внутри PaymentService."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
@@ -25,12 +27,17 @@ class DummyBot:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.sent_messages: list[Dict[str, Any]] = []
|
||||
|
||||
async def create_invoice_link(self, **kwargs: Any) -> str:
|
||||
"""Эмулируем создание платежной ссылки и сохраняем параметры вызова."""
|
||||
self.calls.append(kwargs)
|
||||
return "https://t.me/invoice/stars"
|
||||
|
||||
async def send_message(self, **kwargs: Any) -> None:
|
||||
"""Фиксируем отправленные сообщения пользователю."""
|
||||
self.sent_messages.append(kwargs)
|
||||
|
||||
|
||||
def _make_service(bot: Optional[DummyBot]) -> PaymentService:
|
||||
"""Создаёт экземпляр PaymentService без выполнения полного конструктора."""
|
||||
@@ -41,6 +48,80 @@ def _make_service(bot: Optional[DummyBot]) -> PaymentService:
|
||||
return service
|
||||
|
||||
|
||||
class DummySession:
|
||||
"""Минимальная заглушка AsyncSession для проверки сценариев Stars."""
|
||||
|
||||
def __init__(self, pending_subscription: "DummySubscription") -> None:
|
||||
self.pending_subscription = pending_subscription
|
||||
self.commits: int = 0
|
||||
self.refreshed: list[Any] = []
|
||||
|
||||
async def execute(self, *_args: Any, **_kwargs: Any) -> Any:
|
||||
class _Result:
|
||||
def __init__(self, subscription: "DummySubscription") -> None:
|
||||
self._subscription = subscription
|
||||
|
||||
def scalar_one_or_none(self) -> "DummySubscription":
|
||||
return self._subscription
|
||||
|
||||
return _Result(self.pending_subscription)
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
async def refresh(self, obj: Any) -> None:
|
||||
self.refreshed.append(obj)
|
||||
|
||||
|
||||
class DummySubscription:
|
||||
"""Упрощённая модель подписки для тестов."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subscription_id: int,
|
||||
*,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
period_days: int = 30,
|
||||
) -> None:
|
||||
self.id = subscription_id
|
||||
self.traffic_limit_gb = traffic_limit_gb
|
||||
self.device_limit = device_limit
|
||||
self.status = "pending"
|
||||
self.start_date = datetime(2024, 1, 1)
|
||||
self.end_date = self.start_date + timedelta(days=period_days)
|
||||
|
||||
|
||||
class DummyUser:
|
||||
"""Минимальные данные пользователя для тестов Stars-покупки."""
|
||||
|
||||
def __init__(self, user_id: int = 501, telegram_id: int = 777) -> None:
|
||||
self.id = user_id
|
||||
self.telegram_id = telegram_id
|
||||
self.language = "ru"
|
||||
self.balance_kopeks = 0
|
||||
self.has_made_first_topup = False
|
||||
self.promo_group = None
|
||||
self.subscription = None
|
||||
|
||||
|
||||
class DummyTransaction:
|
||||
"""Локальная транзакция, созданная в тестах."""
|
||||
|
||||
def __init__(self, external_id: str) -> None:
|
||||
self.external_id = external_id
|
||||
|
||||
|
||||
class DummySubscriptionService:
|
||||
"""Заглушка SubscriptionService, запоминающая вызовы."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[Any, Any]] = []
|
||||
|
||||
async def create_remnawave_user(self, db: Any, subscription: Any) -> object:
|
||||
self.calls.append((db, subscription))
|
||||
return object()
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_stars_invoice_calculates_stars(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Количество звёзд должно рассчитываться по курсу с округлением вниз и нижним порогом 1."""
|
||||
@@ -140,3 +221,127 @@ async def test_create_stars_invoice_requires_bot() -> None:
|
||||
amount_kopeks=1000,
|
||||
description="Пополнение",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_stars_payment_simple_subscription_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Оплата простой подписки через Stars активирует pending подписку и уведомляет пользователя."""
|
||||
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
|
||||
pending_subscription = DummySubscription(subscription_id=321, device_limit=2)
|
||||
db = DummySession(pending_subscription)
|
||||
user = DummyUser(user_id=900, telegram_id=123456)
|
||||
activated_subscription = DummySubscription(subscription_id=321, device_limit=2)
|
||||
|
||||
transaction_holder: Dict[str, DummyTransaction] = {}
|
||||
|
||||
async def fake_create_transaction(**kwargs: Any) -> DummyTransaction:
|
||||
transaction = DummyTransaction(external_id=kwargs.get("external_id", ""))
|
||||
transaction_holder["value"] = transaction
|
||||
return transaction
|
||||
|
||||
async def fake_get_user_by_id(_db: Any, _user_id: int) -> DummyUser:
|
||||
return user
|
||||
|
||||
async def fake_activate_pending_subscription(
|
||||
db: Any,
|
||||
user_id: int,
|
||||
period_days: Optional[int] = None,
|
||||
) -> DummySubscription:
|
||||
activated_subscription.start_date = pending_subscription.start_date
|
||||
activated_subscription.end_date = activated_subscription.start_date + timedelta(
|
||||
days=period_days or 30
|
||||
)
|
||||
return activated_subscription
|
||||
|
||||
subscription_service_stub = DummySubscriptionService()
|
||||
admin_calls: list[Dict[str, Any]] = []
|
||||
|
||||
class AdminNotificationStub:
|
||||
def __init__(self, _bot: Any) -> None:
|
||||
self.bot = _bot
|
||||
|
||||
async def send_subscription_purchase_notification(
|
||||
self,
|
||||
db: Any,
|
||||
user_obj: Any,
|
||||
subscription: Any,
|
||||
transaction: Any,
|
||||
period_days: int,
|
||||
was_trial_conversion: bool,
|
||||
) -> None:
|
||||
admin_calls.append(
|
||||
{
|
||||
"user": user_obj,
|
||||
"subscription": subscription,
|
||||
"transaction": transaction,
|
||||
"period": period_days,
|
||||
"was_trial": was_trial_conversion,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.create_transaction",
|
||||
fake_create_transaction,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.get_user_by_id",
|
||||
fake_get_user_by_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.database.crud.subscription.activate_pending_subscription",
|
||||
fake_activate_pending_subscription,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_service.SubscriptionService",
|
||||
lambda: subscription_service_stub,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.admin_notification_service.AdminNotificationService",
|
||||
AdminNotificationStub,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
type(settings),
|
||||
"format_price",
|
||||
lambda self, amount: f"{amount / 100:.0f}₽",
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS",
|
||||
30,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.TelegramStarsService.calculate_rubles_from_stars",
|
||||
lambda stars: Decimal("100"),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = f"simple_sub_{user.id}_{pending_subscription.id}_30"
|
||||
result = await service.process_stars_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
stars_amount=5,
|
||||
payload=payload,
|
||||
telegram_payment_charge_id="charge12345",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert user.balance_kopeks == 0, "Баланс не должен меняться при оплате подписки"
|
||||
assert subscription_service_stub.calls == [(db, activated_subscription)]
|
||||
assert len(admin_calls) == 1
|
||||
assert admin_calls[0]["subscription"] is activated_subscription
|
||||
assert admin_calls[0]["period"] == 30
|
||||
assert bot.sent_messages, "Пользователь должен получить уведомление"
|
||||
assert "Подписка успешно активирована" in bot.sent_messages[0]["text"]
|
||||
assert transaction_holder["value"].external_id == "charge12345"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.config import settings
|
||||
@@ -130,7 +131,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
|
||||
details=base_pricing.details,
|
||||
)
|
||||
|
||||
class DummyPurchaseService:
|
||||
async def submit_purchase(self, db, prepared_context, pricing):
|
||||
return {
|
||||
"subscription": MagicMock(),
|
||||
@@ -143,10 +143,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
|
||||
"app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService",
|
||||
lambda: DummyMiniAppService(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.SubscriptionPurchaseService",
|
||||
lambda: DummyPurchaseService(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
@@ -187,3 +183,118 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
|
||||
clear_draft_mock.assert_awaited_once_with(user.id)
|
||||
bot.send_message.assert_awaited()
|
||||
admin_service_mock.send_subscription_purchase_notification.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
subscription = MagicMock()
|
||||
subscription.id = 99
|
||||
subscription.is_trial = False
|
||||
subscription.status = "active"
|
||||
subscription.end_date = datetime.utcnow()
|
||||
subscription.device_limit = 1
|
||||
subscription.traffic_limit_gb = 100
|
||||
subscription.connected_squads = ["squad-a"]
|
||||
|
||||
user = MagicMock(spec=User)
|
||||
user.id = 7
|
||||
user.telegram_id = 7007
|
||||
user.balance_kopeks = 200_000
|
||||
user.language = "ru"
|
||||
user.subscription = subscription
|
||||
|
||||
cart_data = {
|
||||
"cart_mode": "extend",
|
||||
"subscription_id": subscription.id,
|
||||
"period_days": 30,
|
||||
"total_price": 31_000,
|
||||
"description": "Продление подписки на 30 дней",
|
||||
"device_limit": 2,
|
||||
"traffic_limit_gb": 500,
|
||||
"squad_uuid": "squad-b",
|
||||
"consume_promo_offer": True,
|
||||
}
|
||||
|
||||
subtract_mock = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.subtract_user_balance",
|
||||
subtract_mock,
|
||||
)
|
||||
|
||||
async def extend_stub(db, current_subscription, days):
|
||||
current_subscription.end_date = current_subscription.end_date + timedelta(days=days)
|
||||
return current_subscription
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.extend_subscription",
|
||||
extend_stub,
|
||||
)
|
||||
|
||||
create_transaction_mock = AsyncMock(return_value=MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.create_transaction",
|
||||
create_transaction_mock,
|
||||
)
|
||||
|
||||
service_mock = MagicMock()
|
||||
service_mock.update_remnawave_user = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.SubscriptionService",
|
||||
lambda: service_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
)
|
||||
delete_cart_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
|
||||
delete_cart_mock,
|
||||
)
|
||||
clear_draft_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
|
||||
clear_draft_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.get_texts",
|
||||
lambda lang: DummyTexts(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_period_description",
|
||||
lambda days, lang: f"{days} дней",
|
||||
)
|
||||
|
||||
admin_service_mock = MagicMock()
|
||||
admin_service_mock.send_subscription_extension_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.AdminNotificationService",
|
||||
lambda bot: admin_service_mock,
|
||||
)
|
||||
|
||||
bot = AsyncMock()
|
||||
db_session = AsyncMock(spec=AsyncSession)
|
||||
|
||||
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
|
||||
|
||||
assert result is True
|
||||
subtract_mock.assert_awaited_once_with(
|
||||
db_session,
|
||||
user,
|
||||
cart_data["total_price"],
|
||||
cart_data["description"],
|
||||
consume_promo_offer=True,
|
||||
)
|
||||
assert subscription.device_limit == 2
|
||||
assert subscription.traffic_limit_gb == 500
|
||||
assert "squad-b" in subscription.connected_squads
|
||||
delete_cart_mock.assert_awaited_once_with(user.id)
|
||||
clear_draft_mock.assert_awaited_once_with(user.id)
|
||||
admin_service_mock.send_subscription_extension_notification.assert_awaited()
|
||||
bot.send_message.assert_awaited()
|
||||
service_mock.update_remnawave_user.assert_awaited()
|
||||
create_transaction_mock.assert_awaited()
|
||||
|
||||
Reference in New Issue
Block a user