diff --git a/.env.example b/.env.example
index 6cce18b1..b92c2722 100644
--- a/.env.example
+++ b/.env.example
@@ -132,6 +132,26 @@ TRAFFIC_SELECTION_MODE=selectable
# 0 = безлимит
FIXED_TRAFFIC_LIMIT_GB=100
+# ===== ДОКУПКА ТРАФИКА =====
+# Включить/выключить функцию докупки трафика к существующей подписке
+TRAFFIC_TOPUP_ENABLED=true
+
+# Пакеты для докупки трафика (формат: "гб:цена_в_копейках:enabled")
+# Пустая строка = использовать TRAFFIC_PACKAGES_CONFIG
+# Пример: "10:5000:true,25:10000:true,50:15000:true,100:25000:true"
+TRAFFIC_TOPUP_PACKAGES_CONFIG=
+
+# ===== СБРОС ТРАФИКА =====
+# Режим расчета цены сброса трафика:
+# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение, может быть абьюзом!)
+# "traffic" - цена = стоимость текущего пакета трафика подписки
+# "traffic_with_purchased" - цена = стоимость базового + докупленного трафика (рекомендуется)
+TRAFFIC_RESET_PRICE_MODE=traffic_with_purchased
+
+# Базовая цена сброса в копейках (0 = использовать PERIOD_PRICES[30])
+# Используется как минимальная цена или фиксированная в режиме "period"
+TRAFFIC_RESET_BASE_PRICE=0
+
# ===== ПЕРИОДЫ ПОДПИСКИ =====
# Доступные периоды подписки (через запятую)
# Возможные значения: 14,30,60,90,180,360
diff --git a/app/config.py b/app/config.py
index 4de6c410..eaa75cf5 100644
--- a/app/config.py
+++ b/app/config.py
@@ -153,7 +153,21 @@ class Settings(BaseSettings):
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
- BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
+ BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
+
+ # Настройки докупки трафика
+ TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика
+ # Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG)
+ TRAFFIC_TOPUP_PACKAGES_CONFIG: str = ""
+
+ # Настройки сброса трафика
+ # Режимы расчета цены сброса:
+ # "period" - фиксированная цена = стоимость периода 30 дней (старое поведение)
+ # "traffic" - цена зависит от текущего лимита трафика (цена пакета трафика)
+ # "traffic_with_purchased" - цена = базовый трафик + докупленный трафик (рекомендуется)
+ TRAFFIC_RESET_PRICE_MODE: str = "traffic_with_purchased"
+ # Базовая цена сброса в копейках (используется если режим "period" или как минимальная цена)
+ TRAFFIC_RESET_BASE_PRICE: int = 0 # 0 = использовать PERIOD_PRICES[30]
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
@@ -972,6 +986,57 @@ class Settings(BaseSettings):
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
+ def is_traffic_topup_enabled(self) -> bool:
+ return self.TRAFFIC_TOPUP_ENABLED
+
+ def get_traffic_topup_packages(self) -> List[Dict]:
+ """Возвращает пакеты для докупки трафика. Если не настроены - использует TRAFFIC_PACKAGES_CONFIG."""
+ config_str = self.TRAFFIC_TOPUP_PACKAGES_CONFIG.strip()
+
+ if not config_str:
+ # Если не настроены отдельные пакеты для докупки - используем основные
+ return self.get_traffic_packages()
+
+ packages = []
+ for package_config in config_str.split(','):
+ package_config = package_config.strip()
+ if not package_config:
+ continue
+
+ parts = package_config.split(':')
+ if len(parts) >= 2:
+ try:
+ gb = int(parts[0])
+ price = int(parts[1])
+ enabled = parts[2].lower() == 'true' if len(parts) > 2 else True
+ packages.append({"gb": gb, "price": price, "enabled": enabled})
+ except (ValueError, IndexError):
+ continue
+
+ return packages if packages else self.get_traffic_packages()
+
+ def get_traffic_topup_price(self, gb: Optional[int]) -> int:
+ """Возвращает цену докупки для указанного количества ГБ."""
+ packages = self.get_traffic_topup_packages()
+ enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
+
+ if not enabled_packages:
+ return 0
+
+ # Ищем точное совпадение
+ for pkg in enabled_packages:
+ if pkg["gb"] == gb:
+ return pkg["price"]
+
+ # Если не нашли - возвращаем 0
+ return 0
+
+ def get_traffic_reset_price_mode(self) -> str:
+ return self.TRAFFIC_RESET_PRICE_MODE.lower()
+
+ def get_traffic_reset_base_price(self) -> int:
+ return self.TRAFFIC_RESET_BASE_PRICE
+
def is_devices_selection_enabled(self) -> bool:
return self.DEVICES_SELECTION_ENABLED
diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py
index 120f7f59..04d9c892 100644
--- a/app/database/crud/subscription.py
+++ b/app/database/crud/subscription.py
@@ -222,6 +222,7 @@ async def replace_subscription(
subscription.end_date = current_time + timedelta(days=duration_days)
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
+ subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки
subscription.device_limit = device_limit
subscription.connected_squads = list(new_squads)
subscription.subscription_url = None
@@ -339,7 +340,8 @@ async def extend_subscription(
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
- logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
+ subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным
+ logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
subscription.updated_at = current_time
diff --git a/app/database/models.py b/app/database/models.py
index f09b786a..cd97f12a 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -678,6 +678,7 @@ class Subscription(Base):
traffic_limit_gb = Column(Integer, default=0)
traffic_used_gb = Column(Float, default=0.0)
+ purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик (для расчета цены сброса)
subscription_url = Column(String, nullable=True)
subscription_crypto_link = Column(String, nullable=True)
diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py
index b86d85bf..54f40d17 100644
--- a/app/handlers/subscription/traffic.py
+++ b/app/handlers/subscription/traffic.py
@@ -91,6 +91,17 @@ async def handle_add_traffic(
texts = get_texts(db_user.language)
+ # Проверяем, включена ли функция докупки трафика
+ if not settings.is_traffic_topup_enabled():
+ await callback.answer(
+ texts.t(
+ "TRAFFIC_TOPUP_DISABLED",
+ "⚠️ Функция докупки трафика отключена",
+ ),
+ show_alert=True,
+ )
+ return
+
if settings.is_traffic_fixed():
await callback.answer(
texts.t(
@@ -146,6 +157,43 @@ async def handle_add_traffic(
await callback.answer()
+def _calculate_traffic_reset_price(subscription) -> int:
+ """Рассчитывает цену сброса трафика в зависимости от настроек."""
+ mode = settings.get_traffic_reset_price_mode()
+ base_price = settings.get_traffic_reset_base_price()
+
+ # Если базовая цена не задана, используем цену периода 30 дней
+ if base_price == 0:
+ base_price = PERIOD_PRICES.get(30, 0)
+
+ if mode == "period":
+ # Старое поведение: фиксированная цена = стоимость периода
+ return base_price
+
+ elif mode == "traffic":
+ # Цена = стоимость текущего пакета трафика
+ traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
+ return max(traffic_price, base_price)
+
+ elif mode == "traffic_with_purchased":
+ # Цена = стоимость базового трафика + докупленного
+ # Базовый трафик = текущий лимит - докупленный
+ purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
+ base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
+
+ # Получаем цену базового трафика
+ base_traffic_price = settings.get_traffic_price(base_traffic_gb) if base_traffic_gb > 0 else 0
+
+ # Получаем цену докупленного трафика
+ purchased_traffic_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
+
+ total_price = base_traffic_price + purchased_traffic_price
+ return max(total_price, base_price)
+
+ # Fallback на базовую цену
+ return base_price
+
+
async def handle_reset_traffic(
callback: types.CallbackQuery,
db_user: User,
@@ -168,19 +216,40 @@ async def handle_reset_traffic(
await callback.answer("⌛ У вас безлимитный трафик", show_alert=True)
return
- reset_price = PERIOD_PRICES[30]
+ reset_price = _calculate_traffic_reset_price(subscription)
- if db_user.balance_kopeks < reset_price:
- await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
- return
+ # Формируем информацию о расчете цены
+ purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
+ price_info = ""
+ if purchased_gb > 0 and settings.get_traffic_reset_price_mode() == "traffic_with_purchased":
+ base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
+ price_info = (
+ f"\n\n💡 Расчет цены:\n"
+ f"• Базовый трафик: {texts.format_traffic(base_traffic_gb)}\n"
+ f"• Докупленный: {texts.format_traffic(purchased_gb)}"
+ )
+
+ # Проверяем достаточно ли средств
+ has_enough_balance = db_user.balance_kopeks >= reset_price
+ missing_kopeks = max(0, reset_price - db_user.balance_kopeks)
+
+ # Формируем текст о балансе
+ balance_info = f"\n\n💰 На балансе: {texts.format_price(db_user.balance_kopeks)}"
+ if not has_enough_balance:
+ balance_info += f"\n⚠️ Не хватает: {texts.format_price(missing_kopeks)}"
await callback.message.edit_text(
f"🔄 Сброс трафика\n\n"
f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n"
- f"Стоимость сброса: {texts.format_price(reset_price)}\n\n"
+ f"Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n"
"После сброса счетчик использованного трафика станет равным 0.",
- reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
+ reply_markup=get_reset_traffic_confirm_keyboard(
+ reset_price,
+ db_user.language,
+ has_enough_balance=has_enough_balance,
+ missing_kopeks=missing_kopeks,
+ )
)
await callback.answer()
@@ -199,7 +268,7 @@ async def confirm_reset_traffic(
texts = get_texts(db_user.language)
subscription = db_user.subscription
- reset_price = PERIOD_PRICES[30]
+ reset_price = _calculate_traffic_reset_price(subscription)
if db_user.balance_kopeks < reset_price:
missing_kopeks = reset_price - db_user.balance_kopeks
@@ -388,7 +457,7 @@ async def add_traffic(
texts = get_texts(db_user.language)
subscription = db_user.subscription
- base_price = settings.get_traffic_price(traffic_gb)
+ base_price = settings.get_traffic_topup_price(traffic_gb)
if base_price == 0 and traffic_gb != 0:
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
@@ -458,8 +527,13 @@ async def add_traffic(
if traffic_gb == 0:
subscription.traffic_limit_gb = 0
+ # При переходе на безлимит сбрасываем докупленный трафик
+ subscription.purchased_traffic_gb = 0
else:
await add_subscription_traffic(db, subscription, traffic_gb)
+ # Записываем докупленный трафик для корректного расчета цены сброса
+ current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
+ subscription.purchased_traffic_gb = current_purchased + traffic_gb
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index a0e71575..51554a65 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -510,6 +510,7 @@ def get_main_menu_keyboard(
# Добавляем кнопку докупки трафика для лимитированных подписок
if (
settings.BUY_TRAFFIC_BUTTON_VISIBLE
+ and settings.is_traffic_topup_enabled()
and subscription
and not subscription.is_trial
and (subscription.traffic_limit_gb or 0) > 0
@@ -1690,7 +1691,7 @@ def get_add_traffic_keyboard(
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
- packages = settings.get_traffic_packages()
+ packages = settings.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
if not enabled_packages:
@@ -1846,27 +1847,45 @@ def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, lang
])
-def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
+def get_reset_traffic_confirm_keyboard(
+ price_kopeks: int,
+ language: str = DEFAULT_LANGUAGE,
+ has_enough_balance: bool = True,
+ missing_kopeks: int = 0,
+) -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
- return InlineKeyboardMarkup(inline_keyboard=[
- [
+ buttons = []
+
+ if has_enough_balance:
+ # Достаточно средств - показываем кнопку сброса
+ buttons.append([
InlineKeyboardButton(
text=f"✅ Сбросить за {settings.format_price(price_kopeks)}",
callback_data="confirm_reset_traffic"
)
- ],
- [
+ ])
+ else:
+ # Не хватает средств - показываем кнопку пополнения
+ buttons.append([
InlineKeyboardButton(
- text=texts.t("PENDING_CANCEL_BUTTON", "⌛ Отмена"),
- callback_data="menu_subscription",
+ text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
+ callback_data=f"topup_amount_{missing_kopeks}"
)
- ]
+ ])
+
+ buttons.append([
+ InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data="subscription_settings",
+ )
])
+
+ return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_manage_countries_keyboard(
countries: List[dict],
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index b73eaffa..57e1ca8c 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -1520,6 +1520,7 @@
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
+ "TRAFFIC_TOPUP_DISABLED": "⚠️ Traffic top-up feature is disabled",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index fe16af0c..0d5f2f65 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -1535,6 +1535,7 @@
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
"TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
+ "TRAFFIC_TOPUP_DISABLED": "⚠️ Функция докупки трафика отключена",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json
index 1e7b6f78..5639c0b7 100644
--- a/app/localization/locales/ua.json
+++ b/app/localization/locales/ua.json
@@ -1454,6 +1454,7 @@
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас вже безлімітний трафік",
"TRAFFIC_FIXED_MODE": "⚠️ У поточному режимі трафік фіксований і не може бути змінений",
+ "TRAFFIC_TOPUP_DISABLED": "⚠️ Функція докупки трафіку вимкнена",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостатньо коштів!\nПотрібно: {required} (за {months} міс)\nУ вас: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Ліміт трафіку не змінився",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакети трафіку не налаштовані",
diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json
index 2a573c3f..279fcbca 100644
--- a/app/localization/locales/zh.json
+++ b/app/localization/locales/zh.json
@@ -1452,6 +1452,7 @@
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
+"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
"TRAFFIC_NO_CHANGE":"ℹ️流量限制未更改",
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
@@ -1779,6 +1780,7 @@
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
+"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
"TRAFFIC_NO_CHANGE":"ℹ️流量限制未更改",
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
diff --git a/app/services/menu_layout/constants.py b/app/services/menu_layout/constants.py
index 8bd96980..b76d75e8 100644
--- a/app/services/menu_layout/constants.py
+++ b/app/services/menu_layout/constants.py
@@ -128,7 +128,7 @@ DEFAULT_MENU_CONFIG: Dict[str, Any] = {
"action": "buy_traffic",
"enabled": True,
"visibility": "subscribers",
- "conditions": {"has_traffic_limit": True},
+ "conditions": {"has_traffic_limit": True, "traffic_topup_enabled": True},
"dynamic_text": False,
},
"balance": {
diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py
index 84b39853..5ce989b0 100644
--- a/app/services/menu_layout/service.py
+++ b/app/services/menu_layout/service.py
@@ -715,6 +715,11 @@ class MenuLayoutService:
if is_trial or traffic_limit <= 0:
return False
+ # traffic_topup_enabled - функция докупки трафика включена
+ if conditions.get("traffic_topup_enabled") is True:
+ if not settings.is_traffic_topup_enabled():
+ return False
+
# is_admin
if conditions.get("is_admin") is True:
if not context.is_admin:
diff --git a/migrations/alembic/versions/a1b2c3d4e5f6_add_purchased_traffic_gb_to_subscriptions.py b/migrations/alembic/versions/a1b2c3d4e5f6_add_purchased_traffic_gb_to_subscriptions.py
new file mode 100644
index 00000000..88a656ee
--- /dev/null
+++ b/migrations/alembic/versions/a1b2c3d4e5f6_add_purchased_traffic_gb_to_subscriptions.py
@@ -0,0 +1,28 @@
+"""add purchased_traffic_gb to subscriptions
+
+Revision ID: a1b2c3d4e5f6
+Revises: f4a5b6c7d8e9
+Create Date: 2024-12-25 14:30:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'a1b2c3d4e5f6'
+down_revision = 'f4a5b6c7d8e9'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Добавляем колонку purchased_traffic_gb для отслеживания докупленного трафика
+ op.add_column('subscriptions', sa.Column('purchased_traffic_gb', sa.Integer(), nullable=True, server_default='0'))
+
+ # Устанавливаем NOT NULL после добавления значения по умолчанию
+ op.alter_column('subscriptions', 'purchased_traffic_gb', nullable=False, server_default=None)
+
+
+def downgrade():
+ op.drop_column('subscriptions', 'purchased_traffic_gb')