diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py
index 38d92f1e..43051911 100644
--- a/app/handlers/simple_subscription.py
+++ b/app/handlers/simple_subscription.py
@@ -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,
diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py
index 254ebd37..3186ca6c 100644
--- a/app/handlers/stars_payments.py
+++ b/app/handlers/stars_payments.py
@@ -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}")
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index 7b51db85..d9f8bbcc 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -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)
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 2ddddbc3..b4d0666e 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -781,6 +781,8 @@
"BALANCE_TOPUP": "💳 Top up balance",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 Balance top-up required\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 Top up your balance 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",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index bc08172d..aa75bdd6 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -781,6 +781,8 @@
"BALANCE_TOPUP": "💳 Пополнить баланс",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 Требуется пополнение баланса\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 Пополните баланс, чтобы завершить покупку.\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": "⏳ Блокировка по времени",
diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py
index e7f1a051..6d728a47 100644
--- a/app/services/payment/stars.py
+++ b/app/services/payment/stars.py
@@ -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,
- (
- "✅ Пополнение успешно!\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 = (
+ "✅ Подписка успешно активирована!\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,
+ (
+ "✅ Пополнение успешно!\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
diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py
index 980f7bc4..71b53fd5 100644
--- a/app/services/subscription_auto_purchase_service.py
+++ b/app/services/subscription_auto_purchase_service.py
@@ -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(
diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py
index 3ef4831a..911c4d4c 100644
--- a/app/services/subscription_purchase_service.py
+++ b/app/services/subscription_purchase_service.py
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Sequence, Tuple
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import PERIOD_PRICES, settings
@@ -1060,7 +1061,24 @@ class MiniAppSubscriptionPurchaseService:
await db.refresh(user)
- subscription = getattr(user, "subscription", None)
+ subscription = context.subscription
+ if subscription is not None and getattr(subscription, "id", None):
+ try:
+ await db.refresh(subscription)
+ except Exception as refresh_error: # pragma: no cover - defensive logging
+ logger.warning(
+ "Failed to refresh existing subscription %s: %s",
+ getattr(subscription, "id", None),
+ refresh_error,
+ )
+ else:
+ result = await db.execute(
+ select(Subscription).where(Subscription.user_id == user.id)
+ )
+ subscription = result.scalar_one_or_none()
+ if subscription is not None:
+ context.subscription = subscription
+
was_trial_conversion = False
now = datetime.utcnow()
diff --git a/tests/services/test_payment_service_stars.py b/tests/services/test_payment_service_stars.py
index 4ff8d456..d0770ea5 100644
--- a/tests/services/test_payment_service_stars.py
+++ b/tests/services/test_payment_service_stars.py
@@ -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"
diff --git a/tests/services/test_subscription_auto_purchase_service.py b/tests/services/test_subscription_auto_purchase_service.py
index d2a7dc9f..a4d464d6 100644
--- a/tests/services/test_subscription_auto_purchase_service.py
+++ b/tests/services/test_subscription_auto_purchase_service.py
@@ -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()