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