mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-23 12:53:41 +00:00
When a user is deleted from the panel, the subscription may already be cascade-deleted by the time the webhook handler tries to decrement server counters. This caused StaleDataError followed by PendingRollbackError when accessing subscription.id in the error handler. - Save subscription.id before DB operations to avoid lazy load after rollback - Catch StaleDataError explicitly and rollback the session - Re-fetch subscription/user after potential rollback in _handle_user_deleted - Skip subscription cleanup if it was already cascade-deleted
2072 lines
85 KiB
Python
2072 lines
85 KiB
Python
import logging
|
||
from collections.abc import Iterable
|
||
from datetime import UTC, 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 sqlalchemy.orm.exc import StaleDataError
|
||
|
||
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__)
|
||
|
||
_WEBHOOK_GUARD_SECONDS = 60
|
||
|
||
|
||
def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
|
||
"""Return True if subscription was updated by webhook within guard window."""
|
||
if not subscription.last_webhook_update_at:
|
||
return False
|
||
elapsed = (datetime.now(UTC).replace(tzinfo=None) - subscription.last_webhook_update_at).total_seconds()
|
||
return elapsed < _WEBHOOK_GUARD_SECONDS
|
||
|
||
|
||
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)
|
||
|
||
# Check for existing PENDING trial subscription (retry after failed payment)
|
||
existing = await get_subscription_by_user_id(db, user_id)
|
||
if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value:
|
||
existing.status = SubscriptionStatus.ACTIVE.value
|
||
existing.start_date = datetime.utcnow()
|
||
existing.end_date = end_date
|
||
existing.traffic_limit_gb = traffic_limit_gb
|
||
existing.device_limit = device_limit
|
||
existing.connected_squads = final_squads
|
||
existing.tariff_id = tariff_id
|
||
await db.commit()
|
||
await db.refresh(existing)
|
||
logger.info(
|
||
'🎁 Обновлена PENDING триальная подписка %s для пользователя %s',
|
||
existing.id,
|
||
user_id,
|
||
)
|
||
return existing
|
||
|
||
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}'
|
||
)
|
||
|
||
# Определяем, происходит ли СМЕНА тарифа (а не продление того же)
|
||
# Включает переход из классического режима (tariff_id=None) в тарифный
|
||
is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or 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_used_gb = 0.0
|
||
|
||
if is_tariff_change:
|
||
# При СМЕНЕ тарифа сбрасываем все докупки трафика
|
||
subscription.traffic_limit_gb = traffic_limit_gb
|
||
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} ГБ (смена тарифа, докупки сброшены)'
|
||
)
|
||
else:
|
||
# При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик
|
||
purchased = subscription.purchased_traffic_gb or 0
|
||
subscription.traffic_limit_gb = traffic_limit_gb + purchased
|
||
logger.info(
|
||
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)'
|
||
)
|
||
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
|
||
|
||
# Save ID before any DB operations that might invalidate the ORM object
|
||
sub_id = subscription.id
|
||
|
||
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, sub_id)
|
||
server_ids.update(ids_from_links)
|
||
except Exception as error:
|
||
logger.error(
|
||
'⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s',
|
||
sub_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',
|
||
sub_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 StaleDataError:
|
||
logger.warning(
|
||
'⚠️ Подписка %s уже удалена (StaleDataError), пропускаем декремент серверов %s',
|
||
sub_id,
|
||
list(server_ids),
|
||
)
|
||
await db.rollback()
|
||
except Exception as error:
|
||
logger.error(
|
||
'⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s',
|
||
list(server_ids),
|
||
sub_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),
|
||
selectinload(Subscription.tariff),
|
||
)
|
||
.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:
|
||
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
|
||
# глобальный autopay на них не распространяется
|
||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||
continue
|
||
|
||
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)
|