import logging from datetime import datetime, timedelta from typing import Iterable, Optional, List, Tuple from sqlalchemy import select, and_, func, delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database.models import ( Subscription, SubscriptionStatus, User, SubscriptionServer, PromoGroup, UserPromoGroup, ) from app.database.crud.notification import clear_notifications from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months from app.config import settings from app.utils.timezone import format_local_datetime logger = logging.getLogger(__name__) async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]: result = await db.execute( select(Subscription) .options( selectinload(Subscription.user), selectinload(Subscription.tariff), ) .where(Subscription.user_id == user_id) .order_by(Subscription.created_at.desc()) .limit(1) ) subscription = result.scalar_one_or_none() if subscription: logger.info(f"🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}") subscription = await check_and_update_subscription_status(db, subscription) return subscription async def create_trial_subscription( db: AsyncSession, user_id: int, duration_days: int = None, traffic_limit_gb: int = None, device_limit: Optional[int] = None, squad_uuid: str = None, connected_squads: List[str] = None, tariff_id: Optional[int] = None, ) -> Subscription: """Создает триальную подписку. Args: connected_squads: Список UUID сквадов (если указан, squad_uuid игнорируется) tariff_id: ID тарифа (для режима тарифов) """ duration_days = duration_days or settings.TRIAL_DURATION_DAYS traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB if device_limit is None: device_limit = settings.TRIAL_DEVICE_LIMIT # Если переданы connected_squads, используем их # Иначе используем squad_uuid или получаем случайный final_squads = [] if connected_squads: final_squads = connected_squads elif squad_uuid: final_squads = [squad_uuid] else: try: from app.database.crud.server_squad import get_random_trial_squad_uuid random_squad = await get_random_trial_squad_uuid(db) if random_squad: final_squads = [random_squad] logger.debug( "Выбран сквад %s для триальной подписки пользователя %s", random_squad, user_id, ) except Exception as error: logger.error( "Не удалось получить сквад для триальной подписки пользователя %s: %s", user_id, error, ) end_date = datetime.utcnow() + timedelta(days=duration_days) subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, is_trial=True, start_date=datetime.utcnow(), end_date=end_date, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, connected_squads=final_squads, autopay_enabled=settings.is_autopay_enabled_by_default(), autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE, tariff_id=tariff_id, ) db.add(subscription) await db.commit() await db.refresh(subscription) logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}" + (f" с тарифом {tariff_id}" if tariff_id else "")) if final_squads: try: from app.database.crud.server_squad import ( get_server_ids_by_uuids, add_user_to_servers, ) server_ids = await get_server_ids_by_uuids(db, final_squads) if server_ids: await add_user_to_servers(db, server_ids) logger.info( "📈 Обновлен счетчик пользователей для триальных сквадов %s", final_squads, ) else: logger.warning( "⚠️ Не удалось найти серверы для обновления счетчика (сквады %s)", final_squads, ) except Exception as error: logger.error( "⚠️ Ошибка обновления счетчика пользователей для триальных сквадов %s: %s", final_squads, error, ) return subscription async def create_paid_subscription( db: AsyncSession, user_id: int, duration_days: int, traffic_limit_gb: int = 0, device_limit: Optional[int] = None, connected_squads: List[str] = None, update_server_counters: bool = False, is_trial: bool = False, tariff_id: Optional[int] = None, ) -> Subscription: end_date = datetime.utcnow() + timedelta(days=duration_days) if device_limit is None: device_limit = settings.DEFAULT_DEVICE_LIMIT subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, is_trial=is_trial, start_date=datetime.utcnow(), end_date=end_date, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, connected_squads=connected_squads or [], autopay_enabled=settings.is_autopay_enabled_by_default(), autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE, tariff_id=tariff_id, ) db.add(subscription) await db.commit() await db.refresh(subscription) logger.info(f"💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}") squad_uuids = list(connected_squads or []) if update_server_counters and squad_uuids: try: from app.database.crud.server_squad import ( get_server_ids_by_uuids, add_user_to_servers, ) server_ids = await get_server_ids_by_uuids(db, squad_uuids) if server_ids: await add_user_to_servers(db, server_ids) logger.info( "📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)", user_id, squad_uuids, ) else: logger.warning( "⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)", user_id, squad_uuids, ) except Exception as error: logger.error( "⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s", user_id, error, ) return subscription async def replace_subscription( db: AsyncSession, subscription: Subscription, *, duration_days: int, traffic_limit_gb: int, device_limit: int, connected_squads: List[str], is_trial: bool, autopay_enabled: Optional[bool] = None, autopay_days_before: Optional[int] = None, update_server_counters: bool = False, ) -> Subscription: """Перезаписывает параметры существующей подписки пользователя.""" current_time = datetime.utcnow() old_squads = set(subscription.connected_squads or []) new_squads = set(connected_squads or []) new_autopay_enabled = ( subscription.autopay_enabled if autopay_enabled is None else autopay_enabled ) new_autopay_days_before = ( subscription.autopay_days_before if autopay_days_before is None else autopay_days_before ) subscription.status = SubscriptionStatus.ACTIVE.value subscription.is_trial = is_trial subscription.start_date = current_time subscription.end_date = current_time + timedelta(days=duration_days) subscription.traffic_limit_gb = traffic_limit_gb subscription.traffic_used_gb = 0.0 subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика subscription.device_limit = device_limit subscription.connected_squads = list(new_squads) subscription.subscription_url = None subscription.subscription_crypto_link = None subscription.remnawave_short_uuid = None subscription.autopay_enabled = new_autopay_enabled subscription.autopay_days_before = new_autopay_days_before subscription.updated_at = current_time await db.commit() await db.refresh(subscription) if update_server_counters: try: from app.database.crud.server_squad import ( add_user_to_servers, get_server_ids_by_uuids, remove_user_from_servers, ) squads_to_remove = old_squads - new_squads squads_to_add = new_squads - old_squads if squads_to_remove: server_ids = await get_server_ids_by_uuids(db, list(squads_to_remove)) if server_ids: await remove_user_from_servers(db, sorted(server_ids)) if squads_to_add: server_ids = await get_server_ids_by_uuids(db, list(squads_to_add)) if server_ids: await add_user_to_servers(db, sorted(server_ids)) logger.info( "♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s", subscription.id, len(squads_to_remove), len(squads_to_add), ) except Exception as error: logger.error( "⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s", subscription.id, error, ) return subscription async def extend_subscription( db: AsyncSession, subscription: Subscription, days: int, *, tariff_id: Optional[int] = None, traffic_limit_gb: Optional[int] = None, device_limit: Optional[int] = None, connected_squads: Optional[List[str]] = None, ) -> Subscription: """Продлевает подписку на указанное количество дней. Args: db: Сессия базы данных subscription: Подписка для продления days: Количество дней для продления tariff_id: ID тарифа (опционально, для режима тарифов) traffic_limit_gb: Лимит трафика ГБ (опционально, для режима тарифов) device_limit: Лимит устройств (опционально, для режима тарифов) connected_squads: Список UUID сквадов (опционально, для режима тарифов) """ current_time = datetime.utcnow() logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней") logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}") # Определяем, происходит ли СМЕНА тарифа (а не продление того же) is_tariff_change = ( tariff_id is not None and subscription.tariff_id is not None and tariff_id != subscription.tariff_id ) if is_tariff_change: logger.info(f"🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}") # Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла # и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа. # Если подписка ещё активна - просто добавляем дни к существующей дате окончания. bonus_days = 0 if days < 0: subscription.end_date = subscription.end_date + timedelta(days=days) logger.info( "📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s", abs(days), subscription.end_date, ) elif is_tariff_change: # При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: if subscription.end_date and subscription.end_date > current_time: remaining = subscription.end_date - current_time if remaining.total_seconds() > 0: bonus_days = max(0, remaining.days) logger.info( "🎁 Обнаружен остаток триала: %s дней для подписки %s", bonus_days, subscription.id, ) total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) subscription.start_date = current_time logger.info(f"📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней") elif subscription.end_date > current_time: # Подписка активна - просто добавляем дни к текущей дате окончания # БЕЗ бонусных дней (они уже учтены в end_date) subscription.end_date = subscription.end_date + timedelta(days=days) logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания") else: # Подписка истекла - начинаем с текущей даты + бонус от триала if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: # Триал истёк, но бонус всё равно не добавляем (триал уже истёк) pass total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней") # УДАЛЕНО: Автоматическая конвертация триала по длительности # Теперь триал конвертируется ТОЛЬКО после успешного коммита продления # и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription) # Логируем статус подписки перед проверкой logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}") if days > 0 and subscription.status in ( SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value, ): previous_status = subscription.status subscription.status = SubscriptionStatus.ACTIVE.value logger.info( "🔄 Статус подписки %s изменён с %s на ACTIVE", subscription.id, previous_status, ) elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value: logger.warning( "⚠️ Попытка продлить PENDING подписку %s, дни: %s", subscription.id, days ) # Обновляем параметры тарифа, если переданы if tariff_id is not None: old_tariff_id = subscription.tariff_id subscription.tariff_id = tariff_id logger.info(f"📦 Обновлен тариф подписки: {old_tariff_id} → {tariff_id}") # При покупке тарифа сбрасываем триальный статус if subscription.is_trial: subscription.is_trial = False logger.info(f"🎓 Подписка {subscription.id} конвертирована из триала в платную") if traffic_limit_gb is not None: old_traffic = subscription.traffic_limit_gb subscription.traffic_limit_gb = traffic_limit_gb subscription.traffic_used_gb = 0.0 # Сбрасываем все докупки трафика при смене тарифа from app.database.models import TrafficPurchase from sqlalchemy import delete as sql_delete await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)") elif settings.RESET_TRAFFIC_ON_PAYMENT: subscription.traffic_used_gb = 0.0 # В режиме тарифов сохраняем докупленный трафик при продлении if subscription.tariff_id is None: subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT") else: # При продлении в режиме тарифов - сохраняем purchased_traffic_gb и traffic_reset_at logger.info("🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)") if device_limit is not None: old_devices = subscription.device_limit subscription.device_limit = device_limit logger.info(f"📱 Обновлен лимит устройств: {old_devices} → {device_limit}") if connected_squads is not None: old_squads = subscription.connected_squads subscription.connected_squads = connected_squads logger.info(f"🌍 Обновлены сквады: {old_squads} → {connected_squads}") # Обработка daily полей при смене тарифа if is_tariff_change and tariff_id is not None: # Получаем информацию о новом тарифе для проверки is_daily from app.database.crud.tariff import get_tariff_by_id new_tariff = await get_tariff_by_id(db, tariff_id) old_was_daily = getattr(subscription, 'is_daily_paused', False) or getattr(subscription, 'last_daily_charge_at', None) is not None if new_tariff and getattr(new_tariff, 'is_daily', False): # Переход на суточный тариф - сбрасываем флаги subscription.is_daily_paused = False subscription.last_daily_charge_at = None # Будет установлено при первом списании logger.info(f"🔄 Переход на суточный тариф: сброшены daily флаги") elif old_was_daily: # Переход с суточного на обычный тариф - очищаем daily поля subscription.is_daily_paused = False subscription.last_daily_charge_at = None logger.info(f"🔄 Переход с суточного тарифа: очищены daily флаги") # В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита # Только если не передан traffic_limit_gb И у подписки нет тарифа (классический режим) # Если у подписки есть tariff_id - трафик определяется тарифом, не сбрасываем if traffic_limit_gb is None and settings.is_traffic_fixed() and days > 0 and subscription.tariff_id is None: fixed_limit = settings.get_fixed_traffic_limit() old_limit = subscription.traffic_limit_gb if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0: subscription.traffic_limit_gb = fixed_limit subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика logger.info(f"🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ") subscription.updated_at = current_time await db.commit() await db.refresh(subscription) await clear_notifications(db, subscription.id) logger.info(f"✅ Подписка продлена до: {subscription.end_date}") logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}") return subscription async def add_subscription_traffic( db: AsyncSession, subscription: Subscription, gb: int ) -> Subscription: subscription.add_traffic(gb) subscription.updated_at = datetime.utcnow() # Создаём новую запись докупки с индивидуальной датой истечения (30 дней) from app.database.models import TrafficPurchase from sqlalchemy import select as sql_select from datetime import timedelta new_expires_at = datetime.utcnow() + timedelta(days=30) new_purchase = TrafficPurchase( subscription_id=subscription.id, traffic_gb=gb, expires_at=new_expires_at ) db.add(new_purchase) # Обновляем общий счетчик докупленного трафика current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0 subscription.purchased_traffic_gb = current_purchased + gb # Устанавливаем traffic_reset_at на ближайшую дату истечения из всех активных докупок now = datetime.utcnow() active_purchases_query = ( sql_select(TrafficPurchase) .where(TrafficPurchase.subscription_id == subscription.id) .where(TrafficPurchase.expires_at > now) ) active_purchases_result = await db.execute(active_purchases_query) active_purchases = active_purchases_result.scalars().all() if active_purchases: # Добавляем только что созданную покупку к списку all_active = list(active_purchases) + [new_purchase] earliest_expiry = min(p.expires_at for p in all_active) subscription.traffic_reset_at = earliest_expiry else: # Первая докупка subscription.traffic_reset_at = new_expires_at await db.commit() await db.refresh(subscription) logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика (истекает {new_expires_at.strftime('%d.%m.%Y')})") return subscription async def add_subscription_devices( db: AsyncSession, subscription: Subscription, devices: int ) -> Subscription: subscription.device_limit += devices subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) logger.info(f"📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств") return subscription async def add_subscription_squad( db: AsyncSession, subscription: Subscription, squad_uuid: str ) -> Subscription: if squad_uuid not in subscription.connected_squads: subscription.connected_squads = subscription.connected_squads + [squad_uuid] subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) logger.info(f"🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}") return subscription async def remove_subscription_squad( db: AsyncSession, subscription: Subscription, squad_uuid: str ) -> Subscription: if squad_uuid in subscription.connected_squads: squads = subscription.connected_squads.copy() squads.remove(squad_uuid) subscription.connected_squads = squads subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) logger.info(f"🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}") return subscription async def decrement_subscription_server_counts( db: AsyncSession, subscription: Optional[Subscription], *, subscription_servers: Optional[Iterable[SubscriptionServer]] = None, ) -> None: """Decrease server counters linked to the provided subscription.""" if not subscription: return server_ids: set[int] = set() if subscription_servers is not None: for sub_server in subscription_servers: if sub_server and sub_server.server_squad_id is not None: server_ids.add(sub_server.server_squad_id) else: try: ids_from_links = await get_subscription_server_ids(db, subscription.id) server_ids.update(ids_from_links) except Exception as error: logger.error( "⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s", subscription.id, error, ) connected_squads = list(subscription.connected_squads or []) if connected_squads: try: from app.database.crud.server_squad import get_server_ids_by_uuids squad_server_ids = await get_server_ids_by_uuids(db, connected_squads) server_ids.update(squad_server_ids) except Exception as error: logger.error( "⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s", subscription.id, error, ) if not server_ids: return try: from app.database.crud.server_squad import remove_user_from_servers await remove_user_from_servers(db, sorted(server_ids)) except Exception as error: logger.error( "⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s", list(server_ids), subscription.id, error, ) async def update_subscription_autopay( db: AsyncSession, subscription: Subscription, enabled: bool, days_before: int = 3 ) -> Subscription: subscription.autopay_enabled = enabled subscription.autopay_days_before = days_before subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) status = "включен" if enabled else "выключен" logger.info(f"💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}") return subscription async def deactivate_subscription( db: AsyncSession, subscription: Subscription ) -> Subscription: subscription.status = SubscriptionStatus.DISABLED.value subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) logger.info(f"❌ Подписка пользователя {subscription.user_id} деактивирована") return subscription async def reactivate_subscription( db: AsyncSession, subscription: Subscription ) -> Subscription: """Реактивация подписки (например, после повторной подписки на канал). Активирует только если подписка была DISABLED и ещё не истекла. Не логирует если реактивация не требуется. """ now = datetime.utcnow() # Тихо выходим если реактивация не нужна if subscription.status != SubscriptionStatus.DISABLED.value: return subscription if subscription.end_date and subscription.end_date <= now: return subscription subscription.status = SubscriptionStatus.ACTIVE.value subscription.updated_at = now await db.commit() await db.refresh(subscription) return subscription async def get_expiring_subscriptions( db: AsyncSession, days_before: int = 3 ) -> List[Subscription]: threshold_date = datetime.utcnow() + timedelta(days=days_before) result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.end_date <= threshold_date, Subscription.end_date > datetime.utcnow() ) ) ) return result.scalars().all() async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]: result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.end_date <= datetime.utcnow() ) ) ) return result.scalars().all() async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]: current_time = datetime.utcnow() result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.autopay_enabled == True, Subscription.is_trial == False ) ) ) all_autopay_subscriptions = result.scalars().all() ready_for_autopay = [] for subscription in all_autopay_subscriptions: days_until_expiry = (subscription.end_date - current_time).days if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time: ready_for_autopay.append(subscription) return ready_for_autopay async def get_subscriptions_statistics(db: AsyncSession) -> dict: total_result = await db.execute(select(func.count(Subscription.id))) total_subscriptions = total_result.scalar() active_result = await db.execute( select(func.count(Subscription.id)) .where(Subscription.status == SubscriptionStatus.ACTIVE.value) ) active_subscriptions = active_result.scalar() trial_result = await db.execute( select(func.count(Subscription.id)) .where( and_( Subscription.is_trial == True, Subscription.status == SubscriptionStatus.ACTIVE.value ) ) ) trial_subscriptions = trial_result.scalar() paid_subscriptions = active_subscriptions - trial_subscriptions today = datetime.utcnow().date() today_result = await db.execute( select(func.count(Subscription.id)) .where( and_( Subscription.created_at >= today, Subscription.is_trial == False ) ) ) purchased_today = today_result.scalar() week_ago = datetime.utcnow() - timedelta(days=7) week_result = await db.execute( select(func.count(Subscription.id)) .where( and_( Subscription.created_at >= week_ago, Subscription.is_trial == False ) ) ) purchased_week = week_result.scalar() month_ago = datetime.utcnow() - timedelta(days=30) month_result = await db.execute( select(func.count(Subscription.id)) .where( and_( Subscription.created_at >= month_ago, Subscription.is_trial == False ) ) ) purchased_month = month_result.scalar() try: from app.database.crud.subscription_conversion import get_conversion_statistics conversion_stats = await get_conversion_statistics(db) trial_to_paid_conversion = conversion_stats.get("conversion_rate", 0) renewals_count = conversion_stats.get("month_conversions", 0) logger.info(f"📊 Статистика конверсии из таблицы conversions:") logger.info(f" Общее количество конверсий: {conversion_stats.get('total_conversions', 0)}") logger.info(f" Процент конверсии: {trial_to_paid_conversion}%") logger.info(f" Конверсий за месяц: {renewals_count}") except ImportError: logger.warning("⚠️ Таблица subscription_conversions не найдена, используем старую логику") users_with_paid_result = await db.execute( select(func.count(User.id)) .where(User.has_had_paid_subscription == True) ) users_with_paid = users_with_paid_result.scalar() total_users_result = await db.execute(select(func.count(User.id))) total_users = total_users_result.scalar() if total_users > 0: trial_to_paid_conversion = round((users_with_paid / total_users) * 100, 1) else: trial_to_paid_conversion = 0 renewals_count = 0 return { "total_subscriptions": total_subscriptions, "active_subscriptions": active_subscriptions, "trial_subscriptions": trial_subscriptions, "paid_subscriptions": paid_subscriptions, "purchased_today": purchased_today, "purchased_week": purchased_week, "purchased_month": purchased_month, "trial_to_paid_conversion": trial_to_paid_conversion, "renewals_count": renewals_count } async def get_trial_statistics(db: AsyncSession) -> dict: now = datetime.utcnow() total_trials_result = await db.execute( select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True)) ) total_trials = total_trials_result.scalar() or 0 active_trials_result = await db.execute( select(func.count(Subscription.id)).where( Subscription.is_trial.is_(True), Subscription.end_date > now, Subscription.status.in_( [SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value] ), ) ) active_trials = active_trials_result.scalar() or 0 resettable_trials_result = await db.execute( select(func.count(Subscription.id)) .join(User, Subscription.user_id == User.id) .where( Subscription.is_trial.is_(True), Subscription.end_date <= now, User.has_had_paid_subscription.is_(False), ) ) resettable_trials = resettable_trials_result.scalar() or 0 return { "used_trials": total_trials, "active_trials": active_trials, "resettable_trials": resettable_trials, } async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int: now = datetime.utcnow() result = await db.execute( select(Subscription) .options( selectinload(Subscription.user), selectinload(Subscription.subscription_servers), ) .join(User, Subscription.user_id == User.id) .where( Subscription.is_trial.is_(True), Subscription.end_date <= now, User.has_had_paid_subscription.is_(False), ) ) subscriptions = result.scalars().unique().all() if not subscriptions: return 0 reset_count = len(subscriptions) for subscription in subscriptions: try: await decrement_subscription_server_counts( db, subscription, subscription_servers=subscription.subscription_servers, ) except Exception as error: # pragma: no cover - defensive logging logger.error( "Не удалось обновить счётчики серверов при сбросе триала %s: %s", subscription.id, error, ) subscription_ids = [subscription.id for subscription in subscriptions] if subscription_ids: try: await db.execute( delete(SubscriptionServer).where( SubscriptionServer.subscription_id.in_(subscription_ids) ) ) except Exception as error: # pragma: no cover - defensive logging logger.error( "Ошибка удаления серверных связей триалов %s: %s", subscription_ids, error, ) raise await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids))) try: await db.commit() except Exception as error: # pragma: no cover - defensive logging await db.rollback() logger.error("Ошибка сохранения сброса триалов: %s", error) raise logger.info("♻️ Сброшено триальных подписок: %s", reset_count) return reset_count async def update_subscription_usage( db: AsyncSession, subscription: Subscription, used_gb: float ) -> Subscription: subscription.traffic_used_gb = used_gb subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) return subscription async def get_all_subscriptions( db: AsyncSession, page: int = 1, limit: int = 10 ) -> Tuple[List[Subscription], int]: count_result = await db.execute( select(func.count(Subscription.id)) ) total_count = count_result.scalar() offset = (page - 1) * limit result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) .order_by(Subscription.created_at.desc()) .offset(offset) .limit(limit) ) subscriptions = result.scalars().all() return subscriptions, total_count async def get_subscriptions_batch( db: AsyncSession, offset: int = 0, limit: int = 500, ) -> List[Subscription]: """Получает подписки пачками для синхронизации. Загружает связанных пользователей.""" result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) .order_by(Subscription.id) .offset(offset) .limit(limit) ) return list(result.scalars().all()) async def add_subscription_servers( db: AsyncSession, subscription: Subscription, server_squad_ids: List[int], paid_prices: List[int] = None ) -> Subscription: await db.refresh(subscription) if paid_prices is None: months_remaining = get_remaining_months(subscription.end_date) paid_prices = [] from app.database.models import ServerSquad for server_id in server_squad_ids: result = await db.execute( select(ServerSquad.price_kopeks) .where(ServerSquad.id == server_id) ) server_price_per_month = result.scalar() or 0 total_price_for_period = server_price_per_month * months_remaining paid_prices.append(total_price_for_period) for i, server_id in enumerate(server_squad_ids): subscription_server = SubscriptionServer( subscription_id=subscription.id, server_squad_id=server_id, paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0 ) db.add(subscription_server) await db.commit() await db.refresh(subscription) logger.info(f"🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}") return subscription async def get_server_monthly_price( db: AsyncSession, server_squad_id: int ) -> int: from app.database.models import ServerSquad result = await db.execute( select(ServerSquad.price_kopeks) .where(ServerSquad.id == server_squad_id) ) return result.scalar() or 0 async def get_servers_monthly_prices( db: AsyncSession, server_squad_ids: List[int] ) -> List[int]: prices = [] for server_id in server_squad_ids: price = await get_server_monthly_price(db, server_id) prices.append(price) return prices def _get_discount_percent( user: Optional[User], promo_group: Optional[PromoGroup], category: str, *, period_days: Optional[int] = None, ) -> int: if user is not None: try: return user.get_promo_discount(category, period_days) except AttributeError: pass if promo_group is not None: return promo_group.get_discount_percent(category, period_days) return 0 async def calculate_subscription_total_cost( db: AsyncSession, period_days: int, traffic_gb: int, server_squad_ids: List[int], devices: int, *, user: Optional[User] = None, promo_group: Optional[PromoGroup] = None, ) -> Tuple[int, dict]: from app.config import PERIOD_PRICES months_in_period = calculate_months_from_days(period_days) base_price_original = PERIOD_PRICES.get(period_days, 0) period_discount_percent = _get_discount_percent( user, promo_group, "period", period_days=period_days, ) base_discount_total = base_price_original * period_discount_percent // 100 base_price = base_price_original - base_discount_total promo_group = promo_group or (user.promo_group if user else None) traffic_price_per_month = settings.get_traffic_price(traffic_gb) traffic_discount_percent = _get_discount_percent( user, promo_group, "traffic", period_days=period_days, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month total_traffic_price = discounted_traffic_per_month * months_in_period total_traffic_discount = traffic_discount_per_month * months_in_period servers_prices = await get_servers_monthly_prices(db, server_squad_ids) servers_price_per_month = sum(servers_prices) servers_discount_percent = _get_discount_percent( user, promo_group, "servers", period_days=period_days, ) servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 discounted_servers_per_month = servers_price_per_month - servers_discount_per_month total_servers_price = discounted_servers_per_month * months_in_period total_servers_discount = servers_discount_per_month * months_in_period additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_discount_percent = _get_discount_percent( user, promo_group, "devices", period_days=period_days, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month total_devices_price = discounted_devices_per_month * months_in_period total_devices_discount = devices_discount_per_month * months_in_period total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price details = { 'base_price': base_price, 'base_price_original': base_price_original, 'base_discount_percent': period_discount_percent, 'base_discount_total': base_discount_total, 'traffic_price_per_month': traffic_price_per_month, 'traffic_discount_percent': traffic_discount_percent, 'traffic_discount_total': total_traffic_discount, 'total_traffic_price': total_traffic_price, 'servers_price_per_month': servers_price_per_month, 'servers_discount_percent': servers_discount_percent, 'servers_discount_total': total_servers_discount, 'total_servers_price': total_servers_price, 'devices_price_per_month': devices_price_per_month, 'devices_discount_percent': devices_discount_percent, 'devices_discount_total': total_devices_discount, 'total_devices_price': total_devices_price, 'months_in_period': months_in_period, 'servers_individual_prices': [ (price - (price * servers_discount_percent // 100)) * months_in_period for price in servers_prices ] } logger.debug(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):") logger.debug(f" Базовый период: {base_price/100}₽") if total_traffic_price > 0: message = ( f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽" ) if total_traffic_discount > 0: message += ( f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)" ) logger.debug(message) if total_servers_price > 0: message = ( f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽" ) if total_servers_discount > 0: message += ( f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)" ) logger.debug(message) if total_devices_price > 0: message = ( f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽" ) if total_devices_discount > 0: message += ( f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)" ) logger.debug(message) logger.debug(f" ИТОГО: {total_cost/100}₽") return total_cost, details async def get_subscription_server_ids( db: AsyncSession, subscription_id: int ) -> List[int]: result = await db.execute( select(SubscriptionServer.server_squad_id) .where(SubscriptionServer.subscription_id == subscription_id) ) return [row[0] for row in result.fetchall()] async def get_subscription_servers( db: AsyncSession, subscription_id: int ) -> List[dict]: from app.database.models import ServerSquad result = await db.execute( select(SubscriptionServer, ServerSquad) .join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id) .where(SubscriptionServer.subscription_id == subscription_id) ) servers_info = [] for sub_server, server_squad in result.fetchall(): servers_info.append({ 'server_id': server_squad.id, 'squad_uuid': server_squad.squad_uuid, 'display_name': server_squad.display_name, 'country_code': server_squad.country_code, 'paid_price_kopeks': sub_server.paid_price_kopeks, 'connected_at': sub_server.connected_at, 'is_available': server_squad.is_available }) return servers_info async def remove_subscription_servers( db: AsyncSession, subscription_id: int, server_squad_ids: List[int] ) -> bool: try: from app.database.models import SubscriptionServer from sqlalchemy import delete await db.execute( delete(SubscriptionServer) .where( SubscriptionServer.subscription_id == subscription_id, SubscriptionServer.server_squad_id.in_(server_squad_ids) ) ) await db.commit() logger.info(f"🗑️ Удалены серверы {server_squad_ids} из подписки {subscription_id}") return True except Exception as e: logger.error(f"Ошибка удаления серверов из подписки: {e}") await db.rollback() return False async def get_subscription_renewal_cost( db: AsyncSession, subscription_id: int, period_days: int, *, user: Optional[User] = None, promo_group: Optional[PromoGroup] = None, ) -> int: try: from app.config import PERIOD_PRICES months_in_period = calculate_months_from_days(period_days) base_price = PERIOD_PRICES.get(period_days, 0) result = await db.execute( select(Subscription) .options( selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), ) .where(Subscription.id == subscription_id) ) subscription = result.scalar_one_or_none() if not subscription: return base_price if user is None: user = subscription.user promo_group = promo_group or (user.promo_group if user else None) servers_info = await get_subscription_servers(db, subscription_id) servers_price_per_month = 0 for server_info in servers_info: from app.database.models import ServerSquad result = await db.execute( select(ServerSquad.price_kopeks) .where(ServerSquad.id == server_info['server_id']) ) current_server_price = result.scalar() or 0 servers_price_per_month += current_server_price servers_discount_percent = _get_discount_percent( user, promo_group, "servers", period_days=period_days, ) servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 discounted_servers_per_month = servers_price_per_month - servers_discount_per_month total_servers_cost = discounted_servers_per_month * months_in_period total_servers_discount = servers_discount_per_month * months_in_period # В режиме fixed_with_topup при продлении используем фиксированный лимит if settings.is_traffic_fixed(): renewal_traffic_gb = settings.get_fixed_traffic_limit() else: renewal_traffic_gb = subscription.traffic_limit_gb traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb) traffic_discount_percent = _get_discount_percent( user, promo_group, "traffic", period_days=period_days, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month total_traffic_cost = discounted_traffic_per_month * months_in_period total_traffic_discount = traffic_discount_per_month * months_in_period additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_discount_percent = _get_discount_percent( user, promo_group, "devices", period_days=period_days, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month total_devices_cost = discounted_devices_per_month * months_in_period total_devices_discount = devices_discount_per_month * months_in_period total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):") logger.info(f" 📅 Период: {base_price/100}₽") if total_servers_cost > 0: message = ( f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽" ) if total_servers_discount > 0: message += ( f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)" ) logger.info(message) if total_traffic_cost > 0: message = ( f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽" ) if total_traffic_discount > 0: message += ( f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)" ) logger.info(message) if total_devices_cost > 0: message = ( f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽" ) if total_devices_discount > 0: message += ( f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)" ) logger.info(message) logger.info(f" 💎 ИТОГО: {total_cost/100}₽") return total_cost except Exception as e: logger.error(f"Ошибка расчета стоимости продления: {e}") from app.config import PERIOD_PRICES return PERIOD_PRICES.get(period_days, 0) async def calculate_addon_cost_for_remaining_period( db: AsyncSession, subscription: Subscription, additional_traffic_gb: int = 0, additional_devices: int = 0, additional_server_ids: List[int] = None, *, user: Optional[User] = None, promo_group: Optional[PromoGroup] = None, ) -> int: if additional_server_ids is None: additional_server_ids = [] months_to_pay = get_remaining_months(subscription.end_date) period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None total_cost = 0 if user is None: user = getattr(subscription, "user", None) promo_group = promo_group or (user.promo_group if user else None) if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) traffic_discount_percent = _get_discount_percent( user, promo_group, "traffic", period_days=period_hint_days, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month traffic_total_cost = discounted_traffic_per_month * months_to_pay total_cost += traffic_total_cost message = ( f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽" ) if traffic_discount_per_month > 0: message += ( f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay/100}₽)" ) logger.info(message) if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_discount_percent = _get_discount_percent( user, promo_group, "devices", period_days=period_hint_days, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month devices_total_cost = discounted_devices_per_month * months_to_pay total_cost += devices_total_cost message = ( f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽" ) if devices_discount_per_month > 0: message += ( f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay/100}₽)" ) logger.info(message) if additional_server_ids: from app.database.models import ServerSquad for server_id in additional_server_ids: result = await db.execute( select(ServerSquad.price_kopeks, ServerSquad.display_name) .where(ServerSquad.id == server_id) ) server_data = result.first() if server_data: server_price_per_month, server_name = server_data servers_discount_percent = _get_discount_percent( user, promo_group, "servers", period_days=period_hint_days, ) server_discount_per_month = server_price_per_month * servers_discount_percent // 100 discounted_server_per_month = server_price_per_month - server_discount_per_month server_total_cost = discounted_server_per_month * months_to_pay total_cost += server_total_cost message = ( f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽" ) if server_discount_per_month > 0: message += ( f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay/100}₽)" ) logger.info(message) logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽") return total_cost async def expire_subscription( db: AsyncSession, subscription: Subscription ) -> Subscription: subscription.status = SubscriptionStatus.EXPIRED.value subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) logger.info(f"⏰ Подписка пользователя {subscription.user_id} помечена как истёкшая") return subscription async def check_and_update_subscription_status( db: AsyncSession, subscription: Subscription ) -> Subscription: current_time = datetime.utcnow() logger.info( "🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s", subscription.id, subscription.status, format_local_datetime(subscription.end_date), format_local_datetime(current_time), ) # Для суточных тарифов с паузой не меняем статус на expired # (время "заморожено" пока пользователь на паузе) is_daily_paused = getattr(subscription, 'is_daily_paused', False) if is_daily_paused: logger.info( f"⏸️ Суточная подписка {subscription.id} на паузе, пропускаем проверку истечения" ) return subscription if (subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time): subscription.status = SubscriptionStatus.EXPIRED.value subscription.updated_at = current_time await db.commit() await db.refresh(subscription) logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'") elif subscription.status == SubscriptionStatus.PENDING.value: logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений") return subscription async def create_subscription_no_commit( db: AsyncSession, user_id: int, status: str = "trial", is_trial: bool = True, end_date: datetime = None, traffic_limit_gb: int = 10, traffic_used_gb: float = 0.0, device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, subscription_url: str = "", subscription_crypto_link: str = "", autopay_enabled: Optional[bool] = None, autopay_days_before: Optional[int] = None, ) -> Subscription: """ Создает подписку без немедленного коммита для пакетной обработки """ if end_date is None: end_date = datetime.utcnow() + timedelta(days=3) if connected_squads is None: connected_squads = [] subscription = Subscription( user_id=user_id, status=status, is_trial=is_trial, end_date=end_date, traffic_limit_gb=traffic_limit_gb, traffic_used_gb=traffic_used_gb, device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, subscription_url=subscription_url, subscription_crypto_link=subscription_crypto_link, autopay_enabled=( settings.is_autopay_enabled_by_default() if autopay_enabled is None else autopay_enabled ), autopay_days_before=( settings.DEFAULT_AUTOPAY_DAYS_BEFORE if autopay_days_before is None else autopay_days_before ), ) db.add(subscription) # Выполняем flush, чтобы получить присвоенный первичный ключ await db.flush() # Не коммитим сразу, оставляем для пакетной обработки logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)") return subscription async def create_subscription( db: AsyncSession, user_id: int, status: str = "trial", is_trial: bool = True, end_date: datetime = None, traffic_limit_gb: int = 10, traffic_used_gb: float = 0.0, device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, subscription_url: str = "", subscription_crypto_link: str = "", autopay_enabled: Optional[bool] = None, autopay_days_before: Optional[int] = None, ) -> Subscription: if end_date is None: end_date = datetime.utcnow() + timedelta(days=3) if connected_squads is None: connected_squads = [] subscription = Subscription( user_id=user_id, status=status, is_trial=is_trial, end_date=end_date, traffic_limit_gb=traffic_limit_gb, traffic_used_gb=traffic_used_gb, device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, subscription_url=subscription_url, subscription_crypto_link=subscription_crypto_link, autopay_enabled=( settings.is_autopay_enabled_by_default() if autopay_enabled is None else autopay_enabled ), autopay_days_before=( settings.DEFAULT_AUTOPAY_DAYS_BEFORE if autopay_days_before is None else autopay_days_before ), ) db.add(subscription) await db.commit() await db.refresh(subscription) logger.info(f"✅ Создана подписка для пользователя {user_id}") return subscription async def create_pending_subscription( db: AsyncSession, user_id: int, duration_days: int, traffic_limit_gb: int = 0, device_limit: int = 1, connected_squads: List[str] = None, payment_method: str = "pending", total_price_kopeks: int = 0, is_trial: bool = False, ) -> Subscription: """Creates a pending subscription that will be activated after payment. Args: is_trial: If True, marks the subscription as a trial subscription. """ trial_label = "триальная " if is_trial else "" current_time = datetime.utcnow() end_date = current_time + timedelta(days=duration_days) existing_subscription = await get_subscription_by_user_id(db, user_id) if existing_subscription: if ( existing_subscription.status == SubscriptionStatus.ACTIVE.value and existing_subscription.end_date > current_time ): logger.warning( "⚠️ Попытка создать pending %sподписку для активного пользователя %s. Возвращаем существующую запись.", trial_label, user_id, ) return existing_subscription existing_subscription.status = SubscriptionStatus.PENDING.value existing_subscription.is_trial = is_trial existing_subscription.start_date = current_time existing_subscription.end_date = end_date existing_subscription.traffic_limit_gb = traffic_limit_gb existing_subscription.device_limit = device_limit existing_subscription.connected_squads = connected_squads or [] existing_subscription.traffic_used_gb = 0.0 existing_subscription.updated_at = current_time await db.commit() await db.refresh(existing_subscription) logger.info( "♻️ Обновлена ожидающая %sподписка пользователя %s, ID: %s, метод оплаты: %s", trial_label, user_id, existing_subscription.id, payment_method, ) return existing_subscription subscription = Subscription( user_id=user_id, status=SubscriptionStatus.PENDING.value, is_trial=is_trial, start_date=current_time, end_date=end_date, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, connected_squads=connected_squads or [], autopay_enabled=settings.is_autopay_enabled_by_default(), autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE, ) db.add(subscription) await db.commit() await db.refresh(subscription) logger.info( "💳 Создана ожидающая %sподписка для пользователя %s, ID: %s, метод оплаты: %s", trial_label, user_id, subscription.id, payment_method, ) return subscription # Обратная совместимость: алиас для триальной подписки async def create_pending_trial_subscription( db: AsyncSession, user_id: int, duration_days: int, traffic_limit_gb: int = 0, device_limit: int = 1, connected_squads: List[str] = None, payment_method: str = "pending", total_price_kopeks: int = 0, ) -> Subscription: """Creates a pending trial subscription. Wrapper for create_pending_subscription with is_trial=True.""" return await create_pending_subscription( db=db, user_id=user_id, duration_days=duration_days, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, connected_squads=connected_squads, payment_method=payment_method, total_price_kopeks=total_price_kopeks, is_trial=True, ) async def activate_pending_subscription( db: AsyncSession, user_id: int, period_days: int = None ) -> Optional[Subscription]: """Активирует pending подписку пользователя, меняя её статус на ACTIVE.""" logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней") # Находим pending подписку пользователя result = await db.execute( select(Subscription) .where( and_( Subscription.user_id == user_id, Subscription.status == SubscriptionStatus.PENDING.value ) ) ) pending_subscription = result.scalar_one_or_none() if not pending_subscription: logger.warning(f"Не найдена pending подписка для пользователя {user_id}") return None logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}") # Обновляем статус подписки на ACTIVE current_time = datetime.utcnow() pending_subscription.status = SubscriptionStatus.ACTIVE.value # Если указан период, обновляем дату окончания if period_days is not None: effective_start = pending_subscription.start_date or current_time if effective_start < current_time: effective_start = current_time pending_subscription.end_date = effective_start + timedelta(days=period_days) # Обновляем дату начала, если она не установлена или в прошлом if not pending_subscription.start_date or pending_subscription.start_date < current_time: pending_subscription.start_date = current_time await db.commit() await db.refresh(pending_subscription) logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}") return pending_subscription async def activate_pending_trial_subscription( db: AsyncSession, subscription_id: int, user_id: int, ) -> Optional[Subscription]: """Активирует pending триальную подписку по её ID после оплаты.""" logger.info(f"Активация pending триальной подписки: subscription_id={subscription_id}, user_id={user_id}") # Находим pending подписку по ID result = await db.execute( select(Subscription) .where( and_( Subscription.id == subscription_id, Subscription.user_id == user_id, Subscription.status == SubscriptionStatus.PENDING.value, Subscription.is_trial == True ) ) ) pending_subscription = result.scalar_one_or_none() if not pending_subscription: logger.warning(f"Не найдена pending триальная подписка {subscription_id} для пользователя {user_id}") return None logger.info(f"Найдена pending триальная подписка {pending_subscription.id}, статус: {pending_subscription.status}") # Обновляем статус подписки на ACTIVE current_time = datetime.utcnow() pending_subscription.status = SubscriptionStatus.ACTIVE.value # Обновляем даты if not pending_subscription.start_date or pending_subscription.start_date < current_time: pending_subscription.start_date = current_time # Пересчитываем end_date на основе duration_days если есть duration_days = pending_subscription.duration_days if hasattr(pending_subscription, 'duration_days') else None if duration_days: pending_subscription.end_date = current_time + timedelta(days=duration_days) elif pending_subscription.end_date and pending_subscription.end_date < current_time: # Если end_date в прошлом, пересчитываем from app.config import settings pending_subscription.end_date = current_time + timedelta(days=settings.TRIAL_DURATION_DAYS) await db.commit() await db.refresh(pending_subscription) logger.info(f"Триальная подписка {pending_subscription.id} активирована для пользователя {user_id}") return pending_subscription # ==================== СУТОЧНЫЕ ПОДПИСКИ ==================== async def get_daily_subscriptions_for_charge(db: AsyncSession) -> List[Subscription]: """ Получает все суточные подписки, которые нужно обработать для списания. Критерии: - Тариф подписки суточный (is_daily=True) - Подписка активна - Подписка не приостановлена пользователем - Прошло более 24 часов с последнего списания (или списания ещё не было) """ from app.database.models import Tariff now = datetime.utcnow() one_day_ago = now - timedelta(hours=24) query = ( select(Subscription) .join(Tariff, Subscription.tariff_id == Tariff.id) .options( selectinload(Subscription.user), selectinload(Subscription.tariff), ) .where( and_( Tariff.is_daily.is_(True), Tariff.is_active.is_(True), Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.is_daily_paused.is_(False), # Списания ещё не было ИЛИ прошло более 24 часов ( (Subscription.last_daily_charge_at.is_(None)) | (Subscription.last_daily_charge_at < one_day_ago) ), ) ) ) result = await db.execute(query) subscriptions = result.scalars().all() logger.info( f"🔍 Найдено {len(subscriptions)} суточных подписок для списания" ) return list(subscriptions) async def pause_daily_subscription( db: AsyncSession, subscription: Subscription, ) -> Subscription: """Приостанавливает суточную подписку (списание не будет происходить).""" if not subscription.is_daily_tariff: logger.warning( f"Попытка приостановить не-суточную подписку {subscription.id}" ) return subscription subscription.is_daily_paused = True await db.commit() await db.refresh(subscription) logger.info( f"⏸️ Суточная подписка {subscription.id} приостановлена пользователем {subscription.user_id}" ) return subscription async def resume_daily_subscription( db: AsyncSession, subscription: Subscription, ) -> Subscription: """Возобновляет суточную подписку (списание продолжится).""" if not subscription.is_daily_tariff: logger.warning( f"Попытка возобновить не-суточную подписку {subscription.id}" ) return subscription subscription.is_daily_paused = False # Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств) if subscription.status == SubscriptionStatus.DISABLED.value: subscription.status = SubscriptionStatus.ACTIVE.value # Обновляем время последнего списания для корректного расчёта следующего subscription.last_daily_charge_at = datetime.utcnow() subscription.end_date = datetime.utcnow() + timedelta(days=1) logger.info( f"✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE" ) await db.commit() await db.refresh(subscription) logger.info( f"▶️ Суточная подписка {subscription.id} возобновлена пользователем {subscription.user_id}" ) return subscription async def update_daily_charge_time( db: AsyncSession, subscription: Subscription, charge_time: datetime = None, ) -> Subscription: """Обновляет время последнего суточного списания и продлевает подписку на 1 день.""" now = charge_time or datetime.utcnow() subscription.last_daily_charge_at = now # Продлеваем подписку на 1 день от текущего момента new_end_date = now + timedelta(days=1) if subscription.end_date is None or subscription.end_date < new_end_date: subscription.end_date = new_end_date logger.info(f"📅 Продлена подписка {subscription.id} до {new_end_date}") await db.commit() await db.refresh(subscription) return subscription async def suspend_daily_subscription_insufficient_balance( db: AsyncSession, subscription: Subscription, ) -> Subscription: """ Приостанавливает подписку из-за недостатка баланса. Отличается от pause_daily_subscription тем, что меняет статус на DISABLED. """ subscription.status = SubscriptionStatus.DISABLED.value await db.commit() await db.refresh(subscription) logger.info( f"⚠️ Суточная подписка {subscription.id} приостановлена: недостаточно средств (user_id={subscription.user_id})" ) return subscription async def get_subscription_with_tariff( db: AsyncSession, user_id: int, ) -> Optional[Subscription]: """Получает подписку пользователя с загруженным тарифом.""" result = await db.execute( select(Subscription) .options( selectinload(Subscription.user), selectinload(Subscription.tariff), ) .where(Subscription.user_id == user_id) .order_by(Subscription.created_at.desc()) .limit(1) ) subscription = result.scalar_one_or_none() if subscription: subscription = await check_and_update_subscription_status(db, subscription) return subscription async def toggle_daily_subscription_pause( db: AsyncSession, subscription: Subscription, ) -> Subscription: """Переключает состояние паузы суточной подписки.""" if subscription.is_daily_paused: return await resume_daily_subscription(db, subscription) else: return await pause_daily_subscription(db, subscription)