import logging from collections.abc import Iterable from datetime import datetime, timedelta from typing import Optional from sqlalchemy import and_, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings from app.database.crud.notification import clear_notifications from app.database.models import ( PromoGroup, Subscription, SubscriptionServer, SubscriptionStatus, User, UserPromoGroup, ) from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months from app.utils.timezone import format_local_datetime logger = logging.getLogger(__name__) async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None: 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: int | None = None, squad_uuid: str = None, connected_squads: list[str] = None, tariff_id: int | None = 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 ( add_user_to_servers, get_server_ids_by_uuids, ) 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: int | None = None, connected_squads: list[str] = None, update_server_counters: bool = False, is_trial: bool = False, tariff_id: int | None = 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 ( add_user_to_servers, get_server_ids_by_uuids, ) 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: bool | None = None, autopay_days_before: int | None = 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) # Очищаем старые записи об отправленных уведомлениях при замене подписки # (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно await clear_notifications(db, subscription.id) 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: int | None = None, traffic_limit_gb: int | None = None, device_limit: int | None = None, connected_squads: list[str] | None = 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 sqlalchemy import delete as sql_delete from app.database.models import TrafficPurchase 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('🔄 Переход на суточный тариф: сброшены daily флаги') elif old_was_daily: # Переход с суточного на обычный тариф - очищаем daily поля subscription.is_daily_paused = False subscription.last_daily_charge_at = None logger.info('🔄 Переход с суточного тарифа: очищены 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 datetime import timedelta from sqlalchemy import select as sql_select from app.database.models import TrafficPurchase 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: Subscription | None, *, subscription_servers: Iterable[SubscriptionServer] | None = 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('📊 Статистика конверсии из таблицы 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], *, user: Optional['User'] = None, ) -> list[int]: """Получает месячные цены серверов с проверкой доступности для промогруппы пользователя.""" from sqlalchemy.orm import selectinload from app.database.models import ServerSquad prices = [] # Загружаем промогруппы пользователя если нужно user_promo_group = None user_promo_group_id = None if user: try: # Пробуем загрузить промогруппы если ещё не загружены await db.refresh(user, ['user_promo_groups', 'promo_group']) except Exception: pass try: user_promo_group = user.get_primary_promo_group() user_promo_group_id = user_promo_group.id if user_promo_group else None except Exception as e: logger.warning(f'Не удалось получить промогруппу пользователя: {e}') for server_id in server_squad_ids: # Загружаем сервер с промогруппами result = await db.execute( select(ServerSquad) .options(selectinload(ServerSquad.allowed_promo_groups)) .where(ServerSquad.id == server_id) ) server = result.scalar_one_or_none() if not server: prices.append(0) continue # Проверяем доступность сервера для промогруппы пользователя is_allowed = True if user_promo_group_id is not None and server.allowed_promo_groups: allowed_ids = {pg.id for pg in server.allowed_promo_groups} is_allowed = user_promo_group_id in allowed_ids if server.is_available and is_allowed: prices.append(server.price_kopeks) else: # Сервер недоступен для промогруппы пользователя logger.warning( f'⚠️ Сервер {server.display_name} (id={server_id}) недоступен для ' f'промогруппы пользователя (promo_group_id={user_promo_group_id}), ' f'allowed_promo_groups={[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else []}' ) prices.append(server.price_kopeks) # Всё равно берём реальную цену return prices def _get_discount_percent( user: User | None, promo_group: PromoGroup | None, category: str, *, period_days: int | None = 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: User | None = None, promo_group: PromoGroup | None = 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, user=user) 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 sqlalchemy import delete from app.database.models import SubscriptionServer 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: User | None = None, promo_group: PromoGroup | None = 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: User | None = None, promo_group: PromoGroup | None = 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: # Детальное логирование для отладки проблемы с деактивацией time_diff = current_time - subscription.end_date logger.warning( f'⏰ DEACTIVATION: подписка {subscription.id} (user_id={subscription.user_id}) ' f'деактивируется в check_and_update_subscription_status. ' f'end_date={subscription.end_date}, current_time={current_time}, ' f'просрочена на {time_diff}' ) 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: bool | None = None, autopay_days_before: int | None = 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: bool | None = None, autopay_days_before: int | None = 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) -> Subscription | None: """Активирует 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 effective_start = max(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, ) -> Subscription | None: """Активирует 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), Subscription.is_trial.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 get_disabled_daily_subscriptions_for_resume( db: AsyncSession, ) -> list[Subscription]: """ Получает список DISABLED суточных подписок, которые можно возобновить. Подписки с достаточным балансом пользователя будут возобновлены. """ from app.database.models import Tariff, User query = ( select(Subscription) .join(Tariff, Subscription.tariff_id == Tariff.id) .join(User, Subscription.user_id == User.id) .options( selectinload(Subscription.user), selectinload(Subscription.tariff), ) .where( and_( Tariff.is_daily.is_(True), Tariff.is_active.is_(True), Subscription.status == SubscriptionStatus.DISABLED.value, Subscription.is_trial.is_(False), # Баланс пользователя >= суточной цены тарифа User.balance_kopeks >= Tariff.daily_price_kopeks, ) ) ) result = await db.execute(query) subscriptions = result.scalars().all() logger.info(f'🔍 Найдено {len(subscriptions)} DISABLED суточных подписок для возобновления') 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, ) -> Subscription | None: """Получает подписку пользователя с загруженным тарифом.""" 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) return await pause_daily_subscription(db, subscription)