mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
Новый функционал: - Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot - Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API - Фильтрация по нодам (TRAFFIC_MONIT
1166 lines
47 KiB
Python
1166 lines
47 KiB
Python
"""Automatic subscription purchase from a saved cart after balance top-up."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Optional
|
||
|
||
from aiogram import Bot
|
||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.crud.subscription import extend_subscription
|
||
from app.database.crud.transaction import create_transaction
|
||
from app.database.crud.user import get_user_by_id, 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 (
|
||
PurchaseOptionsContext,
|
||
PurchasePricingResult,
|
||
PurchaseSelection,
|
||
PurchaseValidationError,
|
||
PurchaseBalanceError,
|
||
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
|
||
from app.utils.timezone import format_local_datetime
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class AutoPurchaseContext:
|
||
"""Aggregated data prepared for automatic checkout processing."""
|
||
|
||
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(
|
||
db: AsyncSession,
|
||
user: User,
|
||
cart_data: dict,
|
||
) -> Optional[AutoPurchaseContext]:
|
||
"""Builds purchase context and pricing for a saved cart."""
|
||
|
||
period_days = int(cart_data.get("period_days") or 0)
|
||
if period_days <= 0:
|
||
logger.info(
|
||
"🔁 Автопокупка: у пользователя %s нет корректного периода в сохранённой корзине",
|
||
user.telegram_id,
|
||
)
|
||
return None
|
||
|
||
# Перезагружаем user с нужными связями (user_promo_groups),
|
||
# т.к. после db.refresh() в payment-сервисах связи сбрасываются
|
||
fresh_user = await get_user_by_id(db, user.id)
|
||
if not fresh_user:
|
||
logger.warning(
|
||
"🔁 Автопокупка: не удалось перезагрузить пользователя %s",
|
||
user.telegram_id,
|
||
)
|
||
return None
|
||
user = fresh_user
|
||
|
||
miniapp_service = MiniAppSubscriptionPurchaseService()
|
||
context = await miniapp_service.build_options(db, user)
|
||
|
||
period_config = context.period_map.get(f"days:{period_days}")
|
||
if not period_config:
|
||
logger.warning(
|
||
"🔁 Автопокупка: период %s дней недоступен для пользователя %s",
|
||
period_days,
|
||
user.telegram_id,
|
||
)
|
||
return None
|
||
|
||
traffic_value = cart_data.get("traffic_gb")
|
||
if traffic_value is None:
|
||
traffic_value = (
|
||
period_config.traffic.current_value
|
||
if period_config.traffic.current_value is not None
|
||
else period_config.traffic.default_value or 0
|
||
)
|
||
else:
|
||
traffic_value = int(traffic_value)
|
||
|
||
devices = int(cart_data.get("devices") or period_config.devices.current or 1)
|
||
servers = list(cart_data.get("countries") or [])
|
||
if not servers:
|
||
servers = list(period_config.servers.default_selection)
|
||
|
||
selection = PurchaseSelection(
|
||
period=period_config,
|
||
traffic_value=traffic_value,
|
||
servers=servers,
|
||
devices=devices,
|
||
)
|
||
|
||
pricing = await miniapp_service.calculate_pricing(db, context, 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
|
||
|
||
|
||
def _apply_promo_discount_for_tariff(price: int, discount_percent: int) -> int:
|
||
"""Применяет скидку промогруппы к цене тарифа."""
|
||
if discount_percent <= 0:
|
||
return price
|
||
discount = int(price * discount_percent / 100)
|
||
return max(0, price - discount)
|
||
|
||
|
||
async def _get_tariff_price_for_period(
|
||
db: AsyncSession,
|
||
user: User,
|
||
tariff_id: int,
|
||
period_days: int,
|
||
) -> Optional[int]:
|
||
"""Получает актуальную цену тарифа для заданного периода с учётом скидки пользователя."""
|
||
from app.database.crud.tariff import get_tariff_by_id
|
||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
logger.warning(
|
||
"🔁 Автопокупка: тариф %s недоступен для пользователя %s",
|
||
tariff_id,
|
||
user.telegram_id,
|
||
)
|
||
return None
|
||
|
||
prices = tariff.period_prices or {}
|
||
base_price = prices.get(str(period_days))
|
||
if base_price is None:
|
||
logger.warning(
|
||
"🔁 Автопокупка: период %s дней недоступен для тарифа %s",
|
||
period_days,
|
||
tariff_id,
|
||
)
|
||
return None
|
||
|
||
# Получаем скидку пользователя
|
||
discount_percent = 0
|
||
promo_group = getattr(user, 'promo_group', None)
|
||
if promo_group:
|
||
discount_percent = getattr(promo_group, 'server_discount_percent', 0)
|
||
|
||
personal_discount = get_user_active_promo_discount_percent(user)
|
||
if personal_discount > discount_percent:
|
||
discount_percent = personal_discount
|
||
|
||
final_price = _apply_promo_discount_for_tariff(base_price, discount_percent)
|
||
return final_price
|
||
|
||
|
||
async def _prepare_auto_extend_context(
|
||
db: AsyncSession,
|
||
user: User,
|
||
cart_data: dict,
|
||
) -> Optional[AutoExtendContext]:
|
||
from app.database.crud.subscription import get_subscription_by_user_id
|
||
|
||
subscription = await get_subscription_by_user_id(db, user.id)
|
||
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"))
|
||
|
||
if period_days <= 0:
|
||
logger.warning(
|
||
"🔁 Автопокупка: некорректное количество дней продления (%s) у пользователя %s",
|
||
period_days,
|
||
user.telegram_id,
|
||
)
|
||
return None
|
||
|
||
# Если в корзине есть tariff_id - пересчитываем цену по актуальному тарифу
|
||
tariff_id = cart_data.get("tariff_id")
|
||
if tariff_id:
|
||
tariff_id = _safe_int(tariff_id)
|
||
price_kopeks = await _get_tariff_price_for_period(db, user, tariff_id, period_days)
|
||
if price_kopeks is None:
|
||
# Тариф недоступен или период отсутствует - используем сохранённую цену как fallback
|
||
price_kopeks = _safe_int(
|
||
cart_data.get("total_price")
|
||
or cart_data.get("price")
|
||
or cart_data.get("final_price"),
|
||
)
|
||
logger.warning(
|
||
"🔁 Автопокупка: не удалось пересчитать цену тарифа %s, используем сохранённую: %s",
|
||
tariff_id,
|
||
price_kopeks,
|
||
)
|
||
else:
|
||
price_kopeks = _safe_int(
|
||
cart_data.get("total_price")
|
||
or cart_data.get("price")
|
||
or cart_data.get("final_price"),
|
||
)
|
||
|
||
if price_kopeks <= 0:
|
||
logger.warning(
|
||
"🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s",
|
||
price_kopeks,
|
||
user.telegram_id,
|
||
)
|
||
return None
|
||
|
||
# Формируем описание с учётом тарифа
|
||
if tariff_id:
|
||
from app.database.crud.tariff import get_tariff_by_id
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
tariff_name = tariff.name if tariff else "тариф"
|
||
description = cart_data.get("description") or f"Продление тарифа {tariff_name} на {period_days} дней"
|
||
else:
|
||
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:
|
||
"""
|
||
Применяет обновления лимитов подписки (трафик, устройства, серверы).
|
||
НЕ изменяет is_trial - это делается позже после успешного коммита продления.
|
||
"""
|
||
subscription = context.subscription
|
||
|
||
# Обновляем лимиты для триальной подписки
|
||
if subscription.is_trial:
|
||
# НЕ удаляем триал здесь! Это будет сделано после успешного extend_subscription()
|
||
# subscription.is_trial = False # УДАЛЕНО: преждевременное удаление триала
|
||
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(db, 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
|
||
was_trial = subscription.is_trial # Запоминаем, была ли подписка триальной
|
||
|
||
_apply_extension_updates(prepared)
|
||
|
||
try:
|
||
updated_subscription = await extend_subscription(
|
||
db,
|
||
subscription,
|
||
prepared.period_days,
|
||
)
|
||
|
||
# НОВОЕ: Конвертируем триал в платную подписку ТОЛЬКО после успешного продления
|
||
if was_trial and subscription.is_trial:
|
||
subscription.is_trial = False
|
||
subscription.status = "active"
|
||
user.has_had_paid_subscription = True
|
||
await db.commit()
|
||
logger.info(
|
||
"✅ Триал конвертирован в платную подписку %s для пользователя %s",
|
||
subscription.id,
|
||
user.telegram_id,
|
||
)
|
||
|
||
except Exception as error: # pragma: no cover - defensive logging
|
||
logger.error(
|
||
"❌ Автопокупка: не удалось продлить подписку пользователя %s: %s",
|
||
user.telegram_id,
|
||
error,
|
||
exc_info=True,
|
||
)
|
||
# НОВОЕ: Откатываем изменения при ошибке
|
||
await db.rollback()
|
||
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 = format_local_datetime(new_end_date, "%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(
|
||
db: AsyncSession,
|
||
user: User,
|
||
*,
|
||
bot: Optional[Bot] = None,
|
||
) -> bool:
|
||
"""Attempts to automatically purchase a subscription from a saved cart."""
|
||
|
||
if not settings.is_auto_purchase_after_topup_enabled():
|
||
return False
|
||
|
||
if not user or not getattr(user, "id", None):
|
||
return False
|
||
|
||
cart_data = await user_cart_service.get_user_cart(user.id)
|
||
if not cart_data:
|
||
return False
|
||
|
||
logger.info(
|
||
"🔁 Автопокупка: обнаружена сохранённая корзина у пользователя %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:
|
||
logger.error(
|
||
"❌ Автопокупка: ошибка валидации корзины пользователя %s: %s",
|
||
user.telegram_id,
|
||
error,
|
||
)
|
||
return False
|
||
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
|
||
|
||
pricing = prepared.pricing
|
||
selection = prepared.selection
|
||
|
||
if pricing.final_total <= 0:
|
||
logger.warning(
|
||
"❌ Автопокупка: итоговая сумма для пользователя %s некорректна (%s)",
|
||
user.telegram_id,
|
||
pricing.final_total,
|
||
)
|
||
return False
|
||
|
||
if user.balance_kopeks < pricing.final_total:
|
||
logger.info(
|
||
"🔁 Автопокупка: у пользователя %s недостаточно средств (%s < %s)",
|
||
user.telegram_id,
|
||
user.balance_kopeks,
|
||
pricing.final_total,
|
||
)
|
||
return False
|
||
|
||
purchase_service = prepared.service
|
||
|
||
try:
|
||
purchase_result = await purchase_service.submit_purchase(
|
||
db,
|
||
prepared.context,
|
||
pricing,
|
||
)
|
||
except PurchaseBalanceError:
|
||
logger.info(
|
||
"🔁 Автопокупка: баланс пользователя %s изменился и стал недостаточным",
|
||
user.telegram_id,
|
||
)
|
||
return False
|
||
except PurchaseValidationError as error:
|
||
logger.error(
|
||
"❌ Автопокупка: не удалось подтвердить корзину пользователя %s: %s",
|
||
user.telegram_id,
|
||
error,
|
||
)
|
||
return False
|
||
except Exception as error: # pragma: no cover - defensive logging
|
||
logger.error(
|
||
"❌ Автопокупка: ошибка оформления подписки для пользователя %s: %s",
|
||
user.telegram_id,
|
||
error,
|
||
exc_info=True,
|
||
)
|
||
return False
|
||
|
||
await user_cart_service.delete_user_cart(user.id)
|
||
await clear_subscription_checkout_draft(user.id)
|
||
|
||
subscription = purchase_result.get("subscription")
|
||
transaction = purchase_result.get("transaction")
|
||
was_trial_conversion = purchase_result.get("was_trial_conversion", False)
|
||
texts = get_texts(getattr(user, "language", "ru"))
|
||
|
||
if bot:
|
||
try:
|
||
notification_service = AdminNotificationService(bot)
|
||
await notification_service.send_subscription_purchase_notification(
|
||
db,
|
||
user,
|
||
subscription,
|
||
transaction,
|
||
selection.period.days,
|
||
was_trial_conversion,
|
||
)
|
||
except Exception as error: # pragma: no cover - defensive logging
|
||
logger.error(
|
||
"⚠️ Автопокупка: не удалось отправить уведомление админам (%s): %s",
|
||
user.telegram_id,
|
||
error,
|
||
)
|
||
|
||
try:
|
||
period_label = format_period_description(
|
||
selection.period.days,
|
||
getattr(user, "language", "ru"),
|
||
)
|
||
auto_message = texts.t(
|
||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
|
||
"✅ Subscription purchased automatically after balance top-up ({period}).",
|
||
).format(period=period_label)
|
||
|
||
hint_message = texts.t(
|
||
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
|
||
"Open the ‘My subscription’ section to access your link.",
|
||
)
|
||
|
||
purchase_message = purchase_result.get("message", "")
|
||
full_message = "\n\n".join(
|
||
part.strip()
|
||
for part in [auto_message, purchase_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",
|
||
selection.period.days,
|
||
user.telegram_id,
|
||
)
|
||
|
||
return True
|
||
|
||
|
||
async def auto_activate_subscription_after_topup(
|
||
db: AsyncSession,
|
||
user: User,
|
||
*,
|
||
bot: Optional[Bot] = None,
|
||
topup_amount: Optional[int] = None,
|
||
) -> tuple[bool, bool]:
|
||
"""
|
||
Умная автоактивация после пополнения баланса.
|
||
|
||
Работает БЕЗ сохранённой корзины:
|
||
- Если подписка активна — ничего не делает
|
||
- Если подписка истекла — продлевает с теми же параметрами
|
||
- Если подписки нет — создаёт новую с дефолтными параметрами
|
||
|
||
Выбирает максимальный период, который можно оплатить из баланса.
|
||
|
||
Args:
|
||
topup_amount: Сумма пополнения в копейках (для отображения в уведомлении)
|
||
|
||
Returns:
|
||
tuple[bool, bool]: (success, notification_sent)
|
||
- success: True если подписка активирована
|
||
- notification_sent: True если уведомление отправлено пользователю
|
||
"""
|
||
from datetime import datetime
|
||
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
|
||
from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads
|
||
from app.database.crud.transaction import create_transaction
|
||
from app.database.crud.user import subtract_user_balance
|
||
from app.database.models import TransactionType, PaymentMethod
|
||
from app.services.subscription_service import SubscriptionService
|
||
from app.services.subscription_renewal_service import SubscriptionRenewalService
|
||
from app.services.admin_notification_service import AdminNotificationService
|
||
|
||
if not user or not getattr(user, "id", None):
|
||
return (False, False)
|
||
|
||
subscription = await get_subscription_by_user_id(db, user.id)
|
||
|
||
# Если автоактивация отключена - только отправляем уведомление
|
||
if not settings.is_auto_activate_after_topup_enabled():
|
||
notification_sent = False
|
||
# Отправляем уведомление если включен режим
|
||
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP and bot:
|
||
try:
|
||
texts = get_texts(getattr(user, "language", "ru"))
|
||
has_active_subscription = (
|
||
subscription
|
||
and subscription.status in ("active", "ACTIVE")
|
||
)
|
||
|
||
# Формируем строку с суммой пополнения
|
||
topup_line = ""
|
||
if topup_amount:
|
||
topup_line = f"➕ Пополнено: <b>{settings.format_price(topup_amount)}</b>\n"
|
||
|
||
# Определяем состояние подписки
|
||
is_trial = subscription and getattr(subscription, 'is_trial', False)
|
||
|
||
if has_active_subscription and not is_trial:
|
||
# Активная платная подписка — 2 кнопки
|
||
warning_message = (
|
||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
||
f"{topup_line}"
|
||
f"💳 Текущий баланс: <b>{settings.format_price(user.balance_kopeks)}</b>\n\n"
|
||
f"👇 <b>Выберите действие:</b>"
|
||
)
|
||
keyboard = InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[InlineKeyboardButton(
|
||
text="💎 Продлить подписку",
|
||
callback_data="subscription_extend",
|
||
)],
|
||
[InlineKeyboardButton(
|
||
text="📱 Изменить устройства",
|
||
callback_data="subscription_change_devices",
|
||
)],
|
||
]
|
||
)
|
||
else:
|
||
# Триал или подписка закончилась — 1 кнопка
|
||
warning_message = (
|
||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
||
f"{topup_line}"
|
||
f"💳 Текущий баланс: <b>{settings.format_price(user.balance_kopeks)}</b>\n\n"
|
||
f"{'━' * 20}\n\n"
|
||
f"🚨🚨🚨 <b>ВНИМАНИЕ!</b> 🚨🚨🚨\n\n"
|
||
f"🔴 <b>ПОДПИСКА НЕ АКТИВНА!</b>\n\n"
|
||
f"⚠️ Пополнение баланса <b>НЕ активирует</b> подписку автоматически!\n\n"
|
||
f"👇 <b>Обязательно оформите подписку:</b>"
|
||
)
|
||
keyboard = InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[InlineKeyboardButton(
|
||
text="🚀 КУПИТЬ ПОДПИСКУ",
|
||
callback_data="menu_buy",
|
||
)],
|
||
]
|
||
)
|
||
|
||
await bot.send_message(
|
||
chat_id=user.telegram_id,
|
||
text=warning_message,
|
||
reply_markup=keyboard,
|
||
parse_mode="HTML",
|
||
)
|
||
notification_sent = True
|
||
logger.info(
|
||
"⚠️ Отправлено уведомление о пополнении баланса пользователю %s (автоактивация выключена, подписка %s)",
|
||
user.telegram_id,
|
||
"активна" if has_active_subscription else "неактивна",
|
||
)
|
||
except Exception as notify_error:
|
||
logger.warning(
|
||
"⚠️ Не удалось отправить уведомление пользователю %s: %s",
|
||
user.telegram_id,
|
||
notify_error,
|
||
)
|
||
return (False, notification_sent)
|
||
|
||
# Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть)
|
||
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
|
||
logger.info(
|
||
"🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем",
|
||
user.telegram_id,
|
||
)
|
||
return (False, False)
|
||
|
||
# Определяем параметры подписки
|
||
if subscription:
|
||
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||
# В режиме fixed_with_topup при автоактивации используем фиксированный лимит
|
||
if settings.is_traffic_fixed():
|
||
traffic_limit_gb = settings.get_fixed_traffic_limit()
|
||
else:
|
||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||
connected_squads = subscription.connected_squads or []
|
||
else:
|
||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||
# В режиме fixed_with_topup при автоактивации используем фиксированный лимит
|
||
if settings.is_traffic_fixed():
|
||
traffic_limit_gb = settings.get_fixed_traffic_limit()
|
||
else:
|
||
traffic_limit_gb = 0
|
||
connected_squads = []
|
||
|
||
# Если серверы не выбраны — берём бесплатные по умолчанию
|
||
if not connected_squads:
|
||
available_servers = await get_available_server_squads(db, promo_group_id=user.promo_group_id)
|
||
connected_squads = [
|
||
s.squad_uuid for s in available_servers
|
||
if s.is_available and s.price_kopeks == 0
|
||
]
|
||
if not connected_squads and available_servers:
|
||
connected_squads = [available_servers[0].squad_uuid]
|
||
|
||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||
|
||
balance = user.balance_kopeks
|
||
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True)
|
||
|
||
if not available_periods:
|
||
logger.warning("🔁 Автоактивация: нет доступных периодов подписки")
|
||
return (False, False)
|
||
|
||
subscription_service = SubscriptionService()
|
||
|
||
# Найти максимальный период <= баланса
|
||
best_period = None
|
||
best_price = 0
|
||
|
||
for period in available_periods:
|
||
try:
|
||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||
period,
|
||
traffic_limit_gb,
|
||
server_ids,
|
||
device_limit,
|
||
db,
|
||
user=user
|
||
)
|
||
if price <= balance:
|
||
best_period = period
|
||
best_price = price
|
||
break
|
||
except Exception as calc_error:
|
||
logger.warning(
|
||
"🔁 Автоактивация: ошибка расчёта цены для периода %s: %s",
|
||
period,
|
||
calc_error,
|
||
)
|
||
continue
|
||
|
||
if not best_period:
|
||
logger.info(
|
||
"🔁 Автоактивация: у пользователя %s недостаточно средств (%s) для любого периода",
|
||
user.telegram_id,
|
||
balance,
|
||
)
|
||
notification_sent = False
|
||
# Отправляем уведомление если включен режим
|
||
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP and bot:
|
||
try:
|
||
texts = get_texts(getattr(user, "language", "ru"))
|
||
has_active_subscription = (
|
||
subscription
|
||
and subscription.status in ("active", "ACTIVE")
|
||
)
|
||
|
||
# Формируем строку с суммой пополнения
|
||
topup_line2 = ""
|
||
if topup_amount:
|
||
topup_line2 = f"➕ Пополнено: <b>{settings.format_price(topup_amount)}</b>\n"
|
||
|
||
# Определяем состояние подписки
|
||
is_trial2 = subscription and getattr(subscription, 'is_trial', False)
|
||
|
||
if has_active_subscription and not is_trial2:
|
||
# Активная платная подписка — 2 кнопки
|
||
warning_message = (
|
||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
||
f"{topup_line2}"
|
||
f"💳 Текущий баланс: <b>{settings.format_price(balance)}</b>\n\n"
|
||
f"👇 <b>Выберите действие:</b>"
|
||
)
|
||
keyboard = InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[InlineKeyboardButton(
|
||
text="💎 Продлить подписку",
|
||
callback_data="subscription_extend",
|
||
)],
|
||
[InlineKeyboardButton(
|
||
text="📱 Изменить устройства",
|
||
callback_data="subscription_change_devices",
|
||
)],
|
||
]
|
||
)
|
||
else:
|
||
# Триал или подписка закончилась — 1 кнопка
|
||
warning_message = (
|
||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
||
f"{topup_line2}"
|
||
f"💳 Текущий баланс: <b>{settings.format_price(balance)}</b>\n\n"
|
||
f"{'━' * 20}\n\n"
|
||
f"🚨🚨🚨 <b>ВНИМАНИЕ!</b> 🚨🚨🚨\n\n"
|
||
f"🔴 <b>ПОДПИСКА НЕ АКТИВНА!</b>\n\n"
|
||
f"⚠️ Пополнение баланса <b>НЕ активирует</b> подписку автоматически!\n\n"
|
||
f"👇 <b>Обязательно оформите подписку:</b>"
|
||
)
|
||
keyboard = InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[InlineKeyboardButton(
|
||
text="🚀 КУПИТЬ ПОДПИСКУ",
|
||
callback_data="menu_buy",
|
||
)],
|
||
]
|
||
)
|
||
|
||
await bot.send_message(
|
||
chat_id=user.telegram_id,
|
||
text=warning_message,
|
||
reply_markup=keyboard,
|
||
parse_mode="HTML",
|
||
)
|
||
notification_sent = True
|
||
logger.info(
|
||
"⚠️ Отправлено уведомление о пополнении баланса пользователю %s (недостаточно средств, подписка %s)",
|
||
user.telegram_id,
|
||
"активна" if has_active_subscription else "неактивна",
|
||
)
|
||
except Exception as notify_error:
|
||
logger.warning(
|
||
"⚠️ Не удалось отправить уведомление пользователю %s: %s",
|
||
user.telegram_id,
|
||
notify_error,
|
||
)
|
||
return (False, notification_sent)
|
||
|
||
texts = get_texts(getattr(user, "language", "ru"))
|
||
|
||
try:
|
||
if subscription:
|
||
# Продление существующей подписки
|
||
renewal_service = SubscriptionRenewalService()
|
||
pricing = await renewal_service.calculate_pricing(
|
||
db, user, subscription, best_period
|
||
)
|
||
|
||
old_end_date = subscription.end_date
|
||
result = await renewal_service.finalize(
|
||
db, user, subscription,
|
||
pricing,
|
||
description=f"Автоматическое продление на {best_period} дней",
|
||
payment_method=PaymentMethod.BALANCE,
|
||
)
|
||
|
||
logger.info(
|
||
"✅ Автоактивация: подписка пользователя %s продлена на %s дней за %s коп.",
|
||
user.telegram_id,
|
||
best_period,
|
||
best_price,
|
||
)
|
||
|
||
# Уведомление пользователю
|
||
if bot:
|
||
try:
|
||
period_label = format_period_description(best_period, getattr(user, "language", "ru"))
|
||
new_end_date = result.subscription.end_date
|
||
end_date_str = new_end_date.strftime("%d.%m.%Y") if new_end_date else "—"
|
||
|
||
message = texts.t(
|
||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED",
|
||
"✅ Подписка автоматически продлена на {period}.",
|
||
).format(period=period_label)
|
||
|
||
details = texts.t(
|
||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS",
|
||
"⏰ Новая дата окончания: {date}.",
|
||
).format(date=end_date_str)
|
||
|
||
hint = texts.t(
|
||
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
|
||
"Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
|
||
)
|
||
|
||
keyboard = InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[InlineKeyboardButton(
|
||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||
callback_data="menu_subscription",
|
||
)],
|
||
]
|
||
)
|
||
|
||
await bot.send_message(
|
||
chat_id=user.telegram_id,
|
||
text=f"{message}\n{details}\n\n{hint}",
|
||
reply_markup=keyboard,
|
||
parse_mode="HTML",
|
||
)
|
||
except Exception as notify_error:
|
||
logger.warning(
|
||
"⚠️ Автоактивация: не удалось уведомить пользователя %s: %s",
|
||
user.telegram_id,
|
||
notify_error,
|
||
)
|
||
|
||
else:
|
||
# Создание новой подписки
|
||
new_subscription = await create_paid_subscription(
|
||
db,
|
||
user.id,
|
||
best_period,
|
||
traffic_limit_gb=traffic_limit_gb,
|
||
device_limit=device_limit,
|
||
connected_squads=connected_squads,
|
||
update_server_counters=True
|
||
)
|
||
|
||
await subtract_user_balance(
|
||
db, user, best_price,
|
||
f"Активация подписки на {best_period} дней"
|
||
)
|
||
|
||
await subscription_service.create_remnawave_user(db, new_subscription)
|
||
|
||
await create_transaction(
|
||
db=db,
|
||
user_id=user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=best_price,
|
||
description=f"Активация подписки на {best_period} дней",
|
||
payment_method=PaymentMethod.BALANCE,
|
||
)
|
||
|
||
logger.info(
|
||
"✅ Автоактивация: новая подписка на %s дней создана для пользователя %s за %s коп.",
|
||
best_period,
|
||
user.telegram_id,
|
||
best_price,
|
||
)
|
||
|
||
# Уведомление пользователю
|
||
if bot:
|
||
try:
|
||
period_label = format_period_description(best_period, getattr(user, "language", "ru"))
|
||
|
||
message = texts.t(
|
||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
|
||
"✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
|
||
).format(period=period_label)
|
||
|
||
hint = texts.t(
|
||
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
|
||
"Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
|
||
)
|
||
|
||
keyboard = InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[InlineKeyboardButton(
|
||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||
callback_data="menu_subscription",
|
||
)],
|
||
]
|
||
)
|
||
|
||
await bot.send_message(
|
||
chat_id=user.telegram_id,
|
||
text=f"{message}\n\n{hint}",
|
||
reply_markup=keyboard,
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
# Уведомление админам
|
||
try:
|
||
notification_service = AdminNotificationService(bot)
|
||
await notification_service.send_subscription_purchase_notification(
|
||
db,
|
||
user,
|
||
new_subscription,
|
||
None, # transaction
|
||
best_period,
|
||
False, # was_trial_conversion
|
||
)
|
||
except Exception as admin_error:
|
||
logger.warning(
|
||
"⚠️ Автоактивация: не удалось уведомить админов: %s",
|
||
admin_error,
|
||
)
|
||
|
||
except Exception as notify_error:
|
||
logger.warning(
|
||
"⚠️ Автоактивация: не удалось уведомить пользователя %s: %s",
|
||
user.telegram_id,
|
||
notify_error,
|
||
)
|
||
|
||
return (True, True) # success=True, notification_sent=True (об активации)
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
"❌ Автоактивация: ошибка для пользователя %s: %s",
|
||
user.telegram_id,
|
||
e,
|
||
exc_info=True,
|
||
)
|
||
return (False, False)
|
||
|
||
|
||
__all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"]
|