Files
remnawave-bedolaga-telegram…/app/services/subscription_auto_purchase_service.py
2026-01-16 15:58:22 +03:00

1023 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)
# Если автоактивация отключена - уведомление отправится из _send_payment_success_notification
if not settings.is_auto_activate_after_topup_enabled():
logger.info(
"⚠️ Автоактивация отключена для пользователя %s, уведомление будет отправлено из payment service",
user.telegram_id,
)
return (False, False)
# Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть)
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,
)
# Уведомление отправится из _send_payment_success_notification
logger.info(
"⚠️ Недостаточно средств для автоактивации пользователя %s, уведомление будет отправлено из payment service",
user.telegram_id,
)
return (False, False)
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)
await db.rollback()
return False
__all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"]