diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index 34210992..04c4f193 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -167,6 +167,9 @@ async def create_tariff( tier_level: int = 1, is_trial_available: bool = False, promo_group_ids: Optional[List[int]] = None, + traffic_topup_enabled: bool = False, + traffic_topup_packages: Optional[Dict[str, int]] = None, + max_topup_traffic_gb: int = 0, ) -> Tariff: """Создает новый тариф.""" normalized_prices = _normalize_period_prices(period_prices) @@ -182,6 +185,9 @@ async def create_tariff( period_prices=normalized_prices, tier_level=max(1, tier_level), is_trial_available=is_trial_available, + traffic_topup_enabled=traffic_topup_enabled, + traffic_topup_packages=traffic_topup_packages or {}, + max_topup_traffic_gb=max(0, max_topup_traffic_gb), ) db.add(tariff) @@ -229,6 +235,7 @@ async def update_tariff( promo_group_ids: Optional[List[int]] = None, traffic_topup_enabled: Optional[bool] = None, traffic_topup_packages: Optional[Dict[str, int]] = None, + max_topup_traffic_gb: Optional[int] = None, ) -> Tariff: """Обновляет существующий тариф.""" if name is not None: @@ -258,6 +265,8 @@ async def update_tariff( tariff.traffic_topup_enabled = traffic_topup_enabled if traffic_topup_packages is not None: tariff.traffic_topup_packages = traffic_topup_packages + if max_topup_traffic_gb is not None: + tariff.max_topup_traffic_gb = max(0, max_topup_traffic_gb) # Обновляем промогруппы если указаны if promo_group_ids is not None: diff --git a/app/database/models.py b/app/database/models.py index bba52253..9f47209b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -766,6 +766,8 @@ class Tariff(Base): traffic_topup_enabled = Column(Boolean, default=False, nullable=False) # Разрешена ли докупка трафика # Пакеты трафика: JSON {"5": 5000, "10": 9000, "20": 15000} (ГБ: цена в копейках) traffic_topup_packages = Column(JSON, default=dict) + # Максимальный лимит трафика после докупки (0 = без ограничений) + max_topup_traffic_gb = Column(Integer, default=0, nullable=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 436ebe37..71434a7b 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -1402,6 +1402,7 @@ async def start_edit_tariff_traffic_topup( is_enabled = getattr(tariff, 'traffic_topup_enabled', False) packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 # Форматируем текущие настройки if is_enabled: @@ -1414,6 +1415,12 @@ async def start_edit_tariff_traffic_topup( status = "❌ Отключено" packages_display = " -" + # Форматируем лимит + if max_topup_traffic > 0: + max_limit_display = f"{max_topup_traffic} ГБ" + else: + max_limit_display = "Без ограничений" + buttons = [] # Переключение вкл/выкл @@ -1426,11 +1433,14 @@ async def start_edit_tariff_traffic_topup( InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") ]) - # Редактирование пакетов (только если включено) + # Редактирование пакетов и лимита (только если включено) if is_enabled: buttons.append([ InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}") ]) + buttons.append([ + InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}") + ]) buttons.append([ InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") @@ -1440,6 +1450,7 @@ async def start_edit_tariff_traffic_topup( f"📈 Докупка трафика для «{tariff.name}»\n\n" f"Статус: {status}\n\n" f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" "Пользователи смогут докупать трафик по заданным ценам.", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), parse_mode="HTML" @@ -1473,6 +1484,7 @@ async def toggle_tariff_traffic_topup( # Перерисовываем меню texts = get_texts(db_user.language) packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 if new_value: status = "✅ Включено" @@ -1484,6 +1496,12 @@ async def toggle_tariff_traffic_topup( status = "❌ Отключено" packages_display = " -" + # Форматируем лимит + if max_topup_traffic > 0: + max_limit_display = f"{max_topup_traffic} ГБ" + else: + max_limit_display = "Без ограничений" + buttons = [] if new_value: @@ -1493,6 +1511,9 @@ async def toggle_tariff_traffic_topup( buttons.append([ InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}") ]) + buttons.append([ + InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}") + ]) else: buttons.append([ InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") @@ -1507,6 +1528,7 @@ async def toggle_tariff_traffic_topup( f"📈 Докупка трафика для «{tariff.name}»\n\n" f"Статус: {status}\n\n" f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" "Пользователи смогут докупать трафик по заданным ценам.", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), parse_mode="HTML" @@ -1597,10 +1619,13 @@ async def process_edit_traffic_topup_packages( # Показываем обновленное меню texts = get_texts(db_user.language) packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + max_limit_display = f"{max_topup_traffic} ГБ" if max_topup_traffic > 0 else "Без ограничений" buttons = [ [InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")], [InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")], + [InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")], [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")] ] @@ -1609,6 +1634,115 @@ async def process_edit_traffic_topup_packages( f"📈 Докупка трафика для «{tariff.name}»\n\n" f"Статус: ✅ Включено\n\n" f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + + +# ============ МАКСИМАЛЬНЫЙ ЛИМИТ ДОКУПКИ ТРАФИКА ============ + +@admin_required +@error_handler +async def start_edit_max_topup_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_max_topup_traffic) + await state.update_data(tariff_id=tariff_id) + + current_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + if current_limit > 0: + current_display = f"{current_limit} ГБ" + else: + current_display = "Без ограничений" + + await callback.message.edit_text( + f"📊 Максимальный лимит трафика\n\n" + f"Тариф: {tariff.name}\n" + f"Текущий лимит: {current_display}\n\n" + f"Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n" + f"• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n" + f"• Введите 0 для снятия ограничения", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_max_topup_traffic( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новое значение максимального лимита докупки трафика.""" + texts = get_texts(db_user.language) + state_data = await state.get_data() + tariff_id = state_data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + # Парсим значение + text = message.text.strip() + try: + new_limit = int(text) + if new_limit < 0: + raise ValueError("Negative value") + except ValueError: + await message.answer( + "Введите целое число (0 или больше).\n\n" + "• 0 — без ограничений\n" + "• 200 — максимум 200 ГБ на подписке", + parse_mode="HTML" + ) + return + + tariff = await update_tariff(db, tariff, max_topup_traffic_gb=new_limit) + await state.clear() + + # Показываем обновленное меню + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Пакеты не настроены" + + max_limit_display = f"{new_limit} ГБ" if new_limit > 0 else "Без ограничений" + + buttons = [ + [InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")], + [InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")], + [InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")], + [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")] + ] + + await message.answer( + f"✅ Лимит обновлен!\n\n" + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: ✅ Включено\n\n" + f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" "Пользователи смогут докупать трафик по заданным ценам.", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), parse_mode="HTML" @@ -2166,6 +2300,10 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_edit_traffic_topup_packages, F.data.startswith("admin_tariff_edit_topup_packages:")) dp.message.register(process_edit_traffic_topup_packages, AdminStates.editing_tariff_traffic_topup_packages) + # Редактирование макс. лимита докупки трафика + dp.callback_query.register(start_edit_max_topup_traffic, F.data.startswith("admin_tariff_edit_max_topup:")) + dp.message.register(process_edit_max_topup_traffic, AdminStates.editing_tariff_max_topup_traffic) + # Удаление 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:")) diff --git a/app/states.py b/app/states.py index 6cbfd071..88ebce08 100644 --- a/app/states.py +++ b/app/states.py @@ -181,6 +181,7 @@ class AdminStates(StatesGroup): editing_tariff_squads = State() editing_tariff_promo_groups = State() editing_tariff_traffic_topup_packages = State() + editing_tariff_max_topup_traffic = State() class SupportStates(StatesGroup): diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 57f9b1d4..7dfea9c8 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3531,6 +3531,15 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - if apply_to_addons: traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + # Лимит докупки трафика + max_topup_traffic_gb = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + current_subscription_traffic = subscription.traffic_limit_gb or 0 + + # Рассчитываем доступный лимит докупки + available_topup_gb = None + if max_topup_traffic_gb > 0: + available_topup_gb = max(0, max_topup_traffic_gb - current_subscription_traffic) + # Пакеты докупки трафика traffic_topup_enabled = getattr(tariff, 'traffic_topup_enabled', False) and tariff.traffic_limit_gb > 0 traffic_topup_packages = [] @@ -3538,6 +3547,10 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - if traffic_topup_enabled and hasattr(tariff, 'get_traffic_topup_packages'): packages = tariff.get_traffic_topup_packages() for gb in sorted(packages.keys()): + # Фильтруем пакеты, которые превышают доступный лимит + if available_topup_gb is not None and gb > available_topup_gb: + continue + base_price = packages[gb] # Применяем скидку if traffic_discount_percent > 0: @@ -3557,6 +3570,10 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - price_label=settings.format_price(base_price), )) + # Если нет доступных пакетов из-за лимита - отключаем докупку + if traffic_topup_enabled and not traffic_topup_packages and available_topup_gb == 0: + traffic_topup_enabled = False + return MiniAppCurrentTariff( id=tariff.id, name=tariff.name, @@ -3569,6 +3586,8 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - servers_count=servers_count, traffic_topup_enabled=traffic_topup_enabled, traffic_topup_packages=traffic_topup_packages, + max_topup_traffic_gb=max_topup_traffic_gb, + available_topup_gb=available_topup_gb, ) @@ -6601,6 +6620,24 @@ async def purchase_traffic_topup_endpoint( }, ) + # Проверяем лимит докупки трафика + max_topup_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + if max_topup_limit > 0: + current_traffic = subscription.traffic_limit_gb or 0 + new_traffic = current_traffic + payload.gb + if new_traffic > max_topup_limit: + available_gb = max(0, max_topup_limit - current_traffic) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "topup_limit_exceeded", + "message": f"Traffic top-up limit exceeded. Maximum allowed: {max_topup_limit} GB, current: {current_traffic} GB, available: {available_gb} GB", + "max_limit_gb": max_topup_limit, + "current_gb": current_traffic, + "available_gb": available_gb, + }, + ) + # Получаем цену пакета packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} if payload.gb not in packages: diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 9da5caa4..723e492c 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -551,6 +551,9 @@ class MiniAppCurrentTariff(BaseModel): # Докупка трафика traffic_topup_enabled: bool = False traffic_topup_packages: List[MiniAppTrafficTopupPackage] = Field(default_factory=list) + # Лимит докупки трафика (0 = без лимита) + max_topup_traffic_gb: int = 0 + available_topup_gb: Optional[int] = None # Сколько еще можно докупить (None = без лимита) class MiniAppTrafficTopupRequest(BaseModel): diff --git a/miniapp/index.html b/miniapp/index.html index 75dc2297..0c3b58ba 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -6201,6 +6201,8 @@ 'traffic_topup.error.generic': 'Failed to purchase traffic. Please try again.', 'traffic_topup.success.title': 'Traffic added!', 'traffic_topup.success.message': '+{gb} GB has been added to your subscription.', + 'traffic_topup.available_limit': 'Available to purchase: {gb} GB', + 'traffic_topup.limit_reached': 'Maximum traffic limit reached', 'button.buy_subscription': 'Buy Subscription', 'button.open_bot': 'Open Telegram bot', 'subscription_purchase.title': 'Purchase subscription', @@ -6647,6 +6649,8 @@ 'traffic_topup.error.generic': 'Не удалось докупить трафик. Попробуйте снова.', 'traffic_topup.success.title': 'Трафик добавлен!', 'traffic_topup.success.message': '+{gb} ГБ добавлено к вашей подписке.', + 'traffic_topup.available_limit': 'Доступно для покупки: {gb} ГБ', + 'traffic_topup.limit_reached': 'Достигнут максимальный лимит трафика', 'button.buy_subscription': 'Купить подписку', 'button.open_bot': 'Открыть бота', 'subscription_purchase.title': 'Оформление подписки', @@ -8918,6 +8922,9 @@ window._trafficTopupPackages = trafficTopupPackages; window._subscriptionId = userData?.subscription_id ?? userData?.subscriptionId; window._userBalance = userData?.balance_kopeks || userData?.balanceKopeks || 0; + // Лимит докупки трафика (null = без лимита) + window._availableTopupGb = currentTariff?.available_topup_gb ?? currentTariff?.availableTopupGb ?? null; + window._maxTopupTrafficGb = currentTariff?.max_topup_traffic_gb ?? currentTariff?.maxTopupTrafficGb ?? 0; } else { trafficTopupBtn.classList.add('hidden'); } @@ -12189,11 +12196,29 @@ const packages = window._trafficTopupPackages || []; const balance = window._userBalance || 0; + const availableTopupGb = window._availableTopupGb; packagesContainer.innerHTML = ''; + // Показываем доступный лимит, если он есть + if (availableTopupGb !== null && availableTopupGb !== undefined) { + const limitEl = document.createElement('div'); + limitEl.className = 'traffic-topup-limit-info'; + limitEl.style.cssText = 'text-align: center; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-tertiary); border-radius: 8px; font-size: 13px; color: var(--text-secondary);'; + if (availableTopupGb <= 0) { + limitEl.textContent = t('traffic_topup.limit_reached'); + limitEl.style.color = 'var(--danger-color)'; + } else { + limitEl.textContent = t('traffic_topup.available_limit').replace('{gb}', availableTopupGb); + } + packagesContainer.appendChild(limitEl); + } + if (packages.length === 0) { - packagesContainer.innerHTML = '
' + t('traffic_topup.empty') + '
'; + const emptyEl = document.createElement('div'); + emptyEl.className = 'traffic-topup-empty'; + emptyEl.textContent = t('traffic_topup.empty'); + packagesContainer.appendChild(emptyEl); return; } @@ -12303,6 +12328,11 @@ trafficLimitEl.textContent = formatTrafficLimit(data.new_traffic_limit_gb); } + // Обновляем доступный лимит докупки + if (window._availableTopupGb !== null && window._availableTopupGb !== undefined) { + window._availableTopupGb = Math.max(0, window._availableTopupGb - gb); + } + // Обновляем данные через 2 секунды setTimeout(() => { refreshSubscriptionData({ silent: true });