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 = '