diff --git a/app/cabinet/routes/admin_tariffs.py b/app/cabinet/routes/admin_tariffs.py
index ac116c9a..a03e39ba 100644
--- a/app/cabinet/routes/admin_tariffs.py
+++ b/app/cabinet/routes/admin_tariffs.py
@@ -218,6 +218,8 @@ async def get_tariff(
# Дневной тариф
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
+ # Режим сброса трафика
+ traffic_reset_mode=tariff.traffic_reset_mode,
created_at=tariff.created_at,
updated_at=tariff.updated_at,
)
@@ -268,6 +270,8 @@ async def create_new_tariff(
# Дневной тариф
is_daily=request.is_daily,
daily_price_kopeks=request.daily_price_kopeks,
+ # Режим сброса трафика
+ traffic_reset_mode=request.traffic_reset_mode,
)
logger.info(f"Admin {admin.id} created tariff {tariff.id}: {tariff.name}")
@@ -354,6 +358,9 @@ async def update_existing_tariff(
updates["is_daily"] = request.is_daily
if request.daily_price_kopeks is not None:
updates["daily_price_kopeks"] = request.daily_price_kopeks
+ # Режим сброса трафика (None допускается как значение для сброса к глобальной настройке)
+ if 'traffic_reset_mode' in request.model_fields_set:
+ updates["traffic_reset_mode"] = request.traffic_reset_mode
if updates:
await update_tariff(db, tariff, **updates)
diff --git a/app/cabinet/schemas/tariffs.py b/app/cabinet/schemas/tariffs.py
index 43d5ab3c..69bb052e 100644
--- a/app/cabinet/schemas/tariffs.py
+++ b/app/cabinet/schemas/tariffs.py
@@ -103,6 +103,8 @@ class TariffDetailResponse(BaseModel):
# Дневной тариф
is_daily: bool = False
daily_price_kopeks: int = 0
+ # Режим сброса трафика
+ traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
created_at: datetime
updated_at: Optional[datetime] = None
@@ -141,6 +143,8 @@ class TariffCreateRequest(BaseModel):
# Дневной тариф
is_daily: bool = False
daily_price_kopeks: int = Field(0, ge=0)
+ # Режим сброса трафика
+ traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffUpdateRequest(BaseModel):
@@ -175,6 +179,8 @@ class TariffUpdateRequest(BaseModel):
# Дневной тариф
is_daily: Optional[bool] = None
daily_price_kopeks: Optional[int] = Field(None, ge=0)
+ # Режим сброса трафика
+ traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffToggleResponse(BaseModel):
diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py
index cb5b8582..d23f23d6 100644
--- a/app/database/crud/subscription.py
+++ b/app/database/crud/subscription.py
@@ -24,10 +24,13 @@ logger = logging.getLogger(__name__)
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]:
result = await db.execute(
select(Subscription)
- .options(selectinload(Subscription.user))
+ .options(
+ selectinload(Subscription.user),
+ selectinload(Subscription.tariff),
+ )
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
- .limit(1)
+ .limit(1)
)
subscription = result.scalar_one_or_none()
diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py
index 932f8810..b88ca9ca 100644
--- a/app/database/crud/tariff.py
+++ b/app/database/crud/tariff.py
@@ -186,6 +186,8 @@ async def create_tariff(
traffic_price_per_gb_kopeks: int = 0,
min_traffic_gb: int = 1,
max_traffic_gb: int = 1000,
+ # Режим сброса трафика
+ traffic_reset_mode: Optional[str] = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
) -> Tariff:
"""Создает новый тариф."""
normalized_prices = _normalize_period_prices(period_prices)
@@ -220,6 +222,8 @@ async def create_tariff(
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
min_traffic_gb=max(1, min_traffic_gb),
max_traffic_gb=max(1, max_traffic_gb),
+ # Режим сброса трафика
+ traffic_reset_mode=traffic_reset_mode,
)
db.add(tariff)
@@ -283,6 +287,8 @@ async def update_tariff(
traffic_price_per_gb_kopeks: Optional[int] = None,
min_traffic_gb: Optional[int] = None,
max_traffic_gb: Optional[int] = None,
+ # Режим сброса трафика
+ traffic_reset_mode: Optional[str] = ..., # ... = не передан, None = сбросить к глобальной настройке
) -> Tariff:
"""Обновляет существующий тариф."""
if name is not None:
@@ -343,6 +349,9 @@ async def update_tariff(
tariff.min_traffic_gb = max(1, min_traffic_gb)
if max_traffic_gb is not None:
tariff.max_traffic_gb = max(1, max_traffic_gb)
+ # Режим сброса трафика
+ if traffic_reset_mode is not ...:
+ tariff.traffic_reset_mode = traffic_reset_mode
# Обновляем промогруппы если указаны
if promo_group_ids is not None:
diff --git a/app/database/models.py b/app/database/models.py
index b5042067..3898464b 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -807,6 +807,9 @@ class Tariff(Base):
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
+ # Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
+ traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
+
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py
index 5ea3044f..c6fef6d9 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -5988,6 +5988,28 @@ async def add_tariff_custom_days_traffic_columns() -> bool:
return False
+async def add_tariff_traffic_reset_mode_column() -> bool:
+ """Добавляет колонку traffic_reset_mode в tariffs для настройки режима сброса трафика.
+
+ Значения: DAY, WEEK, MONTH, NO_RESET (NULL = использовать глобальную настройку)
+ """
+ try:
+ if not await check_column_exists('tariffs', 'traffic_reset_mode'):
+ async with engine.begin() as conn:
+ await conn.execute(text(
+ "ALTER TABLE tariffs ADD COLUMN traffic_reset_mode VARCHAR(20) NULL"
+ ))
+ logger.info("✅ Колонка traffic_reset_mode добавлена в tariffs")
+ return True
+ else:
+ logger.info("ℹ️ Колонка traffic_reset_mode уже существует в tariffs")
+ return True
+
+ except Exception as error:
+ logger.error(f"❌ Ошибка добавления колонки traffic_reset_mode: {error}")
+ return False
+
+
async def add_subscription_daily_columns() -> bool:
"""Добавляет колонки для суточных подписок."""
try:
@@ -6624,6 +6646,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с колонками произвольных дней/трафика в tariffs")
+ logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ РЕЖИМА СБРОСА ТРАФИКА В ТАРИФАХ ===")
+ traffic_reset_mode_ready = await add_tariff_traffic_reset_mode_column()
+ if traffic_reset_mode_ready:
+ logger.info("✅ Колонка traffic_reset_mode в tariffs готова")
+ else:
+ logger.warning("⚠️ Проблемы с колонкой traffic_reset_mode в tariffs")
+
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ПОДПИСОК ===")
daily_subscription_columns_ready = await add_subscription_daily_columns()
if daily_subscription_columns_ready:
diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py
index 7083666f..bdaee15c 100644
--- a/app/handlers/admin/tariffs.py
+++ b/app/handlers/admin/tariffs.py
@@ -204,6 +204,9 @@ def get_tariff_view_keyboard(
buttons.append([
InlineKeyboardButton(text="📈 Докупка трафика", callback_data=f"admin_tariff_edit_traffic_topup:{tariff.id}"),
])
+ buttons.append([
+ InlineKeyboardButton(text="🔄 Сброс трафика", callback_data=f"admin_tariff_edit_reset_mode:{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}"),
@@ -250,6 +253,19 @@ def get_tariff_view_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
+def _format_traffic_reset_mode(mode: Optional[str]) -> str:
+ """Форматирует режим сброса трафика для отображения."""
+ mode_labels = {
+ 'DAY': '📅 Ежедневно',
+ 'WEEK': '📆 Еженедельно',
+ 'MONTH': '🗓️ Ежемесячно',
+ 'NO_RESET': '🚫 Никогда',
+ }
+ if mode is None:
+ return f"🌐 Глобальная настройка ({settings.DEFAULT_TRAFFIC_RESET_STRATEGY})"
+ return mode_labels.get(mode, f"⚠️ Неизвестно ({mode})")
+
+
def _format_traffic_topup_packages(tariff: Tariff) -> str:
"""Форматирует пакеты докупки трафика для отображения."""
if not getattr(tariff, 'traffic_topup_enabled', False):
@@ -312,6 +328,10 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
# Форматируем докупку трафика
traffic_topup_display = _format_traffic_topup_packages(tariff)
+ # Форматируем режим сброса трафика
+ traffic_reset_mode = getattr(tariff, 'traffic_reset_mode', None)
+ traffic_reset_display = _format_traffic_reset_mode(traffic_reset_mode)
+
# Форматируем суточный тариф
is_daily = getattr(tariff, 'is_daily', False)
daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0)
@@ -341,6 +361,8 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
Докупка трафика:
{traffic_topup_display}
+Сброс трафика: {traffic_reset_display}
+
{price_block}
Серверы: {squads_display}
@@ -2595,6 +2617,124 @@ async def clear_tariff_promo_groups(
pass
+# ==================== Режим сброса трафика ====================
+
+TRAFFIC_RESET_MODES = [
+ ('DAY', '📅 Ежедневно', 'Трафик сбрасывается каждый день'),
+ ('WEEK', '📆 Еженедельно', 'Трафик сбрасывается каждую неделю'),
+ ('MONTH', '🗓️ Ежемесячно', 'Трафик сбрасывается каждый месяц'),
+ ('NO_RESET', '🚫 Никогда', 'Трафик не сбрасывается автоматически'),
+]
+
+
+def get_traffic_reset_mode_keyboard(tariff_id: int, current_mode: Optional[str], language: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для выбора режима сброса трафика."""
+ texts = get_texts(language)
+ buttons = []
+
+ # Кнопка "Глобальная настройка"
+ global_label = f"{'✅ ' if current_mode is None else ''}🌐 Глобальная настройка ({settings.DEFAULT_TRAFFIC_RESET_STRATEGY})"
+ buttons.append([
+ InlineKeyboardButton(
+ text=global_label,
+ callback_data=f"admin_tariff_set_reset_mode:{tariff_id}:GLOBAL"
+ )
+ ])
+
+ # Кнопки для каждого режима
+ for mode_value, mode_label, mode_desc in TRAFFIC_RESET_MODES:
+ is_selected = current_mode == mode_value
+ label = f"{'✅ ' if is_selected else ''}{mode_label}"
+ buttons.append([
+ InlineKeyboardButton(
+ text=label,
+ callback_data=f"admin_tariff_set_reset_mode:{tariff_id}:{mode_value}"
+ )
+ ])
+
+ # Кнопка назад
+ buttons.append([
+ InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")
+ ])
+
+ return InlineKeyboardMarkup(inline_keyboard=buttons)
+
+
+@admin_required
+@error_handler
+async def start_edit_traffic_reset_mode(
+ 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
+
+ current_mode = getattr(tariff, 'traffic_reset_mode', None)
+
+ await callback.message.edit_text(
+ f"🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n"
+ f"Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n"
+ "Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n"
+ "• Глобальная настройка — использовать значение из конфига бота\n"
+ "• Ежедневно — сброс каждый день\n"
+ "• Еженедельно — сброс каждую неделю\n"
+ "• Ежемесячно — сброс каждый месяц\n"
+ "• Никогда — трафик накапливается за весь период подписки",
+ reply_markup=get_traffic_reset_mode_keyboard(tariff_id, current_mode, db_user.language),
+ parse_mode="HTML"
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def set_traffic_reset_mode(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ """Устанавливает режим сброса трафика для тарифа."""
+ parts = callback.data.split(":")
+ tariff_id = int(parts[1])
+ new_mode = parts[2]
+
+ tariff = await get_tariff_by_id(db, tariff_id)
+
+ if not tariff:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+
+ # Преобразуем GLOBAL в None
+ if new_mode == "GLOBAL":
+ new_mode = None
+
+ # Обновляем тариф
+ tariff = await update_tariff(db, tariff, traffic_reset_mode=new_mode)
+
+ mode_display = _format_traffic_reset_mode(new_mode)
+ await callback.answer(f"Режим сброса изменён: {mode_display}", show_alert=True)
+
+ # Обновляем клавиатуру
+ await callback.message.edit_text(
+ f"🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n"
+ f"Текущий режим: {mode_display}\n\n"
+ "Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n"
+ "• Глобальная настройка — использовать значение из конфига бота\n"
+ "• Ежедневно — сброс каждый день\n"
+ "• Еженедельно — сброс каждую неделю\n"
+ "• Ежемесячно — сброс каждый месяц\n"
+ "• Никогда — трафик накапливается за весь период подписки",
+ reply_markup=get_traffic_reset_mode_keyboard(tariff_id, new_mode, db_user.language),
+ parse_mode="HTML"
+ )
+
+
def register_handlers(dp: Dispatcher):
"""Регистрирует обработчики для управления тарифами."""
# Список тарифов
@@ -2681,3 +2821,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(toggle_daily_tariff, F.data.startswith("admin_tariff_toggle_daily:"))
dp.callback_query.register(start_edit_daily_price, F.data.startswith("admin_tariff_edit_daily_price:"))
dp.message.register(process_daily_price_input, AdminStates.editing_tariff_daily_price)
+
+ # Режим сброса трафика
+ dp.callback_query.register(start_edit_traffic_reset_mode, F.data.startswith("admin_tariff_edit_reset_mode:"))
+ dp.callback_query.register(set_traffic_reset_mode, F.data.startswith("admin_tariff_set_reset_mode:"))
diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py
index adac5867..fffcfc9a 100644
--- a/app/services/monitoring_service.py
+++ b/app/services/monitoring_service.py
@@ -531,7 +531,10 @@ class MonitoringService:
)
result = await db.execute(
select(Subscription)
- .options(selectinload(Subscription.user))
+ .options(
+ selectinload(Subscription.user),
+ selectinload(Subscription.tariff),
+ )
.where(
and_(
Subscription.is_trial.is_(True),
diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py
index c4a509a6..b9938cdd 100644
--- a/app/services/subscription_service.py
+++ b/app/services/subscription_service.py
@@ -62,17 +62,35 @@ def _resolve_addon_discount_percent(
period_days=period_days,
)
-def get_traffic_reset_strategy():
+def get_traffic_reset_strategy(tariff=None):
+ """Получает стратегию сброса трафика.
+
+ Args:
+ tariff: Объект тарифа. Если у тарифа задан traffic_reset_mode,
+ используется он, иначе глобальная настройка из конфига.
+
+ Returns:
+ TrafficLimitStrategy: Стратегия сброса трафика для RemnaWave API.
+ """
from app.config import settings
- strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
-
+
strategy_mapping = {
'NO_RESET': 'NO_RESET',
- 'DAY': 'DAY',
+ 'DAY': 'DAY',
'WEEK': 'WEEK',
'MONTH': 'MONTH'
}
-
+
+ # Проверяем настройку тарифа
+ if tariff is not None:
+ tariff_mode = getattr(tariff, 'traffic_reset_mode', None)
+ if tariff_mode is not None:
+ mapped_strategy = strategy_mapping.get(tariff_mode.upper(), 'NO_RESET')
+ logger.info(f"🔄 Стратегия сброса трафика из тарифа '{getattr(tariff, 'name', 'N/A')}': {tariff_mode} -> {mapped_strategy}")
+ return getattr(TrafficLimitStrategy, mapped_strategy)
+
+ # Используем глобальную настройку
+ strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET')
logger.info(f"🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}")
return getattr(TrafficLimitStrategy, mapped_strategy)
@@ -205,7 +223,7 @@ class SubscriptionService:
status=UserStatus.ACTIVE,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
- traffic_limit_strategy=get_traffic_reset_strategy(),
+ traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
@@ -242,7 +260,7 @@ class SubscriptionService:
expire_at=subscription.end_date,
status=UserStatus.ACTIVE,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
- traffic_limit_strategy=get_traffic_reset_strategy(),
+ traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
telegram_id=user.telegram_id,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
@@ -326,7 +344,7 @@ class SubscriptionService:
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
- traffic_limit_strategy=get_traffic_reset_strategy(),
+ traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,