From 031c2b683b78905106cc96439c0473aaa44310ae Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:14:41 +0300 Subject: [PATCH 01/50] Add files via upload --- app/handlers/subscription/purchase.py | 81 +- app/handlers/subscription/tariff_purchase.py | 1304 ++++++++++++++++++ 2 files changed, 1383 insertions(+), 2 deletions(-) create mode 100644 app/handlers/subscription/tariff_purchase.py diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index a5599911..d83193f1 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, @@ -668,10 +680,36 @@ async def activate_trial( if not settings.is_devices_selection_enabled(): forced_devices = settings.get_disabled_mode_device_limit() + # Проверяем, настроен ли триальный тариф для режима тарифов + trial_tariff_id = settings.get_trial_tariff_id() + trial_tariff = None + trial_traffic_limit = None + trial_device_limit = forced_devices + trial_squads = None + tariff_id_for_trial = None + + if settings.is_tariffs_mode() and trial_tariff_id > 0: + try: + from app.database.crud.tariff import get_tariff_by_id + trial_tariff = await get_tariff_by_id(db, trial_tariff_id) + if trial_tariff and trial_tariff.is_active: + 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 + logger.info(f"Используем триальный тариф {trial_tariff.name} (ID: {trial_tariff_id})") + else: + logger.warning(f"Триальный тариф {trial_tariff_id} не найден или неактивен") + except Exception as e: + logger.error(f"Ошибка получения триального тарифа: {e}") + subscription = await create_trial_subscription( db, db_user.id, - device_limit=forced_devices, + 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 +1086,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 +1367,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() @@ -3895,6 +3968,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..6e87eb4c --- /dev/null +++ b/app/handlers/subscription/tariff_purchase.py @@ -0,0 +1,1304 @@ +"""Покупка подписки по тарифам.""" +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_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, + discount_percent: int = 0, +) -> 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] + + 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)})" + 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="topup_balance" + ) + ], + [ + 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() + + # Получаем скидку пользователя + discount_percent = 0 + promo_group = getattr(db_user, 'promo_group', None) + if promo_group: + # Используем скидку на серверы как общую скидку на тарифы + discount_percent = getattr(promo_group, 'server_discount_percent', 0) + + # Также проверяем персональную скидку + personal_discount = await get_user_active_promo_discount_percent(db_user.id, db) + if personal_discount > discount_percent: + discount_percent = personal_discount + + # Получаем доступные тарифы + 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 + + discount_text = "" + if discount_percent > 0: + discount_text = f"\n\n🎁 Ваша скидка: {discount_percent}%" + + await callback.message.edit_text( + f"📦 Выберите тариф{discount_text}\n\n" + "Выберите подходящий тариф из списка:", + reply_markup=get_tariffs_keyboard(tariffs, db_user.language, discount_percent), + parse_mode="HTML" + ) + + await state.update_data(tariff_discount_percent=discount_percent) + 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 + + data = await state.get_data() + discount_percent = data.get('tariff_discount_percent', 0) + + await callback.message.edit_text( + format_tariff_info_for_user(tariff, db_user.language, discount_percent), + reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, discount_percent), + 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 + + data = await state.get_data() + discount_percent = data.get('tariff_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 + + 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, + ) + 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 + + data = await state.get_data() + discount_percent = data.get('tariff_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.id, final_price) + 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, + final_price, + period, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + # Сохраняем корзину для автопродления + try: + cart_data = { + "cart_mode": "extend", + "subscription_id": subscription.id, + "period_days": period, + "total_price": final_price, + "tariff_id": tariff.id, + "description": f"Продление тарифа {tariff.name} на {period} дней", + } + await user_cart_service.save_user_cart(db_user.id, cart_data) + 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, + discount_percent: int = 0, +) -> 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] + + 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)})" + 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 + + # Получаем скидку пользователя + discount_percent = 0 + promo_group = getattr(db_user, 'promo_group', None) + if promo_group: + discount_percent = getattr(promo_group, 'server_discount_percent', 0) + + personal_discount = await get_user_active_promo_discount_percent(db_user.id, db) + if personal_discount > discount_percent: + discount_percent = personal_discount + + traffic = _format_traffic(tariff.traffic_limit_gb) + + discount_text = "" + if discount_percent > 0: + discount_text = f"\n🎁 Ваша скидка: {discount_percent}%" + + await callback.message.edit_text( + f"🔄 Продление подписки{discount_text}\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, discount_percent), + parse_mode="HTML" + ) + await callback.answer() + + +@error_handler +async def select_tariff_extend_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 = 0 + promo_group = getattr(db_user, 'promo_group', None) + if promo_group: + discount_percent = getattr(promo_group, 'server_discount_percent', 0) + + personal_discount = await get_user_active_promo_discount_percent(db_user.id, db) + if personal_discount > discount_percent: + discount_percent = personal_discount + + # Получаем цену + 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="topup_balance")], + [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.id, final_price) + 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, + final_price, + period, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + # Сохраняем корзину для автопродления + try: + cart_data = { + "cart_mode": "extend", + "subscription_id": subscription.id, + "period_days": period, + "total_price": final_price, + "tariff_id": tariff.id, + "description": f"Продление тарифа {tariff.name} на {period} дней", + } + await user_cart_service.save_user_cart(db_user.id, cart_data) + 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, + discount_percent: int = 0, +) -> 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] + 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_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, + discount_percent: int = 0, +) -> 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] + + 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)})" + 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="topup_balance" + ) + ], + [ + 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 + + # Получаем скидку пользователя + discount_percent = 0 + promo_group = getattr(db_user, 'promo_group', None) + if promo_group: + discount_percent = getattr(promo_group, 'server_discount_percent', 0) + + personal_discount = await get_user_active_promo_discount_percent(db_user.id, db) + if personal_discount > discount_percent: + discount_percent = personal_discount + + # Получаем доступные тарифы + 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 + + discount_text = "" + if discount_percent > 0: + discount_text = f"\n\n🎁 Ваша скидка: {discount_percent}%" + + await callback.message.edit_text( + f"📦 Смена тарифа{discount_text}\n\n" + f"📌 Ваш текущий тариф: {current_tariff_name}\n\n" + "⚠️ При смене тарифа оплачивается полная стоимость нового тарифа.\n" + "Остаток времени текущей подписки будет сохранён.\n\n" + "Выберите новый тариф:", + reply_markup=get_tariff_switch_keyboard(tariffs, current_tariff_id, db_user.language, discount_percent), + parse_mode="HTML" + ) + + await state.update_data( + tariff_switch_discount_percent=discount_percent, + 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 + + data = await state.get_data() + discount_percent = data.get('tariff_switch_discount_percent', 0) + + 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" + + if discount_percent > 0: + info_text += f"\n🎁 Ваша скидка: {discount_percent}%\n" + + info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:" + + await callback.message.edit_text( + info_text, + reply_markup=get_tariff_switch_periods_keyboard(tariff, db_user.language, discount_percent), + 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, +): + """Обрабатывает выбор периода для переключения тарифа.""" + 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() + discount_percent = data.get('tariff_switch_discount_percent', 0) + current_tariff_id = data.get('current_tariff_id') + + # Получаем цену + 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 + + 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"📅 Добавляется период: {_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)}\n\n" + f"⚠️ Остаток времени текущей подписки будет сохранён.", + 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 + + data = await state.get_data() + discount_percent = data.get('tariff_switch_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 + + # Проверяем наличие подписки + 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.id, final_price) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Обновляем подписку с новыми параметрами тарифа и добавляем период + subscription = await extend_subscription( + db, + subscription, + days=period, + 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} на {period} дней", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + final_price, + period, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + # Сохраняем корзину для автопродления (с новым тарифом) + try: + cart_data = { + "cart_mode": "extend", + "subscription_id": subscription.id, + "period_days": period, + "total_price": final_price, + "tariff_id": tariff.id, + "description": f"Продление тарифа {tariff.name} на {period} дней", + } + await user_cart_service.save_user_cart(db_user.id, cart_data) + 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 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:")) From a981bf2ae0cdc7015cd17af561403241f0f17dd2 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:15:16 +0300 Subject: [PATCH 02/50] Add files via upload --- app/handlers/admin/tariffs.py | 1617 +++++++++++++++++++++++++++++++++ app/handlers/admin/users.py | 265 +++++- 2 files changed, 1881 insertions(+), 1 deletion(-) create mode 100644 app/handlers/admin/tariffs.py diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py new file mode 100644 index 00000000..3ef6bc33 --- /dev/null +++ b/app/handlers/admin/tariffs.py @@ -0,0 +1,1617 @@ +"""Управление тарифами в админ-панели.""" +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_squads 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_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_squads:{tariff.id}"), + InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{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 "❌ Нет" + + return f"""📦 Тариф: {tariff.name} + +{status} +🎚️ Уровень: {tariff.tier_level} +📊 Порядок: {tariff.display_order} + +Параметры: +• Трафик: {traffic} +• Устройств: {tariff.device_limit} +• Триал: {trial_status} + +Цены: +{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_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_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 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 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_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:")) + + # Создание тарифа + 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(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..dd8f3323 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, only_active=True) + + 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 From cff00eb51504df89bafc1479bef9eb02f387314c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:16:00 +0300 Subject: [PATCH 03/50] Add files via upload --- app/database/models.py | 102 ++++++++++++++- app/database/universal_migration.py | 185 ++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 2 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index 2cfbf892..57cf4669 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,81 @@ 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) + + # Сквады (серверы) доступные в тарифе + 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 +954,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 +2206,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..7b4fee90 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5049,6 +5049,172 @@ 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 run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -5526,6 +5692,25 @@ 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") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: From b50478eda0a0c3336a3c7cd59b305134ca79664b Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:16:33 +0300 Subject: [PATCH 04/50] Add files via upload --- app/database/crud/subscription.py | 102 +++++++-- app/database/crud/tariff.py | 359 ++++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+), 22 deletions(-) create mode 100644 app/database/crud/tariff.py 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..51150768 --- /dev/null +++ b/app/database/crud/tariff.py @@ -0,0 +1,359 @@ +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_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, + 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 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) From 3150349ffa8c5cb32c03c10f65234fa1699d7e05 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:17:18 +0300 Subject: [PATCH 05/50] Update config.py --- app/config.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/app/config.py b/app/config.py index 1f0b8d0f..c36b0aff 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) From 7b6f646d7edc8c81114d1e615b4cfc3655c241d4 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:20:49 +0300 Subject: [PATCH 06/50] Update bot.py --- app/bot.py | 2 ++ 1 file changed, 2 insertions(+) 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) From 738216cf9fe9ae7b3a4a8adbf43439e1d6553977 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:21:15 +0300 Subject: [PATCH 07/50] Add tariff change button for tariff mode --- app/keyboards/inline.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 62586101..93837a40 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() From e301d496572c014bbb06d34d1146537aa1a81a43 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:21:42 +0300 Subject: [PATCH 08/50] Add tariffs button to admin keyboard --- app/keyboards/admin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index afc12b52..43d67df2 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -218,6 +218,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM callback_data="admin_faq", ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SETTINGS_TARIFFS", "📦 Тарифы"), + callback_data="admin_tariffs", + ) + ], [ InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") ] From a448a2c450c0593e072aa5b7f0e427685a7b1319 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:22:36 +0300 Subject: [PATCH 09/50] Add files via upload --- .../subscription_auto_purchase_service.py | 89 +++++++++++++++++-- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index fdeacad9..f65f06ac 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 = await get_user_active_promo_discount_percent(user.id, db) + 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: From 5d864d028605606cf3fa513ac653afe127550d02 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:23:21 +0300 Subject: [PATCH 10/50] Introduce tariff creation and editing states Added states for creating and editing tariffs. --- app/states.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/states.py b/app/states.py index 5c2a5a68..9a234646 100644 --- a/app/states.py +++ b/app/states.py @@ -158,6 +158,25 @@ 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_squads = State() + editing_tariff_promo_groups = State() + + class SupportStates(StatesGroup): waiting_for_message = State() From 1af1919a14c0c167508265c4fa52be4565ca6232 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:23:58 +0300 Subject: [PATCH 11/50] Update miniapp.py --- app/webapi/routes/miniapp.py | 310 +++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 9aead7f1..80853fc3 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, @@ -5905,3 +5940,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), + ) From 48cb19170bc51015457dae3b636e8b46ddac4734 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:24:30 +0300 Subject: [PATCH 12/50] Update miniapp.py --- app/webapi/schemas/miniapp.py | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) 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) From fc1528532e38140528ae57e8b989b0eb8c9a4788 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 7 Jan 2026 02:25:48 +0300 Subject: [PATCH 13/50] Update index.html --- miniapp/index.html | 256 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) 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 @@ + + +