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