Merge pull request #1557 from Fr1ngg/9scuix-bedolaga/fix-subscription-auto-purchase-after-top-up

Handle YooKassa auto-purchase subscription refresh
This commit is contained in:
Egor
2025-10-26 20:27:34 +03:00
committed by GitHub
10 changed files with 1213 additions and 208 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "⏳ Блокировка по времени",

View File

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

View File

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

View File

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

View File

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

View File

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