diff --git a/.env.example b/.env.example index 7d8db259..0ffbda5c 100644 --- a/.env.example +++ b/.env.example @@ -131,10 +131,21 @@ REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}" REMNAWAVE_USER_DELETE_MODE=delete # ========= ПОДПИСКИ ========= + +# ===== РЕЖИМ ПРОДАЖ ===== +# Режим продаж подписок: +# "classic" - классический режим (выбор серверов, трафика, устройств, периода отдельно) +# "tariffs" - режим тарифов (готовые пакеты с фиксированными параметрами) +SALES_MODE=classic + # ===== ТРИАЛ ПОДПИСКА ===== TRIAL_DURATION_DAYS=3 TRIAL_TRAFFIC_LIMIT_GB=10 TRIAL_DEVICE_LIMIT=1 +# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала) +# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads) +# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS +TRIAL_TARIFF_ID=0 # Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным # Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты. # TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости) diff --git a/app/bot.py b/app/bot.py index b663356a..b323cdc7 100644 --- a/app/bot.py +++ b/app/bot.py @@ -65,6 +65,7 @@ from app.handlers.admin import ( faq as admin_faq, payments as admin_payments, trials as admin_trials, + tariffs as admin_tariffs, ) from app.handlers import contests as user_contests from app.handlers.stars_payments import register_stars_handlers @@ -190,6 +191,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_faq.register_handlers(dp) admin_payments.register_handlers(dp) admin_trials.register_handlers(dp) + admin_tariffs.register_handlers(dp) admin_bulk_ban.register_bulk_ban_handlers(dp) admin_blacklist.register_blacklist_handlers(dp) common.register_handlers(dp) diff --git a/app/config.py b/app/config.py index 1f0b8d0f..5a8221c6 100644 --- a/app/config.py +++ b/app/config.py @@ -166,7 +166,17 @@ class Settings(BaseSettings): TRAFFIC_SELECTION_MODE: str = "selectable" FIXED_TRAFFIC_LIMIT_GB: int = 100 BUY_TRAFFIC_BUTTON_VISIBLE: bool = True - + + # Режим продаж подписок: + # - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно) + # - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами) + SALES_MODE: str = "classic" + + # ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала) + # Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads) + # Длительность триала всё равно берётся из TRIAL_DURATION_DAYS + TRIAL_TARIFF_ID: int = 0 + # Настройки докупки трафика TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика # Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG) @@ -1191,6 +1201,22 @@ class Settings(BaseSettings): def is_modem_enabled(self) -> bool: return bool(self.MODEM_ENABLED) + def is_tariffs_mode(self) -> bool: + """Проверяет, включен ли режим продаж 'Тарифы'.""" + return self.SALES_MODE == "tariffs" + + def is_classic_mode(self) -> bool: + """Проверяет, включен ли классический режим продаж.""" + return self.SALES_MODE != "tariffs" + + def get_sales_mode(self) -> str: + """Возвращает текущий режим продаж.""" + return self.SALES_MODE if self.SALES_MODE in ("classic", "tariffs") else "classic" + + def get_trial_tariff_id(self) -> int: + """Возвращает ID тарифа для триала (0 = использовать стандартные настройки).""" + return self.TRIAL_TARIFF_ID if self.TRIAL_TARIFF_ID > 0 else 0 + def get_modem_price_per_month(self) -> int: try: value = int(self.MODEM_PRICE_PER_MONTH) @@ -1248,11 +1274,12 @@ class Settings(BaseSettings): return applicable_discount def is_trial_paid_activation_enabled(self) -> bool: - # Если цена > 0, триал автоматически платный - # (TRIAL_PAYMENT_ENABLED теперь опционален - для обратной совместимости) - if self.TRIAL_ACTIVATION_PRICE > 0: - return True - return bool(self.TRIAL_PAYMENT_ENABLED) + # TRIAL_PAYMENT_ENABLED - главный переключатель платной активации + # Если выключен - триал бесплатный, независимо от цены + if not self.TRIAL_PAYMENT_ENABLED: + return False + # Если включен - проверяем что цена > 0 + return self.TRIAL_ACTIVATION_PRICE > 0 def get_trial_activation_price(self) -> int: try: diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 3669895d..efe663bc 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -44,23 +44,38 @@ async def create_trial_subscription( duration_days: int = None, traffic_limit_gb: int = None, device_limit: Optional[int] = None, - squad_uuid: str = None + squad_uuid: str = None, + connected_squads: List[str] = None, + tariff_id: Optional[int] = None, ) -> Subscription: - + """Создает триальную подписку. + + Args: + connected_squads: Список UUID сквадов (если указан, squad_uuid игнорируется) + tariff_id: ID тарифа (для режима тарифов) + """ duration_days = duration_days or settings.TRIAL_DURATION_DAYS traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB if device_limit is None: device_limit = settings.TRIAL_DEVICE_LIMIT - if not squad_uuid: + + # Если переданы 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 - squad_uuid = await get_random_trial_squad_uuid(db) - - if squad_uuid: + random_squad = await get_random_trial_squad_uuid(db) + if random_squad: + final_squads = [random_squad] logger.debug( "Выбран сквад %s для триальной подписки пользователя %s", - squad_uuid, + random_squad, user_id, ) except Exception as error: @@ -80,40 +95,42 @@ async def create_trial_subscription( end_date=end_date, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, - connected_squads=[squad_uuid] if squad_uuid else [], + 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}") + logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}" + + (f" с тарифом {tariff_id}" if tariff_id else "")) - if squad_uuid: + if final_squads: try: from app.database.crud.server_squad import ( get_server_ids_by_uuids, add_user_to_servers, ) - server_ids = await get_server_ids_by_uuids(db, [squad_uuid]) + 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", - squad_uuid, + "📈 Обновлен счетчик пользователей для триальных сквадов %s", + final_squads, ) else: logger.warning( - "⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)", - squad_uuid, + "⚠️ Не удалось найти серверы для обновления счетчика (сквады %s)", + final_squads, ) except Exception as error: logger.error( - "⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s", - squad_uuid, + "⚠️ Ошибка обновления счетчика пользователей для триальных сквадов %s: %s", + final_squads, error, ) @@ -129,6 +146,7 @@ async def create_paid_subscription( connected_squads: List[str] = None, update_server_counters: bool = False, is_trial: bool = False, + tariff_id: Optional[int] = None, ) -> Subscription: end_date = datetime.utcnow() + timedelta(days=duration_days) @@ -147,6 +165,7 @@ async def create_paid_subscription( 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) @@ -276,8 +295,24 @@ async def replace_subscription( async def extend_subscription( db: AsyncSession, subscription: Subscription, - days: int + days: int, + *, + tariff_id: Optional[int] = None, + traffic_limit_gb: Optional[int] = None, + device_limit: Optional[int] = None, + connected_squads: Optional[List[str]] = None, ) -> Subscription: + """Продлевает подписку на указанное количество дней. + + Args: + db: Сессия базы данных + subscription: Подписка для продления + days: Количество дней для продления + tariff_id: ID тарифа (опционально, для режима тарифов) + traffic_limit_gb: Лимит трафика ГБ (опционально, для режима тарифов) + device_limit: Лимит устройств (опционально, для режима тарифов) + connected_squads: Список UUID сквадов (опционально, для режима тарифов) + """ current_time = datetime.utcnow() logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней") @@ -320,7 +355,7 @@ async def extend_subscription( # Логируем статус подписки перед проверкой logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}") - + if days > 0 and subscription.status in ( SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value, @@ -339,13 +374,36 @@ async def extend_subscription( days ) - if settings.RESET_TRAFFIC_ON_PAYMENT: + # Обновляем параметры тарифа, если переданы + 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 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 - subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным + subscription.purchased_traffic_gb = 0 + logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ") + elif settings.RESET_TRAFFIC_ON_PAYMENT: + subscription.traffic_used_gb = 0.0 + subscription.purchased_traffic_gb = 0 logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT") + 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}") + # В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита - if settings.is_traffic_fixed() and days > 0: + # Только если не передан traffic_limit_gb (т.е. не режим тарифов) + if traffic_limit_gb is None and settings.is_traffic_fixed() and days > 0: 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: diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py new file mode 100644 index 00000000..2ade37cc --- /dev/null +++ b/app/database/crud/tariff.py @@ -0,0 +1,401 @@ +import logging +from typing import Dict, List, Optional + +from sqlalchemy import func, select, update, delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import Tariff, Subscription, PromoGroup, tariff_promo_groups + + +logger = logging.getLogger(__name__) + + +def _normalize_period_prices(period_prices: Optional[Dict[int, int]]) -> Dict[str, int]: + """Нормализует цены периодов в формат {str: int}.""" + if not period_prices: + return {} + + normalized: Dict[str, int] = {} + + for key, value in period_prices.items(): + try: + period = int(key) + price = int(value) + except (TypeError, ValueError): + continue + + if period > 0 and price >= 0: + normalized[str(period)] = price + + return normalized + + +async def get_all_tariffs( + db: AsyncSession, + *, + include_inactive: bool = False, + offset: int = 0, + limit: Optional[int] = None, +) -> List[Tariff]: + """Получает все тарифы с опциональной фильтрацией по активности.""" + query = select(Tariff).options(selectinload(Tariff.allowed_promo_groups)) + + if not include_inactive: + query = query.where(Tariff.is_active.is_(True)) + + query = query.order_by(Tariff.display_order, Tariff.id) + + if offset: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) + + result = await db.execute(query) + return result.scalars().all() + + +async def get_tariff_by_id( + db: AsyncSession, + tariff_id: int, + *, + with_promo_groups: bool = True, +) -> Optional[Tariff]: + """Получает тариф по ID.""" + query = select(Tariff).where(Tariff.id == tariff_id) + + if with_promo_groups: + query = query.options(selectinload(Tariff.allowed_promo_groups)) + + result = await db.execute(query) + return result.scalars().first() + + +async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) -> int: + """Подсчитывает количество тарифов.""" + query = select(func.count(Tariff.id)) + + if not include_inactive: + query = query.where(Tariff.is_active.is_(True)) + + result = await db.execute(query) + return int(result.scalar_one()) + + +async def get_trial_tariff(db: AsyncSession) -> Optional[Tariff]: + """Получает тариф, доступный для триала (is_trial_available=True).""" + query = ( + select(Tariff) + .where(Tariff.is_trial_available.is_(True)) + .where(Tariff.is_active.is_(True)) + .options(selectinload(Tariff.allowed_promo_groups)) + .limit(1) + ) + result = await db.execute(query) + return result.scalars().first() + + +async def set_trial_tariff(db: AsyncSession, tariff_id: int) -> Optional[Tariff]: + """Устанавливает тариф как триальный (снимает флаг с других тарифов).""" + # Снимаем флаг с всех тарифов + await db.execute( + Tariff.__table__.update().values(is_trial_available=False) + ) + + # Устанавливаем флаг на выбранный тариф + tariff = await get_tariff_by_id(db, tariff_id) + if tariff: + tariff.is_trial_available = True + await db.commit() + await db.refresh(tariff) + + return tariff + + +async def clear_trial_tariff(db: AsyncSession) -> None: + """Снимает флаг триала со всех тарифов.""" + await db.execute( + Tariff.__table__.update().values(is_trial_available=False) + ) + await db.commit() + + +async def get_tariffs_for_user( + db: AsyncSession, + promo_group_id: Optional[int] = None, +) -> List[Tariff]: + """ + Получает тарифы, доступные для пользователя с учетом его промогруппы. + Если у тарифа нет ограничений по промогруппам - он доступен всем. + """ + query = ( + select(Tariff) + .options(selectinload(Tariff.allowed_promo_groups)) + .where(Tariff.is_active.is_(True)) + .order_by(Tariff.display_order, Tariff.id) + ) + + result = await db.execute(query) + tariffs = result.scalars().all() + + # Фильтруем по промогруппе + available_tariffs = [] + for tariff in tariffs: + if not tariff.allowed_promo_groups: + # Нет ограничений - доступен всем + available_tariffs.append(tariff) + elif promo_group_id is not None: + # Проверяем, есть ли промогруппа пользователя в списке разрешенных + if any(pg.id == promo_group_id for pg in tariff.allowed_promo_groups): + available_tariffs.append(tariff) + # else: пользователь без промогруппы, а у тарифа есть ограничения - пропускаем + + return available_tariffs + + +async def create_tariff( + db: AsyncSession, + name: str, + *, + description: Optional[str] = None, + display_order: int = 0, + is_active: bool = True, + traffic_limit_gb: int = 100, + device_limit: int = 1, + allowed_squads: Optional[List[str]] = None, + period_prices: Optional[Dict[int, int]] = None, + tier_level: int = 1, + is_trial_available: bool = False, + promo_group_ids: Optional[List[int]] = None, +) -> Tariff: + """Создает новый тариф.""" + normalized_prices = _normalize_period_prices(period_prices) + + tariff = Tariff( + name=name.strip(), + description=description.strip() if description else None, + display_order=max(0, display_order), + is_active=is_active, + traffic_limit_gb=max(0, traffic_limit_gb), + device_limit=max(1, device_limit), + allowed_squads=allowed_squads or [], + period_prices=normalized_prices, + tier_level=max(1, tier_level), + is_trial_available=is_trial_available, + ) + + db.add(tariff) + await db.flush() + + # Добавляем промогруппы если указаны + if promo_group_ids: + promo_groups_result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)) + ) + promo_groups = promo_groups_result.scalars().all() + tariff.allowed_promo_groups = list(promo_groups) + + await db.commit() + await db.refresh(tariff) + + logger.info( + "Создан тариф '%s' (id=%s, tier=%s, traffic=%sGB, devices=%s, prices=%s)", + tariff.name, + tariff.id, + tariff.tier_level, + tariff.traffic_limit_gb, + tariff.device_limit, + normalized_prices, + ) + + return tariff + + +async def update_tariff( + db: AsyncSession, + tariff: Tariff, + *, + name: Optional[str] = None, + description: Optional[str] = None, + display_order: Optional[int] = None, + is_active: Optional[bool] = None, + traffic_limit_gb: Optional[int] = None, + device_limit: Optional[int] = None, + device_price_kopeks: Optional[int] = ..., # ... = не передан, None = сбросить + allowed_squads: Optional[List[str]] = None, + period_prices: Optional[Dict[int, int]] = None, + tier_level: Optional[int] = None, + is_trial_available: Optional[bool] = None, + promo_group_ids: Optional[List[int]] = None, +) -> Tariff: + """Обновляет существующий тариф.""" + if name is not None: + tariff.name = name.strip() + if description is not None: + tariff.description = description.strip() if description else None + if display_order is not None: + tariff.display_order = max(0, display_order) + if is_active is not None: + tariff.is_active = is_active + if traffic_limit_gb is not None: + tariff.traffic_limit_gb = max(0, traffic_limit_gb) + if device_limit is not None: + tariff.device_limit = max(1, device_limit) + if device_price_kopeks is not ...: + # Если передан device_price_kopeks (включая None) - обновляем + tariff.device_price_kopeks = device_price_kopeks + if allowed_squads is not None: + tariff.allowed_squads = allowed_squads + if period_prices is not None: + tariff.period_prices = _normalize_period_prices(period_prices) + if tier_level is not None: + tariff.tier_level = max(1, tier_level) + if is_trial_available is not None: + tariff.is_trial_available = is_trial_available + + # Обновляем промогруппы если указаны + if promo_group_ids is not None: + if promo_group_ids: + promo_groups_result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)) + ) + promo_groups = promo_groups_result.scalars().all() + tariff.allowed_promo_groups = list(promo_groups) + else: + tariff.allowed_promo_groups = [] + + await db.commit() + await db.refresh(tariff) + + logger.info( + "Обновлен тариф '%s' (id=%s)", + tariff.name, + tariff.id, + ) + + return tariff + + +async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool: + """ + Удаляет тариф. + Подписки с этим тарифом получат tariff_id = NULL. + """ + tariff_id = tariff.id + tariff_name = tariff.name + + # Подсчитываем подписки с этим тарифом + subscriptions_count = await db.execute( + select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id) + ) + affected_subscriptions = subscriptions_count.scalar_one() + + # Удаляем тариф (FK с ondelete=SET NULL автоматически обнулит tariff_id в подписках) + await db.delete(tariff) + await db.commit() + + logger.info( + "Удален тариф '%s' (id=%s), затронуто подписок: %s", + tariff_name, + tariff_id, + affected_subscriptions, + ) + + return True + + +async def get_tariff_subscriptions_count(db: AsyncSession, tariff_id: int) -> int: + """Подсчитывает количество подписок на тарифе.""" + result = await db.execute( + select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id) + ) + return int(result.scalar_one()) + + +async def set_tariff_promo_groups( + db: AsyncSession, + tariff: Tariff, + promo_group_ids: List[int], +) -> Tariff: + """Устанавливает промогруппы для тарифа.""" + if promo_group_ids: + promo_groups_result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)) + ) + promo_groups = promo_groups_result.scalars().all() + tariff.allowed_promo_groups = list(promo_groups) + else: + tariff.allowed_promo_groups = [] + + await db.commit() + await db.refresh(tariff) + + return tariff + + +async def add_promo_group_to_tariff( + db: AsyncSession, + tariff: Tariff, + promo_group_id: int, +) -> bool: + """Добавляет промогруппу к тарифу.""" + promo_group = await db.get(PromoGroup, promo_group_id) + if not promo_group: + return False + + if promo_group not in tariff.allowed_promo_groups: + tariff.allowed_promo_groups.append(promo_group) + await db.commit() + + return True + + +async def remove_promo_group_from_tariff( + db: AsyncSession, + tariff: Tariff, + promo_group_id: int, +) -> bool: + """Удаляет промогруппу из тарифа.""" + for pg in tariff.allowed_promo_groups: + if pg.id == promo_group_id: + tariff.allowed_promo_groups.remove(pg) + await db.commit() + return True + return False + + +async def get_tariffs_with_subscriptions_count( + db: AsyncSession, + *, + include_inactive: bool = False, +) -> List[tuple]: + """Получает тарифы с количеством подписок.""" + query = ( + select(Tariff, func.count(Subscription.id)) + .outerjoin(Subscription, Subscription.tariff_id == Tariff.id) + .group_by(Tariff.id) + .order_by(Tariff.display_order, Tariff.id) + ) + + if not include_inactive: + query = query.where(Tariff.is_active.is_(True)) + + result = await db.execute(query) + return result.all() + + +async def reorder_tariffs( + db: AsyncSession, + tariff_order: List[int], +) -> None: + """Изменяет порядок отображения тарифов.""" + for order, tariff_id in enumerate(tariff_order): + await db.execute( + update(Tariff) + .where(Tariff.id == tariff_id) + .values(display_order=order) + ) + + await db.commit() + + logger.info("Изменен порядок тарифов: %s", tariff_order) diff --git a/app/database/models.py b/app/database/models.py index 2cfbf892..585b2c9a 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -46,6 +46,25 @@ server_squad_promo_groups = Table( ) +# M2M таблица для связи тарифов с промогруппами (доступ к тарифу) +tariff_promo_groups = Table( + "tariff_promo_groups", + Base.metadata, + Column( + "tariff_id", + Integer, + ForeignKey("tariffs.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "promo_group_id", + Integer, + ForeignKey("promo_groups.id", ondelete="CASCADE"), + primary_key=True, + ), +) + + class UserStatus(Enum): ACTIVE = "active" BLOCKED = "blocked" @@ -714,6 +733,82 @@ class UserPromoGroup(Base): return f"" +class Tariff(Base): + """Тарифный план для режима продаж 'Тарифы'.""" + __tablename__ = "tariffs" + + id = Column(Integer, primary_key=True, index=True) + + # Основная информация + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + display_order = Column(Integer, default=0, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + + # Параметры тарифа + traffic_limit_gb = Column(Integer, nullable=False, default=100) # 0 = безлимит + device_limit = Column(Integer, nullable=False, default=1) + device_price_kopeks = Column(Integer, nullable=True, default=None) # Цена за доп. устройство (None = нельзя докупить) + + # Сквады (серверы) доступные в тарифе + allowed_squads = Column(JSON, default=list) # список UUID сквадов + + # Цены на периоды в копейках (JSON: {"14": 30000, "30": 50000, "90": 120000, ...}) + period_prices = Column(JSON, nullable=False, default=dict) + + # Уровень тарифа (для визуального отображения, 1 = базовый) + tier_level = Column(Integer, default=1, nullable=False) + + # Дополнительные настройки + is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + # M2M связь с промогруппами (какие промогруппы имеют доступ к тарифу) + allowed_promo_groups = relationship( + "PromoGroup", + secondary=tariff_promo_groups, + lazy="selectin", + ) + + # Подписки на этом тарифе + subscriptions = relationship("Subscription", back_populates="tariff") + + @property + def is_unlimited_traffic(self) -> bool: + """Проверяет, безлимитный ли трафик.""" + return self.traffic_limit_gb == 0 + + def get_price_for_period(self, period_days: int) -> Optional[int]: + """Возвращает цену в копейках для указанного периода.""" + prices = self.period_prices or {} + return prices.get(str(period_days)) + + def get_available_periods(self) -> List[int]: + """Возвращает список доступных периодов в днях.""" + prices = self.period_prices or {} + return sorted([int(p) for p in prices.keys()]) + + def get_price_rubles(self, period_days: int) -> Optional[float]: + """Возвращает цену в рублях для указанного периода.""" + price_kopeks = self.get_price_for_period(period_days) + if price_kopeks is not None: + return price_kopeks / 100 + return None + + def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> bool: + """Проверяет, доступен ли тариф для указанной промогруппы.""" + if not self.allowed_promo_groups: + return True # Если нет ограничений - доступен всем + if promo_group_id is None: + return True # Если у пользователя нет группы - доступен + return any(pg.id == promo_group_id for pg in self.allowed_promo_groups) + + def __repr__(self): + return f"" + + class User(Base): __tablename__ = "users" @@ -860,10 +955,14 @@ class Subscription(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - + remnawave_short_uuid = Column(String(255), nullable=True) + # Тариф (для режима продаж "Тарифы") + tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True, index=True) + user = relationship("User", back_populates="subscription") + tariff = relationship("Tariff", back_populates="subscriptions") discount_offers = relationship("DiscountOffer", back_populates="subscription") temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription") @@ -2108,4 +2207,4 @@ class CabinetRefreshToken(Base): def __repr__(self) -> str: status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired") - return f"" + return f"" \ No newline at end of file diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index cee17cfa..e6f5b231 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5049,6 +5049,203 @@ async def add_transaction_receipt_columns() -> bool: return False +# ============================================================================= +# МИГРАЦИИ ДЛЯ РЕЖИМА ТАРИФОВ +# ============================================================================= + +async def create_tariffs_table() -> bool: + """Создаёт таблицу тарифов для режима продаж 'Тарифы'.""" + try: + if await check_table_exists('tariffs'): + logger.info("ℹ️ Таблица tariffs уже существует") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text(""" + CREATE TABLE tariffs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + description TEXT, + display_order INTEGER DEFAULT 0 NOT NULL, + is_active BOOLEAN DEFAULT 1 NOT NULL, + traffic_limit_gb INTEGER DEFAULT 100 NOT NULL, + device_limit INTEGER DEFAULT 1 NOT NULL, + allowed_squads JSON DEFAULT '[]', + period_prices JSON DEFAULT '{}' NOT NULL, + tier_level INTEGER DEFAULT 1 NOT NULL, + is_trial_available BOOLEAN DEFAULT 0 NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """)) + elif db_type == 'postgresql': + await conn.execute(text(""" + CREATE TABLE tariffs ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + display_order INTEGER DEFAULT 0 NOT NULL, + is_active BOOLEAN DEFAULT TRUE NOT NULL, + traffic_limit_gb INTEGER DEFAULT 100 NOT NULL, + device_limit INTEGER DEFAULT 1 NOT NULL, + allowed_squads JSON DEFAULT '[]', + period_prices JSON DEFAULT '{}' NOT NULL, + tier_level INTEGER DEFAULT 1 NOT NULL, + is_trial_available BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + """)) + else: # MySQL + await conn.execute(text(""" + CREATE TABLE tariffs ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + display_order INT DEFAULT 0 NOT NULL, + is_active BOOLEAN DEFAULT TRUE NOT NULL, + traffic_limit_gb INT DEFAULT 100 NOT NULL, + device_limit INT DEFAULT 1 NOT NULL, + allowed_squads JSON DEFAULT (JSON_ARRAY()), + period_prices JSON NOT NULL, + tier_level INT DEFAULT 1 NOT NULL, + is_trial_available BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """)) + + logger.info("✅ Таблица tariffs создана") + return True + + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы tariffs: {error}") + return False + + +async def create_tariff_promo_groups_table() -> bool: + """Создаёт связующую таблицу tariff_promo_groups для M2M связи тарифов и промогрупп.""" + try: + if await check_table_exists('tariff_promo_groups'): + logger.info("ℹ️ Таблица tariff_promo_groups уже существует") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text(""" + CREATE TABLE tariff_promo_groups ( + tariff_id INTEGER NOT NULL, + promo_group_id INTEGER NOT NULL, + PRIMARY KEY (tariff_id, promo_group_id), + FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ) + """)) + elif db_type == 'postgresql': + await conn.execute(text(""" + CREATE TABLE tariff_promo_groups ( + tariff_id INTEGER NOT NULL REFERENCES tariffs(id) ON DELETE CASCADE, + promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE, + PRIMARY KEY (tariff_id, promo_group_id) + ) + """)) + else: # MySQL + await conn.execute(text(""" + CREATE TABLE tariff_promo_groups ( + tariff_id INT NOT NULL, + promo_group_id INT NOT NULL, + PRIMARY KEY (tariff_id, promo_group_id), + FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ) + """)) + + logger.info("✅ Таблица tariff_promo_groups создана") + return True + + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы tariff_promo_groups: {error}") + return False + + +async def add_subscription_tariff_id_column() -> bool: + """Добавляет колонку tariff_id в таблицу subscriptions.""" + try: + if await check_column_exists('subscriptions', 'tariff_id'): + logger.info("ℹ️ Колонка tariff_id уже существует в subscriptions") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id)" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id) ON DELETE SET NULL" + )) + # Создаём индекс + await conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_id ON subscriptions(tariff_id)" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN tariff_id INT NULL" + )) + await conn.execute(text( + "ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_tariff " + "FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE SET NULL" + )) + await conn.execute(text( + "CREATE INDEX ix_subscriptions_tariff_id ON subscriptions(tariff_id)" + )) + + logger.info("✅ Колонка tariff_id добавлена в subscriptions") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонки tariff_id: {error}") + return False + + +async def add_tariff_device_price_column() -> bool: + """Добавляет колонку device_price_kopeks в таблицу tariffs.""" + try: + if await check_column_exists('tariffs', 'device_price_kopeks'): + logger.info("ℹ️ Колонка device_price_kopeks уже существует в tariffs") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN device_price_kopeks INTEGER DEFAULT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN device_price_kopeks INTEGER DEFAULT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN device_price_kopeks INT DEFAULT NULL" + )) + + logger.info("✅ Колонка device_price_kopeks добавлена в tariffs") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонки device_price_kopeks: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -5526,6 +5723,31 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам") + logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕЖИМА ТАРИФОВ ===") + tariffs_table_ready = await create_tariffs_table() + if tariffs_table_ready: + logger.info("✅ Таблица tariffs готова") + else: + logger.warning("⚠️ Проблемы с таблицей tariffs") + + tariff_promo_groups_ready = await create_tariff_promo_groups_table() + if tariff_promo_groups_ready: + logger.info("✅ Таблица tariff_promo_groups готова") + else: + logger.warning("⚠️ Проблемы с таблицей tariff_promo_groups") + + tariff_id_column_ready = await add_subscription_tariff_id_column() + if tariff_id_column_ready: + logger.info("✅ Колонка tariff_id в subscriptions готова") + else: + logger.warning("⚠️ Проблемы с колонкой tariff_id в subscriptions") + + device_price_column_ready = await add_tariff_device_price_column() + if device_price_column_ready: + logger.info("✅ Колонка device_price_kopeks в tariffs готова") + else: + logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py new file mode 100644 index 00000000..86bd7ba4 --- /dev/null +++ b/app/handlers/admin/tariffs.py @@ -0,0 +1,1871 @@ +"""Управление тарифами в админ-панели.""" +import logging +from typing import Dict, List, Optional, Tuple + +from aiogram import Dispatcher, types, F +from aiogram.exceptions import TelegramBadRequest +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.tariff import ( + get_all_tariffs, + get_tariff_by_id, + create_tariff, + update_tariff, + delete_tariff, + get_tariff_subscriptions_count, + get_tariffs_with_subscriptions_count, +) +from app.database.crud.promo_group import get_promo_groups_with_counts +from app.database.crud.server_squad import get_all_server_squads +from app.database.models import Tariff, User +from app.localization.texts import get_texts +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler + + +logger = logging.getLogger(__name__) + +ITEMS_PER_PAGE = 10 + + +def _format_traffic(gb: int) -> str: + """Форматирует трафик.""" + if gb == 0: + return "Безлимит" + return f"{gb} ГБ" + + +def _format_price_kopeks(kopeks: int) -> str: + """Форматирует цену из копеек в рубли.""" + rubles = kopeks / 100 + if rubles == int(rubles): + return f"{int(rubles)} ₽" + return f"{rubles:.2f} ₽" + + +def _format_period(days: int) -> str: + """Форматирует период.""" + if days == 1: + return "1 день" + elif days < 5: + return f"{days} дня" + elif days < 21 or days % 10 >= 5 or days % 10 == 0: + return f"{days} дней" + elif days % 10 == 1: + return f"{days} день" + else: + return f"{days} дня" + + +def _parse_period_prices(text: str) -> Dict[str, int]: + """ + Парсит строку с ценами периодов. + Формат: "30:9900, 90:24900, 180:44900" или "30=9900; 90=24900" + """ + prices = {} + text = text.replace(";", ",").replace("=", ":") + + for part in text.split(","): + part = part.strip() + if not part: + continue + + if ":" not in part: + continue + + period_str, price_str = part.split(":", 1) + try: + period = int(period_str.strip()) + price = int(price_str.strip()) + if period > 0 and price >= 0: + prices[str(period)] = price + except ValueError: + continue + + return prices + + +def _format_period_prices_display(prices: Dict[str, int]) -> str: + """Форматирует цены периодов для отображения.""" + if not prices: + return "Не заданы" + + lines = [] + for period_str in sorted(prices.keys(), key=int): + period = int(period_str) + price = prices[period_str] + lines.append(f" • {_format_period(period)}: {_format_price_kopeks(price)}") + + return "\n".join(lines) + + +def _format_period_prices_for_edit(prices: Dict[str, int]) -> str: + """Форматирует цены периодов для редактирования.""" + if not prices: + return "30:9900, 90:24900, 180:44900" + + parts = [] + for period_str in sorted(prices.keys(), key=int): + parts.append(f"{period_str}:{prices[period_str]}") + + return ", ".join(parts) + + +def get_tariffs_list_keyboard( + tariffs: List[Tuple[Tariff, int]], + language: str, + page: int = 0, + total_pages: int = 1, +) -> InlineKeyboardMarkup: + """Создает клавиатуру списка тарифов.""" + texts = get_texts(language) + buttons = [] + + for tariff, subs_count in tariffs: + status = "✅" if tariff.is_active else "❌" + button_text = f"{status} {tariff.name} ({subs_count})" + buttons.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"admin_tariff_view:{tariff.id}" + ) + ]) + + # Пагинация + nav_buttons = [] + if page > 0: + nav_buttons.append( + InlineKeyboardButton(text="◀️", callback_data=f"admin_tariffs_page:{page-1}") + ) + if page < total_pages - 1: + nav_buttons.append( + InlineKeyboardButton(text="▶️", callback_data=f"admin_tariffs_page:{page+1}") + ) + if nav_buttons: + buttons.append(nav_buttons) + + # Кнопка создания + buttons.append([ + InlineKeyboardButton( + text="➕ Создать тариф", + callback_data="admin_tariff_create" + ) + ]) + + # Кнопка назад + buttons.append([ + InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_submenu_settings" + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_tariff_view_keyboard( + tariff: Tariff, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру просмотра тарифа.""" + texts = get_texts(language) + buttons = [] + + # Редактирование полей + buttons.append([ + InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_tariff_edit_name:{tariff.id}"), + InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_tariff_edit_desc:{tariff.id}"), + ]) + buttons.append([ + InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_tariff_edit_traffic:{tariff.id}"), + InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_tariff_edit_devices:{tariff.id}"), + ]) + buttons.append([ + InlineKeyboardButton(text="💰 Цены", callback_data=f"admin_tariff_edit_prices:{tariff.id}"), + InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), + ]) + buttons.append([ + InlineKeyboardButton(text="📱💰 Цена за устройство", callback_data=f"admin_tariff_edit_device_price:{tariff.id}"), + InlineKeyboardButton(text="⏰ Дни триала", callback_data=f"admin_tariff_edit_trial_days:{tariff.id}"), + ]) + buttons.append([ + InlineKeyboardButton(text="🌐 Серверы", callback_data=f"admin_tariff_edit_squads:{tariff.id}"), + InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"), + ]) + + # Переключение триала + if tariff.is_trial_available: + buttons.append([ + InlineKeyboardButton(text="🎁 ❌ Убрать триал", callback_data=f"admin_tariff_toggle_trial:{tariff.id}") + ]) + else: + buttons.append([ + InlineKeyboardButton(text="🎁 Сделать триальным", callback_data=f"admin_tariff_toggle_trial:{tariff.id}") + ]) + + # Переключение активности + if tariff.is_active: + buttons.append([ + InlineKeyboardButton(text="❌ Деактивировать", callback_data=f"admin_tariff_toggle:{tariff.id}") + ]) + else: + buttons.append([ + InlineKeyboardButton(text="✅ Активировать", callback_data=f"admin_tariff_toggle:{tariff.id}") + ]) + + # Удаление + buttons.append([ + InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_tariff_delete:{tariff.id}") + ]) + + # Назад к списку + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_tariffs") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> str: + """Форматирует информацию о тарифе.""" + texts = get_texts(language) + + status = "✅ Активен" if tariff.is_active else "❌ Неактивен" + traffic = _format_traffic(tariff.traffic_limit_gb) + prices_display = _format_period_prices_display(tariff.period_prices or {}) + + # Форматируем список серверов + squads_list = tariff.allowed_squads or [] + squads_display = f"{len(squads_list)} серверов" if squads_list else "Все серверы" + + # Форматируем промогруппы + promo_groups = tariff.allowed_promo_groups or [] + if promo_groups: + promo_display = ", ".join(pg.name for pg in promo_groups) + else: + promo_display = "Доступен всем" + + trial_status = "✅ Да" if tariff.is_trial_available else "❌ Нет" + + # Форматируем дни триала + trial_days = getattr(tariff, 'trial_duration_days', None) + if trial_days: + trial_days_display = f"{trial_days} дней" + else: + trial_days_display = f"По умолчанию ({settings.TRIAL_DURATION_DAYS} дней)" + + # Форматируем цену за устройство + device_price = getattr(tariff, 'device_price_kopeks', None) + if device_price is not None and device_price > 0: + device_price_display = _format_price_kopeks(device_price) + "/мес" + else: + device_price_display = "Недоступно" + + return f"""📦 Тариф: {tariff.name} + +{status} +🎚️ Уровень: {tariff.tier_level} +📊 Порядок: {tariff.display_order} + +Параметры: +• Трафик: {traffic} +• Устройств: {tariff.device_limit} +• Цена за доп. устройство: {device_price_display} +• Триал: {trial_status} +• Дней триала: {trial_days_display} + +Цены: +{prices_display} + +Серверы: {squads_display} +Промогруппы: {promo_display} + +📊 Подписок на тарифе: {subs_count} + +{f"📝 {tariff.description}" if tariff.description else ""}""" + + +@admin_required +@error_handler +async def show_tariffs_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает список тарифов.""" + await state.clear() + texts = get_texts(db_user.language) + + # Проверяем режим продаж + if not settings.is_tariffs_mode(): + await callback.message.edit_text( + "⚠️ Режим тарифов отключен\n\n" + "Для использования тарифов установите:\n" + "SALES_MODE=tariffs\n\n" + "Текущий режим: classic", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + tariffs_data = await get_tariffs_with_subscriptions_count(db, include_inactive=True) + + if not tariffs_data: + await callback.message.edit_text( + "📦 Тарифы\n\n" + "Тарифы ещё не созданы.\n" + "Создайте первый тариф для начала работы.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="➕ Создать тариф", callback_data="admin_tariff_create")], + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + total_pages = (len(tariffs_data) + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE + page_data = tariffs_data[:ITEMS_PER_PAGE] + + total_subs = sum(count for _, count in tariffs_data) + active_count = sum(1 for t, _ in tariffs_data if t.is_active) + + await callback.message.edit_text( + f"📦 Тарифы\n\n" + f"Всего: {len(tariffs_data)} (активных: {active_count})\n" + f"Подписок на тарифах: {total_subs}\n\n" + "Выберите тариф для просмотра и редактирования:", + reply_markup=get_tariffs_list_keyboard(page_data, db_user.language, 0, total_pages), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_tariffs_page( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Показывает страницу списка тарифов.""" + texts = get_texts(db_user.language) + page = int(callback.data.split(":")[1]) + + tariffs_data = await get_tariffs_with_subscriptions_count(db, include_inactive=True) + total_pages = (len(tariffs_data) + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE + + start_idx = page * ITEMS_PER_PAGE + end_idx = start_idx + ITEMS_PER_PAGE + page_data = tariffs_data[start_idx:end_idx] + + total_subs = sum(count for _, count in tariffs_data) + active_count = sum(1 for t, _ in tariffs_data if t.is_active) + + await callback.message.edit_text( + f"📦 Тарифы (стр. {page + 1}/{total_pages})\n\n" + f"Всего: {len(tariffs_data)} (активных: {active_count})\n" + f"Подписок на тарифах: {total_subs}\n\n" + "Выберите тариф для просмотра и редактирования:", + reply_markup=get_tariffs_list_keyboard(page_data, db_user.language, page, total_pages), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def view_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Просмотр тарифа.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await callback.message.edit_text( + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Переключает активность тарифа.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + tariff = await update_tariff(db, tariff, is_active=not tariff.is_active) + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + status = "активирован" if tariff.is_active else "деактивирован" + await callback.answer(f"Тариф {status}", show_alert=True) + + await callback.message.edit_text( + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def toggle_trial_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Переключает тариф как триальный.""" + from app.database.crud.tariff import set_trial_tariff, clear_trial_tariff + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + if tariff.is_trial_available: + # Снимаем флаг триала + await clear_trial_tariff(db) + await callback.answer("Триал снят с тарифа", show_alert=True) + else: + # Устанавливаем этот тариф как триальный (снимает флаг с других) + await set_trial_tariff(db, tariff_id) + await callback.answer(f"Тариф «{tariff.name}» установлен как триальный", show_alert=True) + + # Перезагружаем тариф + tariff = await get_tariff_by_id(db, tariff_id) + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await callback.message.edit_text( + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +# ============ СОЗДАНИЕ ТАРИФА ============ + +@admin_required +@error_handler +async def start_create_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает создание тарифа.""" + texts = get_texts(db_user.language) + + await state.set_state(AdminStates.creating_tariff_name) + await state.update_data(language=db_user.language) + + await callback.message.edit_text( + "📦 Создание тарифа\n\n" + "Шаг 1/6: Введите название тарифа\n\n" + "Пример: Базовый, Премиум, Бизнес", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_tariff_name( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает название тарифа.""" + texts = get_texts(db_user.language) + name = message.text.strip() + + if len(name) < 2: + await message.answer("Название должно быть не короче 2 символов") + return + + if len(name) > 50: + await message.answer("Название должно быть не длиннее 50 символов") + return + + await state.update_data(tariff_name=name) + await state.set_state(AdminStates.creating_tariff_traffic) + + await message.answer( + "📦 Создание тарифа\n\n" + f"Название: {name}\n\n" + "Шаг 2/6: Введите лимит трафика в ГБ\n\n" + "Введите 0 для безлимитного трафика\n" + "Пример: 100, 500, 0", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def process_tariff_traffic( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает лимит трафика.""" + texts = get_texts(db_user.language) + + try: + traffic = int(message.text.strip()) + if traffic < 0: + raise ValueError + except ValueError: + await message.answer("Введите корректное число (0 или больше)") + return + + data = await state.get_data() + await state.update_data(tariff_traffic=traffic) + await state.set_state(AdminStates.creating_tariff_devices) + + traffic_display = _format_traffic(traffic) + + await message.answer( + "📦 Создание тарифа\n\n" + f"Название: {data['tariff_name']}\n" + f"Трафик: {traffic_display}\n\n" + "Шаг 3/6: Введите лимит устройств\n\n" + "Пример: 1, 3, 5", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def process_tariff_devices( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает лимит устройств.""" + texts = get_texts(db_user.language) + + try: + devices = int(message.text.strip()) + if devices < 1: + raise ValueError + except ValueError: + await message.answer("Введите корректное число (1 или больше)") + return + + data = await state.get_data() + await state.update_data(tariff_devices=devices) + await state.set_state(AdminStates.creating_tariff_tier) + + traffic_display = _format_traffic(data['tariff_traffic']) + + await message.answer( + "📦 Создание тарифа\n\n" + f"Название: {data['tariff_name']}\n" + f"Трафик: {traffic_display}\n" + f"Устройств: {devices}\n\n" + "Шаг 4/6: Введите уровень тарифа (1-10)\n\n" + "Уровень используется для визуального отображения\n" + "1 - базовый, 10 - максимальный\n" + "Пример: 1, 2, 3", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def process_tariff_tier( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает уровень тарифа.""" + texts = get_texts(db_user.language) + + try: + tier = int(message.text.strip()) + if tier < 1 or tier > 10: + raise ValueError + except ValueError: + await message.answer("Введите число от 1 до 10") + return + + data = await state.get_data() + await state.update_data(tariff_tier=tier) + await state.set_state(AdminStates.creating_tariff_prices) + + traffic_display = _format_traffic(data['tariff_traffic']) + + await message.answer( + "📦 Создание тарифа\n\n" + f"Название: {data['tariff_name']}\n" + f"Трафик: {traffic_display}\n" + f"Устройств: {data['tariff_devices']}\n" + f"Уровень: {tier}\n\n" + "Шаг 5/6: Введите цены на периоды\n\n" + "Формат: дней:цена_в_копейках\n" + "Несколько периодов через запятую\n\n" + "Пример:\n30:9900, 90:24900, 180:44900, 360:79900", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def process_tariff_prices( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает цены тарифа.""" + texts = get_texts(db_user.language) + + prices = _parse_period_prices(message.text.strip()) + + if not prices: + await message.answer( + "Не удалось распознать цены.\n\n" + "Формат: дней:цена_в_копейках\n" + "Пример: 30:9900, 90:24900", + parse_mode="HTML" + ) + return + + data = await state.get_data() + await state.update_data(tariff_prices=prices) + + traffic_display = _format_traffic(data['tariff_traffic']) + prices_display = _format_period_prices_display(prices) + + # Создаем тариф + tariff = await create_tariff( + db, + name=data['tariff_name'], + traffic_limit_gb=data['tariff_traffic'], + device_limit=data['tariff_devices'], + tier_level=data['tariff_tier'], + period_prices=prices, + is_active=True, + ) + + await state.clear() + + subs_count = 0 + + await message.answer( + f"✅ Тариф создан!\n\n" + + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +# ============ РЕДАКТИРОВАНИЕ ТАРИФА ============ + +@admin_required +@error_handler +async def start_edit_tariff_name( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование названия тарифа.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_name) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + await callback.message.edit_text( + f"✏️ Редактирование названия\n\n" + f"Текущее название: {tariff.name}\n\n" + "Введите новое название:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_tariff_name( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новое название тарифа.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + name = message.text.strip() + if len(name) < 2 or len(name) > 50: + await message.answer("Название должно быть от 2 до 50 символов") + return + + tariff = await update_tariff(db, tariff, name=name) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Название изменено!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def start_edit_tariff_description( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование описания тарифа.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_description) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + current_desc = tariff.description or "Не задано" + + await callback.message.edit_text( + f"📝 Редактирование описания\n\n" + f"Текущее описание:\n{current_desc}\n\n" + "Введите новое описание (или - для удаления):", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_tariff_description( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новое описание тарифа.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + description = message.text.strip() + if description == "-": + description = None + + tariff = await update_tariff(db, tariff, description=description) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Описание изменено!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def start_edit_tariff_traffic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование трафика тарифа.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_traffic) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + current_traffic = _format_traffic(tariff.traffic_limit_gb) + + await callback.message.edit_text( + f"📊 Редактирование трафика\n\n" + f"Текущий лимит: {current_traffic}\n\n" + "Введите новый лимит в ГБ (0 = безлимит):", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_tariff_traffic( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новый лимит трафика.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + try: + traffic = int(message.text.strip()) + if traffic < 0: + raise ValueError + except ValueError: + await message.answer("Введите корректное число (0 или больше)") + return + + tariff = await update_tariff(db, tariff, traffic_limit_gb=traffic) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Трафик изменен!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def start_edit_tariff_devices( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование лимита устройств.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_devices) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + await callback.message.edit_text( + f"📱 Редактирование устройств\n\n" + f"Текущий лимит: {tariff.device_limit}\n\n" + "Введите новый лимит устройств:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_tariff_devices( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новый лимит устройств.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + try: + devices = int(message.text.strip()) + if devices < 1: + raise ValueError + except ValueError: + await message.answer("Введите корректное число (1 или больше)") + return + + tariff = await update_tariff(db, tariff, device_limit=devices) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Лимит устройств изменен!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def start_edit_tariff_tier( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование уровня тарифа.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_tier) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + await callback.message.edit_text( + f"🎚️ Редактирование уровня\n\n" + f"Текущий уровень: {tariff.tier_level}\n\n" + "Введите новый уровень (1-10):", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_tariff_tier( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новый уровень тарифа.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + try: + tier = int(message.text.strip()) + if tier < 1 or tier > 10: + raise ValueError + except ValueError: + await message.answer("Введите число от 1 до 10") + return + + tariff = await update_tariff(db, tariff, tier_level=tier) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Уровень изменен!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def start_edit_tariff_prices( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование цен тарифа.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_prices) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + current_prices = _format_period_prices_for_edit(tariff.period_prices or {}) + prices_display = _format_period_prices_display(tariff.period_prices or {}) + + await callback.message.edit_text( + f"💰 Редактирование цен\n\n" + f"Текущие цены:\n{prices_display}\n\n" + "Введите новые цены в формате:\n" + f"{current_prices}\n\n" + "(дней:цена_в_копейках, через запятую)", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_tariff_prices( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новые цены тарифа.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + prices = _parse_period_prices(message.text.strip()) + if not prices: + await message.answer( + "Не удалось распознать цены.\n" + "Формат: дней:цена\n" + "Пример: 30:9900, 90:24900", + parse_mode="HTML" + ) + return + + tariff = await update_tariff(db, tariff, period_prices=prices) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Цены изменены!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +# ============ РЕДАКТИРОВАНИЕ ЦЕНЫ ЗА УСТРОЙСТВО ============ + +@admin_required +@error_handler +async def start_edit_tariff_device_price( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование цены за устройство.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_device_price) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + device_price = getattr(tariff, 'device_price_kopeks', None) + if device_price is not None and device_price > 0: + current_price = _format_price_kopeks(device_price) + "/мес" + else: + current_price = "Недоступно (докупка устройств запрещена)" + + await callback.message.edit_text( + f"📱💰 Редактирование цены за устройство\n\n" + f"Текущая цена: {current_price}\n\n" + "Введите цену в копейках за одно устройство в месяц.\n\n" + "• 0 или - — докупка устройств недоступна\n" + "• Например: 5000 = 50₽/мес за устройство", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_tariff_device_price( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новую цену за устройство.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + text = message.text.strip() + + if text == "-" or text == "0": + device_price = None + else: + try: + device_price = int(text) + if device_price < 0: + raise ValueError + except ValueError: + await message.answer( + "Введите корректное число (0 или больше).\n" + "Для отключения докупки введите 0 или -", + parse_mode="HTML" + ) + return + + tariff = await update_tariff(db, tariff, device_price_kopeks=device_price) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Цена за устройство изменена!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +# ============ РЕДАКТИРОВАНИЕ ДНЕЙ ТРИАЛА ============ + +@admin_required +@error_handler +async def start_edit_tariff_trial_days( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование дней триала.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_trial_days) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + trial_days = getattr(tariff, 'trial_duration_days', None) + if trial_days: + current_days = f"{trial_days} дней" + else: + current_days = f"По умолчанию ({settings.TRIAL_DURATION_DAYS} дней)" + + await callback.message.edit_text( + f"⏰ Редактирование дней триала\n\n" + f"Текущее значение: {current_days}\n\n" + "Введите количество дней триала.\n\n" + f"• 0 или - — использовать настройку по умолчанию ({settings.TRIAL_DURATION_DAYS} дней)\n" + "• Например: 7 = 7 дней триала", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_tariff_trial_days( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новое количество дней триала.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + text = message.text.strip() + + if text == "-" or text == "0": + trial_days = None + else: + try: + trial_days = int(text) + if trial_days < 1: + raise ValueError + except ValueError: + await message.answer( + "Введите корректное число дней (1 или больше).\n" + "Для использования настройки по умолчанию введите 0 или -", + parse_mode="HTML" + ) + return + + tariff = await update_tariff(db, tariff, trial_duration_days=trial_days) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Дни триала изменены!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +# ============ УДАЛЕНИЕ ТАРИФА ============ + +@admin_required +@error_handler +async def confirm_delete_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Запрашивает подтверждение удаления тарифа.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + warning = "" + if subs_count > 0: + warning = f"\n\n⚠️ Внимание! На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа." + + await callback.message.edit_text( + f"🗑️ Удаление тарифа\n\n" + f"Вы действительно хотите удалить тариф {tariff.name}?" + f"{warning}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"admin_tariff_delete_confirm:{tariff_id}"), + InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_tariff_view:{tariff_id}"), + ] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_tariff_confirmed( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Удаляет тариф после подтверждения.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + tariff_name = tariff.name + await delete_tariff(db, tariff) + + await callback.answer(f"Тариф «{tariff_name}» удален", show_alert=True) + + # Возвращаемся к списку + tariffs_data = await get_tariffs_with_subscriptions_count(db, include_inactive=True) + + if not tariffs_data: + await callback.message.edit_text( + "📦 Тарифы\n\n" + "Тарифы ещё не созданы.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="➕ Создать тариф", callback_data="admin_tariff_create")], + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings")] + ]), + parse_mode="HTML" + ) + return + + total_pages = (len(tariffs_data) + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE + page_data = tariffs_data[:ITEMS_PER_PAGE] + + await callback.message.edit_text( + f"📦 Тарифы\n\n" + f"✅ Тариф «{tariff_name}» удален\n\n" + f"Всего: {len(tariffs_data)}", + reply_markup=get_tariffs_list_keyboard(page_data, db_user.language, 0, total_pages), + parse_mode="HTML" + ) + + +# ============ РЕДАКТИРОВАНИЕ СЕРВЕРОВ ============ + +@admin_required +@error_handler +async def start_edit_tariff_squads( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает меню выбора серверов для тарифа.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + squads, _ = await get_all_server_squads(db) + + if not squads: + await callback.answer("Нет доступных серверов", show_alert=True) + return + + current_squads = set(tariff.allowed_squads or []) + + buttons = [] + for squad in squads: + is_selected = squad.squad_uuid in current_squads + prefix = "✅" if is_selected else "⬜" + buttons.append([ + InlineKeyboardButton( + text=f"{prefix} {squad.display_name}", + callback_data=f"admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_squads:{tariff_id}"), + InlineKeyboardButton(text="✅ Выбрать все", callback_data=f"admin_tariff_select_all_squads:{tariff_id}"), + ]) + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + selected_count = len(current_squads) + + await callback.message.edit_text( + f"🌐 Серверы для тарифа «{tariff.name}»\n\n" + f"Выбрано: {selected_count} из {len(squads)}\n\n" + "Если не выбран ни один сервер - доступны все.\n" + "Нажмите на сервер для выбора/отмены:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_tariff_squad( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Переключает выбор сервера для тарифа.""" + parts = callback.data.split(":") + tariff_id = int(parts[1]) + squad_uuid = parts[2] + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + current_squads = set(tariff.allowed_squads or []) + + if squad_uuid in current_squads: + current_squads.remove(squad_uuid) + else: + current_squads.add(squad_uuid) + + tariff = await update_tariff(db, tariff, allowed_squads=list(current_squads)) + + # Перерисовываем меню + squads, _ = await get_all_server_squads(db) + texts = get_texts(db_user.language) + + buttons = [] + for squad in squads: + is_selected = squad.squad_uuid in current_squads + prefix = "✅" if is_selected else "⬜" + buttons.append([ + InlineKeyboardButton( + text=f"{prefix} {squad.display_name}", + callback_data=f"admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_squads:{tariff_id}"), + InlineKeyboardButton(text="✅ Выбрать все", callback_data=f"admin_tariff_select_all_squads:{tariff_id}"), + ]) + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + try: + await callback.message.edit_text( + f"🌐 Серверы для тарифа «{tariff.name}»\n\n" + f"Выбрано: {len(current_squads)} из {len(squads)}\n\n" + "Если не выбран ни один сервер - доступны все.\n" + "Нажмите на сервер для выбора/отмены:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + except TelegramBadRequest: + pass + + await callback.answer() + + +@admin_required +@error_handler +async def clear_tariff_squads( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Очищает список серверов тарифа.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + tariff = await update_tariff(db, tariff, allowed_squads=[]) + await callback.answer("Все серверы очищены") + + # Перерисовываем меню + squads, _ = await get_all_server_squads(db) + texts = get_texts(db_user.language) + + buttons = [] + for squad in squads: + buttons.append([ + InlineKeyboardButton( + text=f"⬜ {squad.display_name}", + callback_data=f"admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_squads:{tariff_id}"), + InlineKeyboardButton(text="✅ Выбрать все", callback_data=f"admin_tariff_select_all_squads:{tariff_id}"), + ]) + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + try: + await callback.message.edit_text( + f"🌐 Серверы для тарифа «{tariff.name}»\n\n" + f"Выбрано: 0 из {len(squads)}\n\n" + "Если не выбран ни один сервер - доступны все.\n" + "Нажмите на сервер для выбора/отмены:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + except TelegramBadRequest: + pass + + +@admin_required +@error_handler +async def select_all_tariff_squads( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Выбирает все серверы для тарифа.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + squads, _ = await get_all_server_squads(db) + all_uuids = [s.squad_uuid for s in squads] + + tariff = await update_tariff(db, tariff, allowed_squads=all_uuids) + await callback.answer("Все серверы выбраны") + + texts = get_texts(db_user.language) + + buttons = [] + for squad in squads: + buttons.append([ + InlineKeyboardButton( + text=f"✅ {squad.display_name}", + callback_data=f"admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_squads:{tariff_id}"), + InlineKeyboardButton(text="✅ Выбрать все", callback_data=f"admin_tariff_select_all_squads:{tariff_id}"), + ]) + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + try: + await callback.message.edit_text( + f"🌐 Серверы для тарифа «{tariff.name}»\n\n" + f"Выбрано: {len(squads)} из {len(squads)}\n\n" + "Если не выбран ни один сервер - доступны все.\n" + "Нажмите на сервер для выбора/отмены:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + except TelegramBadRequest: + pass + + +# ============ РЕДАКТИРОВАНИЕ ПРОМОГРУПП ============ + +@admin_required +@error_handler +async def start_edit_tariff_promo_groups( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Показывает меню выбора промогрупп для тарифа.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + promo_groups_data = await get_promo_groups_with_counts(db) + + if not promo_groups_data: + await callback.answer("Нет промогрупп", show_alert=True) + return + + current_groups = {pg.id for pg in (tariff.allowed_promo_groups or [])} + + buttons = [] + for promo_group, _ in promo_groups_data: + is_selected = promo_group.id in current_groups + prefix = "✅" if is_selected else "⬜" + buttons.append([ + InlineKeyboardButton( + text=f"{prefix} {promo_group.name}", + callback_data=f"admin_tariff_toggle_promo:{tariff_id}:{promo_group.id}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_promo:{tariff_id}"), + ]) + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + selected_count = len(current_groups) + + await callback.message.edit_text( + f"👥 Промогруппы для тарифа «{tariff.name}»\n\n" + f"Выбрано: {selected_count}\n\n" + "Если не выбрана ни одна группа - тариф доступен всем.\n" + "Выберите группы, которым доступен этот тариф:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_tariff_promo_group( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Переключает выбор промогруппы для тарифа.""" + from app.database.crud.tariff import add_promo_group_to_tariff, remove_promo_group_from_tariff + + parts = callback.data.split(":") + tariff_id = int(parts[1]) + promo_group_id = int(parts[2]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + current_groups = {pg.id for pg in (tariff.allowed_promo_groups or [])} + + if promo_group_id in current_groups: + await remove_promo_group_from_tariff(db, tariff, promo_group_id) + current_groups.remove(promo_group_id) + else: + await add_promo_group_to_tariff(db, tariff, promo_group_id) + current_groups.add(promo_group_id) + + # Обновляем тариф из БД + tariff = await get_tariff_by_id(db, tariff_id) + current_groups = {pg.id for pg in (tariff.allowed_promo_groups or [])} + + # Перерисовываем меню + promo_groups_data = await get_promo_groups_with_counts(db) + texts = get_texts(db_user.language) + + buttons = [] + for promo_group, _ in promo_groups_data: + is_selected = promo_group.id in current_groups + prefix = "✅" if is_selected else "⬜" + buttons.append([ + InlineKeyboardButton( + text=f"{prefix} {promo_group.name}", + callback_data=f"admin_tariff_toggle_promo:{tariff_id}:{promo_group.id}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_promo:{tariff_id}"), + ]) + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + try: + await callback.message.edit_text( + f"👥 Промогруппы для тарифа «{tariff.name}»\n\n" + f"Выбрано: {len(current_groups)}\n\n" + "Если не выбрана ни одна группа - тариф доступен всем.\n" + "Выберите группы, которым доступен этот тариф:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + except TelegramBadRequest: + pass + + await callback.answer() + + +@admin_required +@error_handler +async def clear_tariff_promo_groups( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Очищает список промогрупп тарифа.""" + from app.database.crud.tariff import set_tariff_promo_groups + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await set_tariff_promo_groups(db, tariff, []) + await callback.answer("Все промогруппы очищены") + + # Перерисовываем меню + promo_groups_data = await get_promo_groups_with_counts(db) + texts = get_texts(db_user.language) + + buttons = [] + for promo_group, _ in promo_groups_data: + buttons.append([ + InlineKeyboardButton( + text=f"⬜ {promo_group.name}", + callback_data=f"admin_tariff_toggle_promo:{tariff_id}:{promo_group.id}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_promo:{tariff_id}"), + ]) + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + try: + await callback.message.edit_text( + f"👥 Промогруппы для тарифа «{tariff.name}»\n\n" + f"Выбрано: 0\n\n" + "Если не выбрана ни одна группа - тариф доступен всем.\n" + "Выберите группы, которым доступен этот тариф:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + except TelegramBadRequest: + pass + + +def register_handlers(dp: Dispatcher): + """Регистрирует обработчики для управления тарифами.""" + # Список тарифов + dp.callback_query.register(show_tariffs_list, F.data == "admin_tariffs") + dp.callback_query.register(show_tariffs_page, F.data.startswith("admin_tariffs_page:")) + + # Просмотр и переключение + dp.callback_query.register(view_tariff, F.data.startswith("admin_tariff_view:")) + dp.callback_query.register(toggle_tariff, F.data.startswith("admin_tariff_toggle:") & ~F.data.startswith("admin_tariff_toggle_trial:")) + dp.callback_query.register(toggle_trial_tariff, F.data.startswith("admin_tariff_toggle_trial:")) + + # Создание тарифа + dp.callback_query.register(start_create_tariff, F.data == "admin_tariff_create") + dp.message.register(process_tariff_name, AdminStates.creating_tariff_name) + dp.message.register(process_tariff_traffic, AdminStates.creating_tariff_traffic) + dp.message.register(process_tariff_devices, AdminStates.creating_tariff_devices) + dp.message.register(process_tariff_tier, AdminStates.creating_tariff_tier) + dp.message.register(process_tariff_prices, AdminStates.creating_tariff_prices) + + # Редактирование названия + dp.callback_query.register(start_edit_tariff_name, F.data.startswith("admin_tariff_edit_name:")) + dp.message.register(process_edit_tariff_name, AdminStates.editing_tariff_name) + + # Редактирование описания + dp.callback_query.register(start_edit_tariff_description, F.data.startswith("admin_tariff_edit_desc:")) + dp.message.register(process_edit_tariff_description, AdminStates.editing_tariff_description) + + # Редактирование трафика + dp.callback_query.register(start_edit_tariff_traffic, F.data.startswith("admin_tariff_edit_traffic:")) + dp.message.register(process_edit_tariff_traffic, AdminStates.editing_tariff_traffic) + + # Редактирование устройств + dp.callback_query.register(start_edit_tariff_devices, F.data.startswith("admin_tariff_edit_devices:")) + dp.message.register(process_edit_tariff_devices, AdminStates.editing_tariff_devices) + + # Редактирование уровня + dp.callback_query.register(start_edit_tariff_tier, F.data.startswith("admin_tariff_edit_tier:")) + dp.message.register(process_edit_tariff_tier, AdminStates.editing_tariff_tier) + + # Редактирование цен + dp.callback_query.register(start_edit_tariff_prices, F.data.startswith("admin_tariff_edit_prices:")) + dp.message.register(process_edit_tariff_prices, AdminStates.editing_tariff_prices) + + # Редактирование цены за устройство + dp.callback_query.register(start_edit_tariff_device_price, F.data.startswith("admin_tariff_edit_device_price:")) + dp.message.register(process_edit_tariff_device_price, AdminStates.editing_tariff_device_price) + + # Редактирование дней триала + dp.callback_query.register(start_edit_tariff_trial_days, F.data.startswith("admin_tariff_edit_trial_days:")) + dp.message.register(process_edit_tariff_trial_days, AdminStates.editing_tariff_trial_days) + + # Удаление + dp.callback_query.register(confirm_delete_tariff, F.data.startswith("admin_tariff_delete:")) + dp.callback_query.register(delete_tariff_confirmed, F.data.startswith("admin_tariff_delete_confirm:")) + + # Редактирование серверов + dp.callback_query.register(start_edit_tariff_squads, F.data.startswith("admin_tariff_edit_squads:")) + dp.callback_query.register(toggle_tariff_squad, F.data.startswith("admin_tariff_toggle_squad:")) + dp.callback_query.register(clear_tariff_squads, F.data.startswith("admin_tariff_clear_squads:")) + dp.callback_query.register(select_all_tariff_squads, F.data.startswith("admin_tariff_select_all_squads:")) + + # Редактирование промогрупп + dp.callback_query.register(start_edit_tariff_promo_groups, F.data.startswith("admin_tariff_edit_promo:")) + dp.callback_query.register(toggle_tariff_promo_group, F.data.startswith("admin_tariff_toggle_promo:")) + dp.callback_query.register(clear_tariff_promo_groups, F.data.startswith("admin_tariff_clear_promo:")) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index bba87980..8eb315ad 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -44,6 +44,7 @@ from app.database.crud.server_squad import ( get_server_squad_by_id, get_server_ids_by_uuids, ) +from app.database.crud.tariff import get_all_tariffs, get_tariff_by_id from app.services.subscription_service import SubscriptionService from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, @@ -976,6 +977,15 @@ async def _render_user_subscription_overview( text += f"Статус: {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n" text += f"Тип: {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n" + + # Отображение тарифа + if subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff: + text += f"Тариф: 📦 {tariff.name}\n" + else: + text += f"Тариф: ID {subscription.tariff_id} (удалён)\n" + text += f"Начало: {format_datetime(subscription.start_date)}\n" text += f"Окончание: {format_datetime(subscription.end_date)}\n" text += f"Трафик: {traffic_display}\n" @@ -1053,6 +1063,15 @@ async def _render_user_subscription_overview( ) ]) + # Кнопка смены тарифа в режиме тарифов + if settings.is_tariffs_mode(): + keyboard.append([ + types.InlineKeyboardButton( + text="📦 Сменить тариф", + callback_data=f"admin_sub_change_tariff_{user_id}" + ) + ]) + if subscription.is_active: keyboard.append([ types.InlineKeyboardButton( @@ -5037,6 +5056,234 @@ async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: st return False +# ============================================================================= +# Смена тарифа пользователя администратором +# ============================================================================= + +@admin_required +@error_handler +async def show_admin_tariff_change( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показывает список доступных тарифов для смены.""" + user_id = int(callback.data.split('_')[-1]) + + user = await get_user_by_id(db, user_id) + if not user: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + from app.database.crud.subscription import get_subscription_by_user_id + subscription = await get_subscription_by_user_id(db, user_id) + + if not subscription: + await callback.answer("❌ У пользователя нет подписки", show_alert=True) + return + + # Получаем все активные тарифы + tariffs = await get_all_tariffs(db, include_inactive=False) + + if not tariffs: + await callback.message.edit_text( + "❌ Нет доступных тарифов\n\n" + "Создайте тарифы в разделе управления тарифами.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] + ]) + ) + await callback.answer() + return + + # Текущий тариф + current_tariff = None + if subscription.tariff_id: + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) + + text = "📦 Смена тарифа пользователя\n\n" + user_link = f'{user.full_name}' + text += f"👤 {user_link}\n\n" + + if current_tariff: + text += f"Текущий тариф: {current_tariff.name}\n\n" + else: + text += "Текущий тариф: не установлен\n\n" + + text += "Выберите новый тариф:\n" + + keyboard = [] + for tariff in tariffs: + # Отмечаем текущий тариф + prefix = "✅ " if current_tariff and tariff.id == current_tariff.id else "" + + # Описание тарифа + traffic_str = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 + + button_text = f"{prefix}{tariff.name} ({tariff.device_limit} устр., {traffic_str}, {servers_count} серв.)" + + keyboard.append([ + types.InlineKeyboardButton( + text=button_text, + callback_data=f"admin_sub_tariff_select_{tariff.id}_{user_id}" + ) + ]) + + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def select_admin_tariff_change( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Подтверждение выбора тарифа.""" + parts = callback.data.split('_') + tariff_id = int(parts[-2]) + user_id = int(parts[-1]) + + user = await get_user_by_id(db, user_id) + if not user: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await callback.answer("❌ Тариф не найден", show_alert=True) + return + + from app.database.crud.subscription import get_subscription_by_user_id + subscription = await get_subscription_by_user_id(db, user_id) + + if not subscription: + await callback.answer("❌ У пользователя нет подписки", show_alert=True) + return + + # Проверяем, если это тот же тариф + if subscription.tariff_id == tariff_id: + await callback.answer("ℹ️ Этот тариф уже установлен", show_alert=True) + return + + traffic_str = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 + + text = f"📦 Подтверждение смены тарифа\n\n" + user_link = f'{user.full_name}' + text += f"👤 {user_link}\n\n" + text += f"Новый тариф: {tariff.name}\n" + text += f"• Устройства: {tariff.device_limit}\n" + text += f"• Трафик: {traffic_str}\n" + text += f"• Серверы: {servers_count}\n\n" + text += "⚠️ Параметры подписки будут обновлены в соответствии с тарифом.\n" + text += "Дата окончания подписки не изменится." + + keyboard = [ + [ + types.InlineKeyboardButton( + text="✅ Подтвердить", + callback_data=f"admin_sub_tariff_confirm_{tariff_id}_{user_id}" + ), + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_sub_change_tariff_{user_id}" + ) + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def confirm_admin_tariff_change( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Применяет смену тарифа.""" + parts = callback.data.split('_') + tariff_id = int(parts[-2]) + user_id = int(parts[-1]) + + user = await get_user_by_id(db, user_id) + if not user: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await callback.answer("❌ Тариф не найден", show_alert=True) + return + + from app.database.crud.subscription import get_subscription_by_user_id + subscription = await get_subscription_by_user_id(db, user_id) + + if not subscription: + await callback.answer("❌ У пользователя нет подписки", show_alert=True) + return + + try: + old_tariff_id = subscription.tariff_id + + # Обновляем параметры подписки в соответствии с тарифом + subscription.tariff_id = tariff.id + subscription.device_limit = tariff.device_limit + subscription.traffic_limit_gb = tariff.traffic_limit_gb + subscription.connected_squads = tariff.allowed_squads or [] + subscription.updated_at = datetime.utcnow() + + await db.commit() + + # Синхронизируем с RemnaWave + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + + logger.info( + f"Админ {db_user.id} изменил тариф пользователя {user_id}: " + f"{old_tariff_id} -> {tariff_id} ({tariff.name})" + ) + + await callback.message.edit_text( + f"✅ Тариф успешно изменен\n\n" + f"Новый тариф: {tariff.name}\n" + f"• Устройства: {tariff.device_limit}\n" + f"• Трафик: {'♾️' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'}\n" + f"• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] + ]) + ) + + except Exception as e: + logger.error(f"Ошибка смены тарифа: {e}") + await db.rollback() + + await callback.message.edit_text( + "❌ Ошибка смены тарифа\n\n" + f"Детали: {str(e)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] + ]) + ) + + await callback.answer() + + def register_handlers(dp: Dispatcher): dp.callback_query.register( @@ -5353,7 +5600,23 @@ def register_handlers(dp: Dispatcher): toggle_user_modem, F.data.startswith("admin_user_modem_") ) - + + # Смена тарифа пользователя + dp.callback_query.register( + show_admin_tariff_change, + F.data.startswith("admin_sub_change_tariff_") + ) + + dp.callback_query.register( + select_admin_tariff_change, + F.data.startswith("admin_sub_tariff_select_") + ) + + dp.callback_query.register( + confirm_admin_tariff_change, + F.data.startswith("admin_sub_tariff_confirm_") + ) + dp.message.register( process_devices_edit_text, AdminStates.editing_user_devices diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index 034e9e68..6da8c50d 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -183,13 +183,6 @@ async def handle_change_devices( texts = get_texts(db_user.language) subscription = db_user.subscription - if not settings.is_devices_selection_enabled(): - await callback.answer( - texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), - show_alert=True, - ) - return - if not subscription or subscription.is_trial: await callback.answer( texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"), @@ -197,6 +190,30 @@ async def handle_change_devices( ) return + # Проверяем тариф подписки + tariff = None + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + # Для тарифов - проверяем разрешено ли изменение устройств + tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None + if tariff: + if tariff_device_price is None or tariff_device_price <= 0: + await callback.answer( + texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Изменение устройств недоступно для вашего тарифа"), + show_alert=True, + ) + return + else: + # Для обычных подписок проверяем глобальную настройку + if not settings.is_devices_selection_enabled(): + await callback.answer( + texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + show_alert=True, + ) + return + current_devices = subscription.device_limit period_hint_days = _get_period_hint_from_subscription(subscription) @@ -206,17 +223,34 @@ async def handle_change_devices( period_hint_days, ) - prompt_text = texts.t( - "CHANGE_DEVICES_PROMPT", - ( - "📱 Изменение количества устройств\n\n" - "Текущий лимит: {current_devices} устройств\n" - "Выберите новое количество устройств:\n\n" - "💡 Важно:\n" - "• При увеличении - доплата пропорционально оставшемуся времени\n" - "• При уменьшении - возврат средств не производится" - ), - ).format(current_devices=current_devices) + # Для тарифов показываем цену из тарифа + if tariff: + price_per_device = tariff_device_price + price_text = texts.format_price(price_per_device) + prompt_text = texts.t( + "CHANGE_DEVICES_PROMPT_TARIFF", + ( + "📱 Изменение количества устройств\n\n" + "Текущий лимит: {current_devices} устройств\n" + "Цена за доп. устройство: {price}/мес\n" + "Выберите новое количество устройств:\n\n" + "💡 Важно:\n" + "• При увеличении - доплата пропорционально оставшемуся времени\n" + "• При уменьшении - возврат средств не производится" + ), + ).format(current_devices=current_devices, price=price_text) + else: + prompt_text = texts.t( + "CHANGE_DEVICES_PROMPT", + ( + "📱 Изменение количества устройств\n\n" + "Текущий лимит: {current_devices} устройств\n" + "Выберите новое количество устройств:\n\n" + "💡 Важно:\n" + "• При увеличении - доплата пропорционально оставшемуся времени\n" + "• При уменьшении - возврат средств не производится" + ), + ).format(current_devices=current_devices) await callback.message.edit_text( prompt_text, @@ -225,6 +259,7 @@ async def handle_change_devices( db_user.language, subscription.end_date, devices_discount_percent, + tariff=tariff, ), parse_mode="HTML" ) @@ -240,12 +275,30 @@ async def confirm_change_devices( texts = get_texts(db_user.language) subscription = db_user.subscription - if not settings.is_devices_selection_enabled(): - await callback.answer( - texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), - show_alert=True, - ) - return + # Проверяем тариф подписки + tariff = None + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + # Для тарифов - проверяем разрешено ли изменение устройств + tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None + if tariff: + if tariff_device_price is None or tariff_device_price <= 0: + await callback.answer( + texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Изменение устройств недоступно для вашего тарифа"), + show_alert=True, + ) + return + price_per_device = tariff_device_price + else: + if not settings.is_devices_selection_enabled(): + await callback.answer( + texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + show_alert=True, + ) + return + price_per_device = settings.PRICE_PER_DEVICE current_devices = subscription.device_limit @@ -271,13 +324,16 @@ async def confirm_change_devices( if devices_difference > 0: additional_devices = devices_difference - if current_devices < settings.DEFAULT_DEVICE_LIMIT: + # Для тарифов - все устройства платные (нет бесплатного лимита) + if tariff: + chargeable_devices = additional_devices + elif current_devices < settings.DEFAULT_DEVICE_LIMIT: free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices chargeable_devices = max(0, additional_devices - free_devices) else: chargeable_devices = additional_devices - devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE + devices_price_per_month = chargeable_devices * price_per_device months_hint = get_remaining_months(subscription.end_date) period_hint_days = months_hint * 30 if months_hint > 0 else None devices_discount_percent = _get_addon_discount_percent_for_user( @@ -937,12 +993,30 @@ async def confirm_add_devices( texts = get_texts(db_user.language) subscription = db_user.subscription - if not settings.is_devices_selection_enabled(): - await callback.answer( - texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), - show_alert=True, - ) - return + # Проверяем тариф подписки + tariff = None + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + # Для тарифов - проверяем разрешено ли добавление устройств + tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None + if tariff: + if tariff_device_price is None or tariff_device_price <= 0: + await callback.answer( + texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Добавление устройств недоступно для вашего тарифа"), + show_alert=True, + ) + return + price_per_device = tariff_device_price + else: + if not settings.is_devices_selection_enabled(): + await callback.answer( + texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + show_alert=True, + ) + return + price_per_device = settings.PRICE_PER_DEVICE resume_callback = None @@ -956,7 +1030,7 @@ async def confirm_add_devices( ) return - devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE + devices_price_per_month = devices_count * price_per_device months_hint = get_remaining_months(subscription.end_date) period_hint_days = months_hint * 30 if months_hint > 0 else None devices_discount_percent = _get_addon_discount_percent_for_user( diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index a5599911..3a1680a2 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -333,6 +333,17 @@ async def show_subscription_info( else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов") ) + # Получаем название тарифа для режима тарифов + tariff_line = "" + if settings.is_tariffs_mode() and subscription.tariff_id: + try: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff: + tariff_line = f"\n📦 Тариф: {tariff.name}" + except Exception as e: + logger.warning(f"Ошибка получения тарифа: {e}") + message_template = texts.t( "SUBSCRIPTION_OVERVIEW_TEMPLATE", """👤 {full_name} @@ -340,7 +351,7 @@ async def show_subscription_info( 📱 Подписка: {status_emoji} {status_display}{warning} 📱 Информация о подписке -🎭 Тип: {subscription_type} +🎭 Тип: {subscription_type}{tariff_line} 📅 Действует до: {end_date} ⏰ Осталось: {time_left} 📈 Трафик: {traffic} @@ -370,6 +381,7 @@ async def show_subscription_info( status_display=status_display, warning=warning_text, subscription_type=subscription_type, + tariff_line=tariff_line, end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), time_left=time_left_text, traffic=traffic_used_display, @@ -446,34 +458,74 @@ async def show_trial_offer( await callback.answer() return + # Получаем параметры триала (из тарифа или из глобальных настроек) + trial_days = settings.TRIAL_DURATION_DAYS + trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB + trial_device_limit = settings.TRIAL_DEVICE_LIMIT + trial_tariff = None trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер") + + # Проверяем триальный тариф + if settings.is_tariffs_mode(): + try: + from app.database.crud.tariff import get_trial_tariff, get_tariff_by_id as get_tariff + + trial_tariff = await get_trial_tariff(db) + if not trial_tariff: + trial_tariff_id = settings.get_trial_tariff_id() + if trial_tariff_id > 0: + trial_tariff = await get_tariff(db, trial_tariff_id) + if trial_tariff and not trial_tariff.is_active: + trial_tariff = None + + if trial_tariff: + trial_traffic = trial_tariff.traffic_limit_gb + trial_device_limit = trial_tariff.device_limit + tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) + if tariff_trial_days: + trial_days = tariff_trial_days + logger.info(f"Показываем триал с тарифом {trial_tariff.name}") + except Exception as e: + logger.error(f"Ошибка получения триального тарифа: {e}") + try: from app.database.crud.server_squad import get_trial_eligible_server_squads - trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True) - - if trial_squads: - if len(trial_squads) == 1: - trial_server_name = trial_squads[0].display_name - else: - trial_server_name = texts.t( - "TRIAL_SERVER_RANDOM_POOL", - "🎲 Случайный из {count} серверов", - ).format(count=len(trial_squads)) + # Для тарифа используем его сервера + if trial_tariff and trial_tariff.allowed_squads: + from app.database.crud.server_squad import get_server_squads_by_uuids + tariff_squads = await get_server_squads_by_uuids(db, trial_tariff.allowed_squads) + if tariff_squads: + if len(tariff_squads) == 1: + trial_server_name = tariff_squads[0].display_name + else: + trial_server_name = texts.t( + "TRIAL_SERVER_RANDOM_POOL", + "🎲 Случайный из {count} серверов", + ).format(count=len(tariff_squads)) else: - logger.warning("Не настроены сквады для выдачи триалов") + trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True) + if trial_squads: + if len(trial_squads) == 1: + trial_server_name = trial_squads[0].display_name + else: + trial_server_name = texts.t( + "TRIAL_SERVER_RANDOM_POOL", + "🎲 Случайный из {count} серверов", + ).format(count=len(trial_squads)) + else: + logger.warning("Не настроены сквады для выдачи триалов") except Exception as e: logger.error(f"Ошибка получения триального сервера: {e}") - trial_device_limit = settings.TRIAL_DEVICE_LIMIT if not settings.is_devices_selection_enabled(): forced_limit = settings.get_disabled_mode_device_limit() if forced_limit is not None: trial_device_limit = forced_limit devices_line = "" - if settings.is_devices_selection_enabled(): + if settings.is_devices_selection_enabled() or trial_tariff: devices_line_template = texts.t( "TRIAL_AVAILABLE_DEVICES_LINE", "\n📱 Устройства: {devices} шт.", @@ -492,8 +544,8 @@ async def show_trial_offer( ).format(price=settings.format_price(trial_price)) trial_text = texts.TRIAL_AVAILABLE.format( - days=settings.TRIAL_DURATION_DAYS, - traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB), + days=trial_days, + traffic=texts.format_traffic(trial_traffic), devices=trial_device_limit if trial_device_limit is not None else "", devices_line=devices_line, server_name=trial_server_name, @@ -668,10 +720,49 @@ async def activate_trial( if not settings.is_devices_selection_enabled(): forced_devices = settings.get_disabled_mode_device_limit() + # Проверяем, настроен ли триальный тариф для режима тарифов + trial_tariff = None + trial_traffic_limit = None + trial_device_limit = forced_devices + trial_squads = None + tariff_id_for_trial = None + trial_duration = None # None = использовать TRIAL_DURATION_DAYS + + if settings.is_tariffs_mode(): + try: + from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff + + # Сначала проверяем тариф из БД с флагом is_trial_available + trial_tariff = await get_trial_tariff(db) + + # Если не найден в БД, проверяем настройку TRIAL_TARIFF_ID + if not trial_tariff: + trial_tariff_id = settings.get_trial_tariff_id() + if trial_tariff_id > 0: + trial_tariff = await get_tariff_by_id(db, trial_tariff_id) + if trial_tariff and not trial_tariff.is_active: + trial_tariff = None + + if trial_tariff: + trial_traffic_limit = trial_tariff.traffic_limit_gb + trial_device_limit = trial_tariff.device_limit + trial_squads = trial_tariff.allowed_squads or [] + tariff_id_for_trial = trial_tariff.id + tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) + if tariff_trial_days: + trial_duration = tariff_trial_days + logger.info(f"Используем триальный тариф {trial_tariff.name} (ID: {trial_tariff.id})") + except Exception as e: + logger.error(f"Ошибка получения триального тарифа: {e}") + subscription = await create_trial_subscription( db, db_user.id, - device_limit=forced_devices, + duration_days=trial_duration, + device_limit=trial_device_limit, + traffic_limit_gb=trial_traffic_limit, + connected_squads=trial_squads, + tariff_id=tariff_id_for_trial, ) await db.refresh(db_user) @@ -1048,6 +1139,12 @@ async def start_subscription_purchase( ): texts = get_texts(db_user.language) + # Проверяем режим продаж - если tariffs, перенаправляем на выбор тарифов + if settings.is_tariffs_mode(): + from .tariff_purchase import show_tariffs_list + await show_tariffs_list(callback, db_user, db, state) + return + keyboard = get_subscription_period_keyboard(db_user.language, db_user) prompt_text = await _build_subscription_period_prompt(db_user, texts, db) @@ -1323,6 +1420,35 @@ async def handle_extend_subscription( await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True) return + # В режиме тарифов проверяем наличие tariff_id + if settings.is_tariffs_mode(): + if subscription.tariff_id: + # У подписки есть тариф - перенаправляем на продление по тарифу + from .tariff_purchase import show_tariff_extend + await show_tariff_extend(callback, db_user, db) + return + else: + # У подписки нет тарифа - предлагаем выбрать тариф + await callback.message.edit_text( + "📦 Выберите тариф для продления\n\n" + "Ваша текущая подписка была создана до введения тарифов.\n" + "Для продления необходимо выбрать один из доступных тарифов.\n\n" + "⚠️ Ваша текущая подписка продолжит действовать до окончания срока.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="📦 Выбрать тариф", + callback_data="tariff_switch" + )], + [types.InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_subscription" + )] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + subscription_service = SubscriptionService() available_periods = settings.get_available_renewal_periods() @@ -2829,6 +2955,12 @@ async def handle_subscription_settings( texts = get_texts(db_user.language) subscription = db_user.subscription + # Получаем тариф подписки если есть + tariff = None + if subscription and subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not subscription or subscription.is_trial: await callback.answer( texts.t( @@ -2884,7 +3016,7 @@ async def handle_subscription_settings( await callback.message.edit_text( settings_text, - reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries), + reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries, tariff=tariff), parse_mode="HTML" ) await callback.answer() @@ -3895,6 +4027,10 @@ def register_handlers(dp: Dispatcher): from .modem import register_modem_handlers register_modem_handlers(dp) + # Регистрируем обработчики покупки по тарифам + from .tariff_purchase import register_tariff_purchase_handlers + register_tariff_purchase_handlers(dp) + # Регистрируем обработчик для простой покупки dp.callback_query.register( handle_simple_subscription_purchase, diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py new file mode 100644 index 00000000..28844fa3 --- /dev/null +++ b/app/handlers/subscription/tariff_purchase.py @@ -0,0 +1,1329 @@ +"""Покупка подписки по тарифам.""" +import logging +from typing import List, Optional + +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.exceptions import TelegramBadRequest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.tariff import get_tariffs_for_user, get_tariff_by_id +from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, extend_subscription +from app.database.crud.transaction import create_transaction +from app.database.crud.user import subtract_user_balance +from app.database.models import User, Tariff, TransactionType +from app.localization.texts import get_texts +from app.states import SubscriptionStates +from app.utils.decorators import error_handler +from app.services.subscription_service import SubscriptionService +from app.services.admin_notification_service import AdminNotificationService +from app.services.user_cart_service import user_cart_service +from app.utils.promo_offer import get_user_active_promo_discount_percent + + +logger = logging.getLogger(__name__) + + +def _format_traffic(gb: int) -> str: + """Форматирует трафик.""" + if gb == 0: + return "Безлимит" + return f"{gb} ГБ" + + +def _format_price_kopeks(kopeks: int) -> str: + """Форматирует цену из копеек в рубли.""" + rubles = kopeks / 100 + if rubles == int(rubles): + return f"{int(rubles)} ₽" + return f"{rubles:.2f} ₽" + + +def _format_period(days: int) -> str: + """Форматирует период.""" + if days == 1: + return "1 день" + elif days < 5: + return f"{days} дня" + elif days < 21 or days % 10 >= 5 or days % 10 == 0: + return f"{days} дней" + elif days % 10 == 1: + return f"{days} день" + else: + return f"{days} дня" + + +def _apply_promo_discount(price: int, discount_percent: int) -> int: + """Применяет скидку промогруппы к цене.""" + if discount_percent <= 0: + return price + discount = int(price * discount_percent / 100) + return max(0, price - discount) + + +def _get_user_period_discount(db_user: User, period_days: int) -> int: + """Получает скидку пользователя на период из промогруппы.""" + promo_group = getattr(db_user, 'promo_group', None) + if promo_group: + # Используем метод get_discount_percent с категорией "period" + discount = promo_group.get_discount_percent("period", period_days) + if discount > 0: + return discount + + # Проверяем персональную скидку + personal_discount = get_user_active_promo_discount_percent(db_user) + return personal_discount + + +def get_tariffs_keyboard( + tariffs: List[Tariff], + language: str, + discount_percent: int = 0, +) -> InlineKeyboardMarkup: + """Создает клавиатуру выбора тарифов.""" + texts = get_texts(language) + buttons = [] + + for tariff in tariffs: + # Берем минимальную цену для отображения + prices = tariff.period_prices or {} + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + price_text = f"от {_format_price_kopeks(min_price)}" + else: + price_text = "" + + traffic = _format_traffic(tariff.traffic_limit_gb) + + button_text = f"📦 {tariff.name} • {traffic} • {tariff.device_limit} уст. {price_text}" + buttons.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"tariff_select:{tariff.id}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_tariff_periods_keyboard( + tariff: Tariff, + language: str, + db_user: Optional[User] = None, +) -> InlineKeyboardMarkup: + """Создает клавиатуру выбора периода для тарифа с учетом скидок по периодам.""" + texts = get_texts(language) + buttons = [] + + prices = tariff.period_prices or {} + for period_str in sorted(prices.keys(), key=int): + period = int(period_str) + price = prices[period_str] + + # Получаем скидку для конкретного периода + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, period) + + if discount_percent > 0: + original_price = price + price = _apply_promo_discount(price, discount_percent) + price_text = f"{_format_price_kopeks(price)} (было {_format_price_kopeks(original_price)}, -{discount_percent}%)" + else: + price_text = _format_price_kopeks(price) + + button_text = f"{_format_period(period)} — {price_text}" + buttons.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"tariff_period:{tariff.id}:{period}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="tariff_list") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_tariff_confirm_keyboard( + tariff_id: int, + period: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру подтверждения покупки тарифа.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Подтвердить покупку", + callback_data=f"tariff_confirm:{tariff_id}:{period}" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"tariff_select:{tariff_id}" + ) + ] + ]) + + +def get_tariff_insufficient_balance_keyboard( + tariff_id: int, + period: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру при недостаточном балансе.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"tariff_select:{tariff_id}" + ) + ] + ]) + + +def format_tariff_info_for_user( + tariff: Tariff, + language: str, + discount_percent: int = 0, +) -> str: + """Форматирует информацию о тарифе для пользователя.""" + texts = get_texts(language) + + traffic = _format_traffic(tariff.traffic_limit_gb) + + text = f"""📦 {tariff.name} + +Параметры: +• Трафик: {traffic} +• Устройств: {tariff.device_limit} +""" + + if tariff.description: + text += f"\n📝 {tariff.description}\n" + + if discount_percent > 0: + text += f"\n🎁 Ваша скидка: {discount_percent}%\n" + + text += "\nВыберите период подписки:" + + return text + + +@error_handler +async def show_tariffs_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает список тарифов для покупки.""" + texts = get_texts(db_user.language) + await state.clear() + + # Получаем доступные тарифы + promo_group_id = getattr(db_user, 'promo_group_id', None) + tariffs = await get_tariffs_for_user(db, promo_group_id) + + if not tariffs: + await callback.message.edit_text( + "😔 Нет доступных тарифов\n\n" + "К сожалению, сейчас нет тарифов для покупки.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + # Проверяем есть ли у пользователя скидки по периодам + promo_group = getattr(db_user, 'promo_group', None) + has_period_discounts = False + if promo_group: + period_discounts = getattr(promo_group, 'period_discounts', None) + if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0: + has_period_discounts = True + + discount_hint = "" + if has_period_discounts: + discount_hint = "\n\n🎁 Скидки зависят от выбранного периода" + + await callback.message.edit_text( + f"📦 Выберите тариф{discount_hint}\n\n" + "Выберите подходящий тариф из списка:", + reply_markup=get_tariffs_keyboard(tariffs, db_user.language, discount_percent=0), + parse_mode="HTML" + ) + + await callback.answer() + + +@error_handler +async def select_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает выбор тарифа.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + await callback.message.edit_text( + format_tariff_info_for_user(tariff, db_user.language), + reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, db_user=db_user), + parse_mode="HTML" + ) + + await state.update_data(selected_tariff_id=tariff_id) + await callback.answer() + + +@error_handler +async def select_tariff_period( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает выбор периода для тарифа.""" + parts = callback.data.split(":") + tariff_id = int(parts[1]) + period = int(parts[2]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + # Получаем скидку для выбранного периода + discount_percent = _get_user_period_discount(db_user, period) + + # Получаем цену + prices = tariff.period_prices or {} + base_price = prices.get(str(period), 0) + final_price = _apply_promo_discount(base_price, discount_percent) + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + + traffic = _format_traffic(tariff.traffic_limit_gb) + + if user_balance >= final_price: + # Показываем подтверждение + discount_text = "" + if discount_percent > 0: + discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})" + + await callback.message.edit_text( + f"✅ Подтверждение покупки\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Период: {_format_period(period)}\n" + f"{discount_text}\n" + f"💰 Итого: {_format_price_kopeks(final_price)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"После оплаты: {_format_price_kopeks(user_balance - final_price)}", + reply_markup=get_tariff_confirm_keyboard(tariff_id, period, db_user.language), + parse_mode="HTML" + ) + else: + # Недостаточно средств + missing = final_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📅 Период: {_format_period(period)}\n" + f"💰 Стоимость: {_format_price_kopeks(final_price)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language), + parse_mode="HTML" + ) + + await state.update_data( + selected_tariff_id=tariff_id, + selected_period=period, + final_price=final_price, + tariff_discount_percent=discount_percent, + ) + await callback.answer() + + +@error_handler +async def confirm_tariff_purchase( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает покупку тарифа и создает подписку.""" + parts = callback.data.split(":") + tariff_id = int(parts[1]) + period = int(parts[2]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + # Получаем скидку для выбранного периода + discount_percent = _get_user_period_discount(db_user, period) + + # Получаем цену + prices = tariff.period_prices or {} + base_price = prices.get(str(period), 0) + final_price = _apply_promo_discount(base_price, discount_percent) + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + if user_balance < final_price: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем баланс + success = await subtract_user_balance( + db, db_user, final_price, + f"Покупка тарифа {tariff.name} на {period} дней" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Проверяем есть ли уже подписка + existing_subscription = await get_subscription_by_user_id(db, db_user.id) + + if existing_subscription: + # Продлеваем существующую подписку и обновляем параметры тарифа + subscription = await extend_subscription( + db, + existing_subscription, + days=period, + tariff_id=tariff.id, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + ) + else: + # Создаем новую подписку + subscription = await create_paid_subscription( + db=db, + user_id=db_user.id, + duration_days=period, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + tariff_id=tariff.id, + ) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="покупка тарифа", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-final_price, + description=f"Покупка тарифа {tariff.name} на {period} дней", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, # Транзакция отсутствует, оплата с баланса + period, + was_trial_conversion=False, + amount_kopeks=final_price, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + # Очищаем корзину после успешной покупки + try: + await user_cart_service.delete_user_cart(db_user.id) + logger.info(f"Корзина очищена после покупки тарифа для пользователя {db_user.telegram_id}") + except Exception as e: + logger.error(f"Ошибка очистки корзины: {e}") + + await state.clear() + + traffic = _format_traffic(tariff.traffic_limit_gb) + + await callback.message.edit_text( + f"🎉 Подписка успешно оформлена!\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Период: {_format_period(period)}\n" + f"💰 Списано: {_format_price_kopeks(final_price)}\n\n" + f"Перейдите в раздел «Подписка» для подключения.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Подписка оформлена!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при покупке тарифа: {e}", exc_info=True) + await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) + + +# ==================== Продление по тарифу ==================== + +def get_tariff_extend_keyboard( + tariff: Tariff, + language: str, + db_user: Optional[User] = None, +) -> InlineKeyboardMarkup: + """Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам.""" + texts = get_texts(language) + buttons = [] + + prices = tariff.period_prices or {} + for period_str in sorted(prices.keys(), key=int): + period = int(period_str) + price = prices[period_str] + + # Получаем скидку для конкретного периода + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, period) + + if discount_percent > 0: + original_price = price + price = _apply_promo_discount(price, discount_percent) + price_text = f"{_format_price_kopeks(price)} (было {_format_price_kopeks(original_price)}, -{discount_percent}%)" + else: + price_text = _format_price_kopeks(price) + + button_text = f"{_format_period(period)} — {price_text}" + buttons.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"tariff_extend:{tariff.id}:{period}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_tariff_extend_confirm_keyboard( + tariff_id: int, + period: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру подтверждения продления по тарифу.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Подтвердить продление", + callback_data=f"tariff_ext_confirm:{tariff_id}:{period}" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="subscription_extend" + ) + ] + ]) + + +async def show_tariff_extend( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Показывает экран продления по текущему тарифу.""" + texts = get_texts(db_user.language) + + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription or not subscription.tariff_id: + await callback.answer("Тариф не найден", show_alert=True) + return + + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + traffic = _format_traffic(tariff.traffic_limit_gb) + + # Проверяем есть ли у пользователя скидки по периодам + promo_group = getattr(db_user, 'promo_group', None) + has_period_discounts = False + if promo_group: + period_discounts = getattr(promo_group, 'period_discounts', None) + if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0: + has_period_discounts = True + + discount_hint = "" + if has_period_discounts: + discount_hint = "\n🎁 Скидки зависят от выбранного периода" + + await callback.message.edit_text( + f"🔄 Продление подписки{discount_hint}\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n\n" + "Выберите период продления:", + reply_markup=get_tariff_extend_keyboard(tariff, db_user.language, db_user=db_user), + parse_mode="HTML" + ) + await callback.answer() + + +@error_handler +async def select_tariff_extend_period( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает выбор периода для продления.""" + texts = get_texts(db_user.language) + parts = callback.data.split(":") + tariff_id = int(parts[1]) + period = int(parts[2]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + # Получаем скидку для выбранного периода + discount_percent = _get_user_period_discount(db_user, period) + + # Получаем цену + prices = tariff.period_prices or {} + base_price = prices.get(str(period), 0) + final_price = _apply_promo_discount(base_price, discount_percent) + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + + traffic = _format_traffic(tariff.traffic_limit_gb) + + if user_balance >= final_price: + discount_text = "" + if discount_percent > 0: + discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})" + + await callback.message.edit_text( + f"✅ Подтверждение продления\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Период: {_format_period(period)}\n" + f"{discount_text}\n" + f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"После оплаты: {_format_price_kopeks(user_balance - final_price)}", + reply_markup=get_tariff_extend_confirm_keyboard(tariff_id, period, db_user.language), + parse_mode="HTML" + ) + else: + missing = final_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📅 Период: {_format_period(period)}\n" + f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.BACK, callback_data="subscription_extend")] + ]), + parse_mode="HTML" + ) + + await state.update_data( + extend_tariff_id=tariff_id, + extend_period=period, + extend_discount_percent=discount_percent, + ) + await callback.answer() + + +@error_handler +async def confirm_tariff_extend( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает продление по тарифу.""" + parts = callback.data.split(":") + tariff_id = int(parts[1]) + period = int(parts[2]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("Подписка не найдена", show_alert=True) + return + + data = await state.get_data() + discount_percent = data.get('extend_discount_percent', 0) + + # Получаем цену + prices = tariff.period_prices or {} + base_price = prices.get(str(period), 0) + final_price = _apply_promo_discount(base_price, discount_percent) + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + if user_balance < final_price: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем баланс + success = await subtract_user_balance( + db, db_user, final_price, + f"Продление тарифа {tariff.name} на {period} дней" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Продлеваем подписку (параметры тарифа не меняются, только добавляется время) + subscription = await extend_subscription( + db, + subscription, + days=period, + ) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="продление тарифа", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-final_price, + description=f"Продление тарифа {tariff.name} на {period} дней", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, # Транзакция отсутствует, оплата с баланса + period, + was_trial_conversion=False, + amount_kopeks=final_price, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + # Очищаем корзину после успешной покупки + try: + await user_cart_service.delete_user_cart(db_user.id) + logger.info(f"Корзина очищена после продления тарифа для пользователя {db_user.telegram_id}") + except Exception as e: + logger.error(f"Ошибка очистки корзины: {e}") + + await state.clear() + + traffic = _format_traffic(tariff.traffic_limit_gb) + + await callback.message.edit_text( + f"🎉 Подписка успешно продлена!\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Добавлено: {_format_period(period)}\n" + f"💰 Списано: {_format_price_kopeks(final_price)}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Подписка продлена!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при продлении тарифа: {e}", exc_info=True) + await callback.answer("Произошла ошибка при продлении подписки", show_alert=True) + + +# ==================== Переключение тарифов ==================== + +def get_tariff_switch_keyboard( + tariffs: List[Tariff], + current_tariff_id: Optional[int], + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру выбора тарифа для переключения.""" + texts = get_texts(language) + buttons = [] + + for tariff in tariffs: + # Пропускаем текущий тариф + if tariff.id == current_tariff_id: + continue + + prices = tariff.period_prices or {} + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + price_text = f"от {_format_price_kopeks(min_price)}" + else: + price_text = "" + + traffic = _format_traffic(tariff.traffic_limit_gb) + + button_text = f"📦 {tariff.name} • {traffic} • {tariff.device_limit} уст. {price_text}" + buttons.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"tariff_sw_select:{tariff.id}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_tariff_switch_periods_keyboard( + tariff: Tariff, + language: str, + db_user: Optional[User] = None, +) -> InlineKeyboardMarkup: + """Создает клавиатуру выбора периода для переключения тарифа с учетом скидок по периодам.""" + texts = get_texts(language) + buttons = [] + + prices = tariff.period_prices or {} + for period_str in sorted(prices.keys(), key=int): + period = int(period_str) + price = prices[period_str] + + # Получаем скидку для конкретного периода + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, period) + + if discount_percent > 0: + original_price = price + price = _apply_promo_discount(price, discount_percent) + price_text = f"{_format_price_kopeks(price)} (было {_format_price_kopeks(original_price)}, -{discount_percent}%)" + else: + price_text = _format_price_kopeks(price) + + button_text = f"{_format_period(period)} — {price_text}" + buttons.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"tariff_sw_period:{tariff.id}:{period}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="tariff_switch") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_tariff_switch_confirm_keyboard( + tariff_id: int, + period: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру подтверждения переключения тарифа.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Подтвердить переключение", + callback_data=f"tariff_sw_confirm:{tariff_id}:{period}" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"tariff_sw_select:{tariff_id}" + ) + ] + ]) + + +def get_tariff_switch_insufficient_balance_keyboard( + tariff_id: int, + period: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру при недостаточном балансе для переключения.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"tariff_sw_select:{tariff_id}" + ) + ] + ]) + + +@error_handler +async def show_tariff_switch_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает список тарифов для переключения.""" + texts = get_texts(db_user.language) + await state.clear() + + # Проверяем наличие активной подписки + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("У вас нет активной подписки", show_alert=True) + return + + current_tariff_id = subscription.tariff_id + + # Получаем доступные тарифы + promo_group_id = getattr(db_user, 'promo_group_id', None) + tariffs = await get_tariffs_for_user(db, promo_group_id) + + # Фильтруем текущий тариф + available_tariffs = [t for t in tariffs if t.id != current_tariff_id] + + if not available_tariffs: + await callback.message.edit_text( + "😔 Нет доступных тарифов для переключения\n\n" + "Вы уже используете единственный доступный тариф.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + # Получаем текущий тариф для отображения + current_tariff_name = "Неизвестно" + if current_tariff_id: + current_tariff = await get_tariff_by_id(db, current_tariff_id) + if current_tariff: + current_tariff_name = current_tariff.name + + # Проверяем есть ли у пользователя скидки по периодам + promo_group = getattr(db_user, 'promo_group', None) + has_period_discounts = False + if promo_group: + period_discounts = getattr(promo_group, 'period_discounts', None) + if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0: + has_period_discounts = True + + discount_hint = "" + if has_period_discounts: + discount_hint = "\n🎁 Скидки зависят от выбранного периода" + + await callback.message.edit_text( + f"📦 Смена тарифа{discount_hint}\n\n" + f"📌 Ваш текущий тариф: {current_tariff_name}\n\n" + "⚠️ При смене тарифа оплачивается полная стоимость нового тарифа.\n" + "Остаток времени текущей подписки будет сохранён.\n\n" + "Выберите новый тариф:", + reply_markup=get_tariff_switch_keyboard(tariffs, current_tariff_id, db_user.language), + parse_mode="HTML" + ) + + await state.update_data( + current_tariff_id=current_tariff_id, + ) + await callback.answer() + + +@error_handler +async def select_tariff_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает выбор тарифа для переключения.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + traffic = _format_traffic(tariff.traffic_limit_gb) + + info_text = f"""📦 {tariff.name} + +Параметры нового тарифа: +• Трафик: {traffic} +• Устройств: {tariff.device_limit} +""" + + if tariff.description: + info_text += f"\n📝 {tariff.description}\n" + + info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:" + + await callback.message.edit_text( + info_text, + reply_markup=get_tariff_switch_periods_keyboard(tariff, db_user.language, db_user=db_user), + parse_mode="HTML" + ) + + await state.update_data(switch_tariff_id=tariff_id) + await callback.answer() + + +@error_handler +async def select_tariff_switch_period( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает выбор периода для переключения тарифа.""" + from datetime import datetime + + parts = callback.data.split(":") + tariff_id = int(parts[1]) + period = int(parts[2]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + data = await state.get_data() + current_tariff_id = data.get('current_tariff_id') + + # Получаем скидку для выбранного периода + discount_percent = _get_user_period_discount(db_user, period) + + # Получаем цену + prices = tariff.period_prices or {} + base_price = prices.get(str(period), 0) + final_price = _apply_promo_discount(base_price, discount_percent) + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + + traffic = _format_traffic(tariff.traffic_limit_gb) + + # Получаем текущий тариф для отображения + current_tariff_name = "Неизвестно" + if current_tariff_id: + current_tariff = await get_tariff_by_id(db, current_tariff_id) + if current_tariff: + current_tariff_name = current_tariff.name + + # Получаем текущую подписку для расчёта оставшегося времени + subscription = await get_subscription_by_user_id(db, db_user.id) + remaining_days = 0 + if subscription and subscription.end_date: + remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) + + # Определяем что произойдёт с временем + if remaining_days >= period: + time_info = f"⏰ Осталось дней: {remaining_days} (будет сохранено)" + else: + days_to_add = period - remaining_days + time_info = f"⏰ Осталось дней: {remaining_days} → будет {period} (+{days_to_add})" + + if user_balance >= final_price: + discount_text = "" + if discount_percent > 0: + discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})" + + await callback.message.edit_text( + f"✅ Подтверждение переключения тарифа\n\n" + f"📌 Текущий тариф: {current_tariff_name}\n" + f"📦 Новый тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"{time_info}\n" + f"{discount_text}\n" + f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"После оплаты: {_format_price_kopeks(user_balance - final_price)}", + reply_markup=get_tariff_switch_confirm_keyboard(tariff_id, period, db_user.language), + parse_mode="HTML" + ) + else: + missing = final_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📅 Период: {_format_period(period)}\n" + f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=get_tariff_switch_insufficient_balance_keyboard(tariff_id, period, db_user.language), + parse_mode="HTML" + ) + + await state.update_data( + switch_tariff_id=tariff_id, + switch_period=period, + switch_final_price=final_price, + ) + await callback.answer() + + +@error_handler +async def confirm_tariff_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает переключение тарифа.""" + parts = callback.data.split(":") + tariff_id = int(parts[1]) + period = int(parts[2]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + # Получаем скидку для выбранного периода + discount_percent = _get_user_period_discount(db_user, period) + + # Получаем цену + prices = tariff.period_prices or {} + base_price = prices.get(str(period), 0) + final_price = _apply_promo_discount(base_price, discount_percent) + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + if user_balance < final_price: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + # Проверяем наличие подписки + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("У вас нет активной подписки", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем баланс + success = await subtract_user_balance( + db, db_user, final_price, + f"Смена тарифа на {tariff.name} ({period} дней)" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Рассчитываем сколько дней осталось у текущей подписки + from datetime import datetime + remaining_days = (subscription.end_date - datetime.utcnow()).days + if remaining_days < 0: + remaining_days = 0 + + # Если выбранный период больше оставшегося - добавляем разницу + # Пользователь должен получить минимум то, за что заплатил + days_to_add = max(0, period - remaining_days) + + # Обновляем подписку с новыми параметрами тарифа + subscription = await extend_subscription( + db, + subscription, + days=days_to_add, # Добавляем только разницу, если период > остатка + tariff_id=tariff.id, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + ) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=True, + reset_reason="переключение тарифа", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave при переключении тарифа: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-final_price, + description=f"Смена тарифа на {tariff.name}", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, # Транзакция отсутствует, оплата с баланса + days_to_add, # Добавленные дни (0 если остаток >= периода) + was_trial_conversion=False, + amount_kopeks=final_price, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + # Очищаем корзину после успешной покупки + try: + await user_cart_service.delete_user_cart(db_user.id) + logger.info(f"Корзина очищена после смены тарифа для пользователя {db_user.telegram_id}") + except Exception as e: + logger.error(f"Ошибка очистки корзины: {e}") + + await state.clear() + + traffic = _format_traffic(tariff.traffic_limit_gb) + + # Формируем текст о времени подписки + if days_to_add > 0: + time_info = f"📅 Добавлено дней: {days_to_add}" + else: + time_info = "📅 Остаток времени подписки сохранён" + + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"💰 Списано: {_format_price_kopeks(final_price)}\n" + f"{time_info}\n\n" + f"Перейдите в раздел «Подписка» для просмотра деталей.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Тариф изменён!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при переключении тарифа: {e}", exc_info=True) + await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) + + +def register_tariff_purchase_handlers(dp: Dispatcher): + """Регистрирует обработчики покупки по тарифам.""" + # Список тарифов (для режима tariffs) + dp.callback_query.register(show_tariffs_list, F.data == "tariff_list") + dp.callback_query.register(show_tariffs_list, F.data == "buy_subscription_tariffs") + + # Выбор тарифа + dp.callback_query.register(select_tariff, F.data.startswith("tariff_select:")) + + # Выбор периода + dp.callback_query.register(select_tariff_period, F.data.startswith("tariff_period:")) + + # Подтверждение покупки + dp.callback_query.register(confirm_tariff_purchase, F.data.startswith("tariff_confirm:")) + + # Продление по тарифу + dp.callback_query.register(select_tariff_extend_period, F.data.startswith("tariff_extend:")) + dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:")) + + # Переключение тарифов + dp.callback_query.register(show_tariff_switch_list, F.data == "tariff_switch") + dp.callback_query.register(select_tariff_switch, F.data.startswith("tariff_sw_select:")) + dp.callback_query.register(select_tariff_switch_period, F.data.startswith("tariff_sw_period:")) + dp.callback_query.register(confirm_tariff_switch, F.data.startswith("tariff_sw_confirm:")) diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index b5fd0687..1c1a8de5 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -107,6 +107,17 @@ async def handle_add_traffic( ) return + # В режиме тарифов докупка трафика недоступна + if settings.is_tariffs_mode(): + await callback.answer( + texts.t( + "TARIFF_TRAFFIC_TOPUP_DISABLED", + "⚠️ В режиме тарифов докупка трафика недоступна", + ), + show_alert=True, + ) + return + if settings.is_traffic_topup_blocked(): await callback.answer( texts.t( diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index afc12b52..7f04df86 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -24,10 +24,16 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ), ], [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_MAIN_TARIFFS", "📦 Тарифы"), + callback_data="admin_tariffs", + ), InlineKeyboardButton( text=_t(texts, "ADMIN_MAIN_PRICING", "💰 Цены"), callback_data="admin_pricing", ), + ], + [ InlineKeyboardButton( text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"), callback_data="admin_submenu_promo", diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 62586101..5d9309a4 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -993,6 +993,14 @@ def get_subscription_keyboard( callback_data="subscription_settings", ) ]) + # Кнопка смены тарифа для режима тарифов + if settings.is_tariffs_mode() and subscription: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Сменить тариф"), + callback_data="tariff_switch" + ) + ]) # Кнопка докупки трафика для платных подписок if ( settings.is_traffic_topup_enabled() @@ -1783,28 +1791,37 @@ def get_change_devices_keyboard( language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None, discount_percent: int = 0, + tariff=None, # Тариф для цены за устройство ) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - - device_price_per_month = settings.PRICE_PER_DEVICE - + + # Используем цену из тарифа если есть, иначе глобальную настройку + tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None + if tariff and tariff_device_price: + device_price_per_month = tariff_device_price + # Для тарифов все устройства платные (нет бесплатного лимита) + default_device_limit = 0 + else: + device_price_per_month = settings.PRICE_PER_DEVICE + default_device_limit = settings.DEFAULT_DEVICE_LIMIT + buttons = [] - - min_devices = 1 + + min_devices = 1 max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20 - + start_range = max(1, min(current_devices - 3, max_devices - 6)) end_range = min(max_devices + 1, max(current_devices + 4, 7)) - + for devices_count in range(start_range, end_range): if devices_count == current_devices: emoji = "✅" @@ -1813,11 +1830,11 @@ def get_change_devices_keyboard( elif devices_count > current_devices: emoji = "➕" additional_devices = devices_count - current_devices - - current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT) - new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT) + + current_chargeable = max(0, current_devices - default_device_limit) + new_chargeable = max(0, devices_count - default_device_limit) chargeable_devices = new_chargeable - current_chargeable - + if chargeable_devices > 0: price_per_month = chargeable_devices * device_price_per_month discounted_per_month, discount_per_month = apply_percentage_discount( @@ -1839,19 +1856,19 @@ def get_change_devices_keyboard( emoji = "➖" action_text = "" price_text = " (без возврата)" - + button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}" - + buttons.append([ InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}") ]) - + if current_devices < start_range or current_devices >= end_range: current_button = f"✅ {current_devices} устр. (текущее)" buttons.insert(0, [ InlineKeyboardButton(text=current_button, callback_data=f"change_devices_{current_devices}") ]) - + buttons.append([ InlineKeyboardButton( text=texts.BACK, @@ -2402,18 +2419,25 @@ def get_devices_management_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, show_countries_management: bool = True) -> InlineKeyboardMarkup: +def get_updated_subscription_settings_keyboard( + language: str = DEFAULT_LANGUAGE, + show_countries_management: bool = True, + tariff=None, # Тариф подписки (если есть - ограничиваем настройки) +) -> InlineKeyboardMarkup: from app.config import settings - + texts = get_texts(language) keyboard = [] - if show_countries_management: + # Если подписка на тарифе - отключаем страны, модем, трафик + has_tariff = tariff is not None + + if show_countries_management and not has_tariff: keyboard.append([ InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries") ]) - if settings.is_traffic_selectable(): + if settings.is_traffic_selectable() and not has_tariff: keyboard.append([ InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic") ]) @@ -2421,7 +2445,17 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic") ]) - if settings.is_devices_selection_enabled(): + # Устройства: для тарифов - только если указана цена за устройство + if has_tariff: + tariff_device_price = getattr(tariff, 'device_price_kopeks', None) + if tariff_device_price is not None and tariff_device_price > 0: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), + callback_data="subscription_change_devices" + ) + ]) + elif settings.is_devices_selection_enabled(): keyboard.append([ InlineKeyboardButton( text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), @@ -2429,7 +2463,7 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, ) ]) - if settings.is_modem_enabled(): + if settings.is_modem_enabled() and not has_tariff: keyboard.append([ InlineKeyboardButton( text=texts.t("MODEM_BUTTON", "📡 Модем"), diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 9062de15..14866b55 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -666,6 +666,7 @@ "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта", "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:", "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n", + "ADMIN_SETTINGS_TARIFFS": "📦 Тарифы", "ADMIN_SQUAD_ADD_ALL": "👥 Добавить всех пользователей", "ADMIN_SQUAD_DELETE": "🗑️ Удалить сквад", "ADMIN_SQUAD_EDIT": "✏️ Редактировать", @@ -940,6 +941,7 @@ "CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ", "CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ", "CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", + "CHANGE_TARIFF_BUTTON": "📦 Сменить тариф", "CHANNEL_CHECK_BUTTON": "✅ Я подписался", "CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.", "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться", diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index 5ce989b0..f7b6f5fe 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -719,6 +719,9 @@ class MenuLayoutService: if conditions.get("traffic_topup_enabled") is True: if not settings.is_traffic_topup_enabled(): return False + # В режиме тарифов докупка трафика недоступна + if settings.is_tariffs_mode(): + return False # is_admin if conditions.get("is_admin") is True: diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index fdeacad9..d6760ac4 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -134,6 +134,57 @@ def _safe_int(value: Optional[object], default: int = 0) -> int: return default +def _apply_promo_discount_for_tariff(price: int, discount_percent: int) -> int: + """Применяет скидку промогруппы к цене тарифа.""" + if discount_percent <= 0: + return price + discount = int(price * discount_percent / 100) + return max(0, price - discount) + + +async def _get_tariff_price_for_period( + db: AsyncSession, + user: User, + tariff_id: int, + period_days: int, +) -> Optional[int]: + """Получает актуальную цену тарифа для заданного периода с учётом скидки пользователя.""" + from app.database.crud.tariff import get_tariff_by_id + from app.utils.promo_offer import get_user_active_promo_discount_percent + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + logger.warning( + "🔁 Автопокупка: тариф %s недоступен для пользователя %s", + tariff_id, + user.telegram_id, + ) + return None + + prices = tariff.period_prices or {} + base_price = prices.get(str(period_days)) + if base_price is None: + logger.warning( + "🔁 Автопокупка: период %s дней недоступен для тарифа %s", + period_days, + tariff_id, + ) + return None + + # Получаем скидку пользователя + discount_percent = 0 + promo_group = getattr(user, 'promo_group', None) + if promo_group: + discount_percent = getattr(promo_group, 'server_discount_percent', 0) + + personal_discount = get_user_active_promo_discount_percent(user) + if personal_discount > discount_percent: + discount_percent = personal_discount + + final_price = _apply_promo_discount_for_tariff(base_price, discount_percent) + return final_price + + async def _prepare_auto_extend_context( db: AsyncSession, user: User, @@ -162,11 +213,6 @@ async def _prepare_auto_extend_context( return None period_days = _safe_int(cart_data.get("period_days")) - price_kopeks = _safe_int( - cart_data.get("total_price") - or cart_data.get("price") - or cart_data.get("final_price"), - ) if period_days <= 0: logger.warning( @@ -176,6 +222,30 @@ async def _prepare_auto_extend_context( ) return None + # Если в корзине есть tariff_id - пересчитываем цену по актуальному тарифу + tariff_id = cart_data.get("tariff_id") + if tariff_id: + tariff_id = _safe_int(tariff_id) + price_kopeks = await _get_tariff_price_for_period(db, user, tariff_id, period_days) + if price_kopeks is None: + # Тариф недоступен или период отсутствует - используем сохранённую цену как fallback + price_kopeks = _safe_int( + cart_data.get("total_price") + or cart_data.get("price") + or cart_data.get("final_price"), + ) + logger.warning( + "🔁 Автопокупка: не удалось пересчитать цену тарифа %s, используем сохранённую: %s", + tariff_id, + price_kopeks, + ) + else: + price_kopeks = _safe_int( + cart_data.get("total_price") + or cart_data.get("price") + or cart_data.get("final_price"), + ) + if price_kopeks <= 0: logger.warning( "🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s", @@ -184,7 +254,14 @@ async def _prepare_auto_extend_context( ) return None - description = cart_data.get("description") or f"Продление подписки на {period_days} дней" + # Формируем описание с учётом тарифа + if tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + tariff_name = tariff.name if tariff else "тариф" + description = cart_data.get("description") or f"Продление тарифа {tariff_name} на {period_days} дней" + else: + description = cart_data.get("description") or f"Продление подписки на {period_days} дней" device_limit = cart_data.get("device_limit") if device_limit is not None: diff --git a/app/states.py b/app/states.py index 5c2a5a68..b0ba70d5 100644 --- a/app/states.py +++ b/app/states.py @@ -158,6 +158,27 @@ class AdminStates(StatesGroup): viewing_user_from_campaign_list = State() viewing_user_from_ready_to_renew_list = State() + # Состояния для управления тарифами + creating_tariff_name = State() + creating_tariff_description = State() + creating_tariff_traffic = State() + creating_tariff_devices = State() + creating_tariff_tier = State() + creating_tariff_prices = State() + creating_tariff_squads = State() + + editing_tariff_name = State() + editing_tariff_description = State() + editing_tariff_traffic = State() + editing_tariff_devices = State() + editing_tariff_tier = State() + editing_tariff_prices = State() + editing_tariff_device_price = State() + editing_tariff_trial_days = State() + editing_tariff_squads = State() + editing_tariff_promo_groups = State() + + class SupportStates(StatesGroup): waiting_for_message = State() diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 9aead7f1..f6beb775 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -33,6 +33,7 @@ from app.database.crud.server_squad import ( get_server_squad_by_uuid, remove_user_from_servers, ) +from app.database.crud.tariff import get_all_tariffs, get_tariff_by_id, get_tariffs_for_user from app.database.crud.subscription import ( add_subscription_servers, create_trial_subscription, @@ -183,6 +184,14 @@ from ..schemas.miniapp import ( MiniAppSubscriptionRenewalPeriod, MiniAppSubscriptionRenewalRequest, MiniAppSubscriptionRenewalResponse, + MiniAppTariff, + MiniAppTariffPeriod, + MiniAppTariffsRequest, + MiniAppTariffsResponse, + MiniAppTariffPurchaseRequest, + MiniAppTariffPurchaseResponse, + MiniAppCurrentTariff, + MiniAppConnectedServer, ) @@ -3493,10 +3502,36 @@ async def get_subscription_details( trial_payment_required=trial_payment_required, trial_price_kopeks=trial_price_kopeks if trial_payment_required else None, trial_price_label=trial_price_label, + sales_mode=settings.get_sales_mode(), + current_tariff=await _get_current_tariff_model(db, subscription) if subscription else None, **autopay_extras, ) +async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[MiniAppCurrentTariff]: + """Возвращает модель текущего тарифа пользователя.""" + if not subscription or not getattr(subscription, "tariff_id", None): + return None + + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not tariff: + return None + + servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 + + return MiniAppCurrentTariff( + id=tariff.id, + name=tariff.name, + description=tariff.description, + tier_level=tariff.tier_level, + traffic_limit_gb=tariff.traffic_limit_gb, + traffic_limit_label=_format_traffic_limit_label(tariff.traffic_limit_gb) if settings.is_tariffs_mode() else f"{tariff.traffic_limit_gb} ГБ", + is_unlimited_traffic=tariff.traffic_limit_gb == 0, + device_limit=tariff.device_limit, + servers_count=servers_count, + ) + + @router.post( "/subscription/autopay", response_model=MiniAppSubscriptionAutopayResponse, @@ -3663,11 +3698,47 @@ async def activate_subscription_trial_endpoint( if not settings.is_devices_selection_enabled(): forced_devices = settings.get_disabled_mode_device_limit() + # Получаем параметры триала для режима тарифов + trial_traffic_limit = None + trial_device_limit = forced_devices + trial_squads = None + tariff_id_for_trial = None + trial_duration = None # None = использовать TRIAL_DURATION_DAYS + + if settings.is_tariffs_mode(): + try: + from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff + + trial_tariff = await get_trial_tariff(db) + + if not trial_tariff: + trial_tariff_id = settings.get_trial_tariff_id() + if trial_tariff_id > 0: + trial_tariff = await get_tariff_by_id(db, trial_tariff_id) + if trial_tariff and not trial_tariff.is_active: + trial_tariff = None + + if trial_tariff: + trial_traffic_limit = trial_tariff.traffic_limit_gb + trial_device_limit = trial_tariff.device_limit + trial_squads = trial_tariff.allowed_squads or [] + tariff_id_for_trial = trial_tariff.id + tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) + if tariff_trial_days: + trial_duration = tariff_trial_days + logger.info(f"Miniapp: используем триальный тариф {trial_tariff.name}") + except Exception as e: + logger.error(f"Ошибка получения триального тарифа: {e}") + try: subscription = await create_trial_subscription( db, user.id, - device_limit=forced_devices, + duration_days=trial_duration, + device_limit=trial_device_limit, + traffic_limit_gb=trial_traffic_limit, + connected_squads=trial_squads, + tariff_id=tariff_id_for_trial, ) except Exception as error: # pragma: no cover - defensive logging logger.error( @@ -5905,3 +5976,278 @@ async def update_subscription_devices_endpoint( ) return MiniAppSubscriptionUpdateResponse(success=True) + + +# ============================================================================= +# Тарифы для режима продаж "Тарифы" +# ============================================================================= + +def _format_traffic_limit_label(traffic_gb: int) -> str: + """Форматирует лимит трафика для отображения.""" + if traffic_gb == 0: + return "♾️ Безлимит" + return f"{traffic_gb} ГБ" + + +async def _build_tariff_model( + db: AsyncSession, + tariff, + current_tariff_id: Optional[int] = None, +) -> MiniAppTariff: + """Преобразует объект тарифа в модель для API.""" + servers: List[MiniAppConnectedServer] = [] + servers_count = 0 + + if tariff.allowed_squads: + servers_count = len(tariff.allowed_squads) + for squad_uuid in tariff.allowed_squads[:5]: # Ограничиваем для превью + server = await get_server_squad_by_uuid(db, squad_uuid) + if server: + servers.append(MiniAppConnectedServer( + uuid=squad_uuid, + name=server.display_name or squad_uuid[:8], + )) + + periods: List[MiniAppTariffPeriod] = [] + if tariff.period_prices: + for period_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): + period_days = int(period_str) + months = max(1, period_days // 30) + per_month = price_kopeks // months if months > 0 else price_kopeks + + periods.append(MiniAppTariffPeriod( + days=period_days, + months=months, + label=format_period_description(period_days), + price_kopeks=price_kopeks, + price_label=settings.format_price(price_kopeks), + price_per_month_kopeks=per_month, + price_per_month_label=settings.format_price(per_month), + )) + + return MiniAppTariff( + id=tariff.id, + name=tariff.name, + description=tariff.description, + tier_level=tariff.tier_level, + traffic_limit_gb=tariff.traffic_limit_gb, + traffic_limit_label=_format_traffic_limit_label(tariff.traffic_limit_gb), + is_unlimited_traffic=tariff.traffic_limit_gb == 0, + device_limit=tariff.device_limit, + servers_count=servers_count, + servers=servers, + periods=periods, + is_current=current_tariff_id == tariff.id if current_tariff_id else False, + is_available=tariff.is_active, + ) + + +async def _build_current_tariff_model(db: AsyncSession, tariff) -> MiniAppCurrentTariff: + """Создаёт модель текущего тарифа.""" + servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 + + return MiniAppCurrentTariff( + id=tariff.id, + name=tariff.name, + description=tariff.description, + tier_level=tariff.tier_level, + traffic_limit_gb=tariff.traffic_limit_gb, + traffic_limit_label=_format_traffic_limit_label(tariff.traffic_limit_gb), + is_unlimited_traffic=tariff.traffic_limit_gb == 0, + device_limit=tariff.device_limit, + servers_count=servers_count, + ) + + +@router.post("/subscription/tariffs", response_model=MiniAppTariffsResponse) +async def get_tariffs_endpoint( + payload: MiniAppTariffsRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppTariffsResponse: + """Возвращает список доступных тарифов для пользователя.""" + user = await _authorize_miniapp_user(payload.init_data, db) + + # Проверяем режим продаж + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "tariffs_mode_disabled", + "message": "Tariffs mode is not enabled", + }, + ) + + # Получаем промогруппу пользователя + promo_group = getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + + # Получаем тарифы, доступные пользователю + tariffs = await get_tariffs_for_user(db, promo_group_id) + + # Текущий тариф пользователя + subscription = getattr(user, "subscription", None) + current_tariff_id = subscription.tariff_id if subscription else None + current_tariff_model: Optional[MiniAppCurrentTariff] = None + + if current_tariff_id: + current_tariff = await get_tariff_by_id(db, current_tariff_id) + if current_tariff: + current_tariff_model = await _build_current_tariff_model(db, current_tariff) + + # Формируем список тарифов + tariff_models: List[MiniAppTariff] = [] + for tariff in tariffs: + model = await _build_tariff_model(db, tariff, current_tariff_id) + tariff_models.append(model) + + return MiniAppTariffsResponse( + success=True, + sales_mode="tariffs", + tariffs=tariff_models, + current_tariff=current_tariff_model, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + ) + + +@router.post("/subscription/tariff/purchase", response_model=MiniAppTariffPurchaseResponse) +async def purchase_tariff_endpoint( + payload: MiniAppTariffPurchaseRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppTariffPurchaseResponse: + """Покупка или смена тарифа.""" + user = await _authorize_miniapp_user(payload.init_data, db) + + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "tariffs_mode_disabled", + "message": "Tariffs mode is not enabled", + }, + ) + + tariff = await get_tariff_by_id(db, payload.tariff_id) + if not tariff or not tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "tariff_not_found", + "message": "Tariff not found or inactive", + }, + ) + + # Проверяем доступность тарифа для пользователя + promo_group = getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "tariff_not_available", + "message": "This tariff is not available for your promo group", + }, + ) + + # Получаем цену за выбранный период + price_kopeks = tariff.get_price_for_period(payload.period_days) + if price_kopeks is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_period", + "message": "Invalid period for this tariff", + }, + ) + + # Проверяем баланс + if user.balance_kopeks < price_kopeks: + missing = price_kopeks - user.balance_kopeks + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_funds", + "message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}", + "missing_amount": missing, + }, + ) + + subscription = getattr(user, "subscription", None) + + # Списываем баланс + description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней" + success = await subtract_user_balance(db, user, price_kopeks, description) + if not success: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={ + "code": "balance_charge_failed", + "message": "Failed to charge balance", + }, + ) + + # Создаём транзакцию + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=price_kopeks, + description=description, + ) + + if subscription: + # Смена/продление тарифа + subscription = await extend_subscription( + db=db, + subscription=subscription, + days=payload.period_days, + tariff_id=tariff.id, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=tariff.allowed_squads or [], + ) + else: + # Создание новой подписки + from app.database.crud.subscription import create_paid_subscription + subscription = await create_paid_subscription( + db=db, + user_id=user.id, + days=payload.period_days, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=tariff.allowed_squads or [], + tariff_id=tariff.id, + ) + + # Синхронизируем с RemnaWave + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + + # Сохраняем корзину для автопродления + try: + from app.services.user_cart_service import user_cart_service + cart_data = { + "cart_mode": "extend", + "subscription_id": subscription.id, + "period_days": payload.period_days, + "total_price": price_kopeks, + "tariff_id": tariff.id, + "description": f"Продление тарифа {tariff.name} на {payload.period_days} дней", + } + await user_cart_service.save_user_cart(user.id, cart_data) + logger.info(f"Корзина тарифа сохранена для автопродления (miniapp) пользователя {user.telegram_id}") + except Exception as e: + logger.error(f"Ошибка сохранения корзины тарифа (miniapp): {e}") + + await db.refresh(user) + + return MiniAppTariffPurchaseResponse( + success=True, + message=f"Тариф '{tariff.name}' успешно активирован", + subscription_id=subscription.id, + tariff_id=tariff.id, + tariff_name=tariff.name, + new_end_date=subscription.end_date, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 34c1d51a..73360418 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -487,6 +487,85 @@ class MiniAppPaymentStatusResponse(BaseModel): results: List[MiniAppPaymentStatusResult] = Field(default_factory=list) +# ============================================================================= +# Тарифы для режима продаж "Тарифы" +# ============================================================================= + +class MiniAppTariffPeriod(BaseModel): + """Период тарифа с ценой.""" + days: int + months: Optional[int] = None + label: str + price_kopeks: int + price_label: str + price_per_month_kopeks: Optional[int] = None + price_per_month_label: Optional[str] = None + + +class MiniAppTariff(BaseModel): + """Тариф для отображения в miniapp.""" + id: int + name: str + description: Optional[str] = None + tier_level: int = 1 + traffic_limit_gb: int + traffic_limit_label: str + is_unlimited_traffic: bool = False + device_limit: int + servers_count: int + servers: List[MiniAppConnectedServer] = Field(default_factory=list) + periods: List[MiniAppTariffPeriod] = Field(default_factory=list) + is_current: bool = False + is_available: bool = True + + +class MiniAppCurrentTariff(BaseModel): + """Текущий тариф пользователя.""" + id: int + name: str + description: Optional[str] = None + tier_level: int = 1 + traffic_limit_gb: int + traffic_limit_label: str + is_unlimited_traffic: bool = False + device_limit: int + servers_count: int + + +class MiniAppTariffsRequest(BaseModel): + """Запрос списка тарифов.""" + init_data: str = Field(..., alias="initData") + + +class MiniAppTariffsResponse(BaseModel): + """Ответ со списком тарифов.""" + success: bool = True + sales_mode: str = "tariffs" + tariffs: List[MiniAppTariff] = Field(default_factory=list) + current_tariff: Optional[MiniAppCurrentTariff] = None + balance_kopeks: int = 0 + balance_label: Optional[str] = None + + +class MiniAppTariffPurchaseRequest(BaseModel): + """Запрос на покупку/смену тарифа.""" + init_data: str = Field(..., alias="initData") + tariff_id: int = Field(..., alias="tariffId") + period_days: int = Field(..., alias="periodDays") + + +class MiniAppTariffPurchaseResponse(BaseModel): + """Ответ на покупку тарифа.""" + success: bool = True + message: Optional[str] = None + subscription_id: Optional[int] = None + tariff_id: Optional[int] = None + tariff_name: Optional[str] = None + new_end_date: Optional[datetime] = None + balance_kopeks: Optional[int] = None + balance_label: Optional[str] = None + + class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: Optional[int] = None @@ -535,6 +614,10 @@ class MiniAppSubscriptionResponse(BaseModel): trial_price_kopeks: Optional[int] = Field(default=None, alias="trialPriceKopeks") trial_price_label: Optional[str] = Field(default=None, alias="trialPriceLabel") + # Режим продаж и тариф + sales_mode: str = Field(default="classic", alias="salesMode") + current_tariff: Optional[MiniAppCurrentTariff] = Field(default=None, alias="currentTariff") + model_config = ConfigDict(extra="allow", populate_by_name=True) diff --git a/miniapp/index.html b/miniapp/index.html index cc85c05f..e07f5459 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -5135,6 +5135,43 @@ + + +