From 8407f7cdb1342b0b3ceb80c36182318871bdf65f Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 31 Oct 2025 19:15:48 +0300 Subject: [PATCH] Restore device management button in disabled mode --- .env.example | 4 + README.md | 3 + app/config.py | 32 +- app/database/crud/subscription.py | 10 +- app/handlers/admin/campaigns.py | 9 +- app/handlers/admin/users.py | 53 +- app/handlers/simple_subscription.py | 138 +++-- app/handlers/subscription/autopay.py | 75 +-- app/handlers/subscription/countries.py | 6 + app/handlers/subscription/devices.py | 28 + app/handlers/subscription/pricing.py | 81 ++- app/handlers/subscription/purchase.py | 536 ++++++++++++++------ app/handlers/subscription/summary.py | 59 +++ app/handlers/subscription/traffic.py | 19 +- app/keyboards/inline.py | 38 +- app/localization/locales/en.json | 6 +- app/localization/locales/ru.json | 6 +- app/services/admin_notification_service.py | 10 +- app/services/campaign_service.py | 6 +- app/services/monitoring_service.py | 15 +- app/services/promocode_service.py | 17 +- app/services/remnawave_service.py | 30 +- app/services/subscription_service.py | 64 ++- app/services/system_settings_service.py | 17 + app/utils/subscription_utils.py | 55 ++ app/webapi/routes/miniapp.py | 21 +- app/webapi/routes/subscriptions.py | 18 +- tests/test_device_limit_resolution.py | 167 ++++++ tests/test_subscription_cart_integration.py | 74 +++ 29 files changed, 1276 insertions(+), 321 deletions(-) create mode 100644 app/handlers/subscription/summary.py create mode 100644 tests/test_device_limit_resolution.py diff --git a/.env.example b/.env.example index a18ea009..6a5b24fc 100644 --- a/.env.example +++ b/.env.example @@ -146,6 +146,10 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true, # Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!) PRICE_PER_DEVICE=10000 +# Включить выбор количества устройств при покупке и продлении +DEVICES_SELECTION_ENABLED=true +# Единое количество устройств для режима без выбора (0 — не назначать устройства) +DEVICES_SELECTION_DISABLED_AMOUNT=0 # ===== РЕФЕРАЛЬНАЯ СИСТЕМА ===== REFERRAL_PROGRAM_ENABLED=true diff --git a/README.md b/README.md index aca837a4..77eb9951 100644 --- a/README.md +++ b/README.md @@ -569,6 +569,9 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70 TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,0:20000:true" PRICE_PER_DEVICE=5000 +DEVICES_SELECTION_ENABLED=true +# Единое количество устройств для режима без выбора (0 — не назначать устройства) +DEVICES_SELECTION_DISABLED_AMOUNT=0 # ===== РЕФЕРАЛЬНАЯ СИСТЕМА ===== REFERRAL_PROGRAM_ENABLED=true diff --git a/app/config.py b/app/config.py index 788bc8e6..1efa90c6 100644 --- a/app/config.py +++ b/app/config.py @@ -123,10 +123,12 @@ class Settings(BaseSettings): PRICE_TRAFFIC_500GB: int = 19000 PRICE_TRAFFIC_1000GB: int = 19500 PRICE_TRAFFIC_UNLIMITED: int = 20000 - + TRAFFIC_PACKAGES_CONFIG: str = "" PRICE_PER_DEVICE: int = 5000 + DEVICES_SELECTION_ENABLED: bool = True + DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = "" @@ -797,9 +799,35 @@ class Settings(BaseSettings): def is_traffic_fixed(self) -> bool: return self.TRAFFIC_SELECTION_MODE.lower() == "fixed" - + def get_fixed_traffic_limit(self) -> int: return self.FIXED_TRAFFIC_LIMIT_GB + + def is_devices_selection_enabled(self) -> bool: + return self.DEVICES_SELECTION_ENABLED + + def get_devices_selection_disabled_amount(self) -> Optional[int]: + raw_value = self.DEVICES_SELECTION_DISABLED_AMOUNT + + if raw_value in (None, ""): + return None + + try: + value = int(raw_value) + except (TypeError, ValueError): + logger.warning( + "Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s", + raw_value, + ) + return None + + if value < 0: + return 0 + + return value + + def get_disabled_mode_device_limit(self) -> Optional[int]: + return self.get_devices_selection_disabled_amount() def is_yookassa_enabled(self) -> bool: return (self.YOOKASSA_ENABLED and diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 7b304a8d..7421ff72 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -41,13 +41,14 @@ async def create_trial_subscription( user_id: int, duration_days: int = None, traffic_limit_gb: int = None, - device_limit: int = None, + device_limit: Optional[int] = None, squad_uuid: str = None ) -> Subscription: duration_days = duration_days or settings.TRIAL_DURATION_DAYS traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB - device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT + if device_limit is None: + device_limit = settings.TRIAL_DEVICE_LIMIT if not squad_uuid: try: from app.database.crud.server_squad import get_random_trial_squad_uuid @@ -126,13 +127,16 @@ async def create_paid_subscription( user_id: int, duration_days: int, traffic_limit_gb: int = 0, - device_limit: int = 1, + device_limit: Optional[int] = None, connected_squads: List[str] = None, update_server_counters: bool = False, ) -> Subscription: end_date = datetime.utcnow() + timedelta(days=duration_days) + if device_limit is None: + device_limit = settings.DEFAULT_DEVICE_LIMIT + subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index f6b9b325..7847ac75 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -46,6 +46,9 @@ def _format_campaign_summary(campaign, texts) -> str: bonus_info = f"💰 Бонус на баланс: {bonus_text}" else: traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0) + device_limit = campaign.subscription_device_limit + if device_limit is None: + device_limit = settings.DEFAULT_DEVICE_LIMIT bonus_info = ( "📱 Подписка: {days} д.\n" "🌐 Трафик: {traffic}\n" @@ -53,7 +56,7 @@ def _format_campaign_summary(campaign, texts) -> str: ).format( days=campaign.subscription_duration_days or 0, traffic=traffic_text, - devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT, + devices=device_limit, ) return ( @@ -935,7 +938,9 @@ async def start_edit_campaign_subscription_devices( campaign_edit_message_is_caption=is_caption, ) - current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT + current_devices = campaign.subscription_device_limit + if current_devices is None: + current_devices = settings.DEFAULT_DEVICE_LIMIT await callback.message.edit_text( ( diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index a1f44f7c..78fd9276 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -35,6 +35,9 @@ from app.database.crud.server_squad import ( get_server_ids_by_uuids, ) from app.services.subscription_service import SubscriptionService +from app.utils.subscription_utils import ( + resolve_hwid_device_limit_for_payload, +) logger = logging.getLogger(__name__) @@ -3338,7 +3341,15 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in logger.error(f"У пользователя {user_id} уже есть подписка") return False - subscription = await create_trial_subscription(db, user_id) + forced_devices = None + if not settings.is_devices_selection_enabled(): + forced_devices = settings.get_disabled_mode_device_limit() + + subscription = await create_trial_subscription( + db, + user_id, + device_limit=forced_devices, + ) subscription_service = SubscriptionService() await subscription_service.create_remnawave_user(db, subscription) @@ -3382,12 +3393,20 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad if getattr(settings, "TRIAL_SQUAD_UUID", None): trial_squads = [settings.TRIAL_SQUAD_UUID] + forced_devices = None + if not settings.is_devices_selection_enabled(): + forced_devices = settings.get_disabled_mode_device_limit() + + device_limit = settings.DEFAULT_DEVICE_LIMIT + if forced_devices is not None: + device_limit = forced_devices + subscription = await create_paid_subscription( db=db, user_id=user_id, duration_days=days, traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB, - device_limit=settings.DEFAULT_DEVICE_LIMIT, + device_limit=device_limit, connected_squads=trial_squads, update_server_counters=True, ) @@ -3594,7 +3613,9 @@ async def admin_buy_subscription( text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ" - devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT + devices_limit = subscription.device_limit + if devices_limit is None: + devices_limit = settings.DEFAULT_DEVICE_LIMIT servers_count = len(subscription.connected_squads or []) text += f"📶 Трафик: {traffic_text}\n" text += f"📱 Устройства: {devices_limit}\n" @@ -3685,7 +3706,9 @@ async def admin_buy_subscription_confirm( text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n" text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ" - devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT + devices_limit = subscription.device_limit + if devices_limit is None: + devices_limit = settings.DEFAULT_DEVICE_LIMIT servers_count = len(subscription.connected_squads or []) text += f"📶 Трафик: {traffic_text}\n" text += f"📱 Устройства: {devices_limit}\n" @@ -3832,40 +3855,50 @@ async def admin_buy_subscription_execute( from app.external.remnawave_api import UserStatus, TrafficLimitStrategy remnawave_service = RemnaWaveService() + hwid_limit = resolve_hwid_device_limit_for_payload(subscription) + if target_user.remnawave_uuid: async with remnawave_service.get_api_client() as api: - remnawave_user = await api.update_user( + update_kwargs = dict( uuid=target_user.remnawave_uuid, status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, expire_at=subscription.end_date, traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, traffic_limit_strategy=TrafficLimitStrategy.MONTH, - hwid_device_limit=subscription.device_limit, description=settings.format_remnawave_user_description( full_name=target_user.full_name, username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=subscription.connected_squads + active_internal_squads=subscription.connected_squads, ) + + if hwid_limit is not None: + update_kwargs['hwid_device_limit'] = hwid_limit + + remnawave_user = await api.update_user(**update_kwargs) else: username = f"user_{target_user.telegram_id}" async with remnawave_service.get_api_client() as api: - remnawave_user = await api.create_user( + create_kwargs = dict( username=username, expire_at=subscription.end_date, status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, traffic_limit_strategy=TrafficLimitStrategy.MONTH, telegram_id=target_user.telegram_id, - hwid_device_limit=subscription.device_limit, description=settings.format_remnawave_user_description( full_name=target_user.full_name, username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=subscription.connected_squads + active_internal_squads=subscription.connected_squads, ) + + if hwid_limit is not None: + create_kwargs['hwid_device_limit'] = hwid_limit + + remnawave_user = await api.create_user(**create_kwargs) if remnawave_user and hasattr(remnawave_user, 'uuid'): target_user.remnawave_uuid = remnawave_user.uuid diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 43051911..a672c85b 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -16,7 +16,10 @@ from app.services.payment_service import PaymentService from app.services.subscription_purchase_service import SubscriptionPurchaseService from app.utils.decorators import error_handler from app.states import SubscriptionStates -from app.utils.subscription_utils import get_display_subscription_link +from app.utils.subscription_utils import ( + get_display_subscription_link, + resolve_simple_subscription_device_limit, +) from app.utils.pricing_utils import compute_simple_subscription_price logger = logging.getLogger(__name__) @@ -35,15 +38,17 @@ async def start_simple_subscription_purchase( if not settings.SIMPLE_SUBSCRIPTION_ENABLED: await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True) return - + # Проверяем, есть ли у пользователя подписка (информируем, но не блокируем покупку) from app.database.crud.subscription import get_subscription_by_user_id current_subscription = await get_subscription_by_user_id(db, db_user.id) - + + device_limit = resolve_simple_subscription_device_limit() + # Подготовим параметры простой подписки subscription_params = { "period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS, - "device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT, + "device_limit": device_limit, "traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB, "squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID } @@ -111,20 +116,32 @@ async def start_simple_subscription_purchase( subscription_params, resolved_squad_uuid, ) - message_text = ( - f"⚡ Простая покупка подписки\n\n" - f"📅 Период: {subscription_params['period_days']} дней\n" - f"📱 Устройства: {subscription_params['device_limit']}\n" - f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n" - f"🌍 Сервер: {server_label}\n\n" - f"💰 Стоимость: {settings.format_price(price_kopeks)}\n" - f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n" - + ( + show_devices = settings.is_devices_selection_enabled() + + message_lines = [ + "⚡ Простая покупка подписки", + "", + f"📅 Период: {subscription_params['period_days']} дней", + ] + + if show_devices: + message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + + message_lines.extend([ + f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}", + f"🌍 Сервер: {server_label}", + "", + f"💰 Стоимость: {settings.format_price(price_kopeks)}", + f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}", + "", + ( "Вы можете оплатить подписку с баланса или выбрать другой способ оплаты." if can_pay_from_balance else "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:" - ) - ) + ), + ]) + + message_text = "\n".join(message_lines) if trial_notice: message_text = f"{trial_notice}\n\n{message_text}" @@ -433,16 +450,28 @@ async def handle_simple_subscription_pay_with_balance( subscription_params, resolved_squad_uuid, ) - success_message = ( - f"✅ Подписка успешно активирована!\n\n" - f"📅 Период: {subscription_params['period_days']} дней\n" - f"📱 Устройства: {subscription_params['device_limit']}\n" - f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n" - f"🌍 Сервер: {server_label}\n\n" - f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n" - f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}\n\n" - f"🔗 Для подключения перейдите в раздел 'Подключиться'" - ) + show_devices = settings.is_devices_selection_enabled() + + success_lines = [ + "✅ Подписка успешно активирована!", + "", + f"📅 Период: {subscription_params['period_days']} дней", + ] + + if show_devices: + success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + + success_lines.extend([ + f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}", + f"🌍 Сервер: {server_label}", + "", + f"💰 Списано с баланса: {settings.format_price(price_kopeks)}", + f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}", + "", + "🔗 Для подключения перейдите в раздел 'Подключиться'", + ]) + + success_message = "\n".join(success_lines) connect_mode = settings.CONNECT_BUTTON_MODE subscription_link = get_display_subscription_link(subscription) @@ -619,19 +648,31 @@ async def handle_simple_subscription_other_payment_methods( subscription_params, resolved_squad_uuid, ) - message_text = ( - f"💳 Оплата подписки\n\n" - f"📅 Период: {subscription_params['period_days']} дней\n" - f"📱 Устройства: {subscription_params['device_limit']}\n" - f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n" - f"🌍 Сервер: {server_label}\n\n" - f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n" - + ( + show_devices = settings.is_devices_selection_enabled() + + message_lines = [ + "💳 Оплата подписки", + "", + f"📅 Период: {subscription_params['period_days']} дней", + ] + + if show_devices: + message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + + message_lines.extend([ + f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}", + f"🌍 Сервер: {server_label}", + "", + f"💰 Стоимость: {settings.format_price(price_kopeks)}", + "", + ( "Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:" if can_pay_from_balance else "Выберите подходящий способ оплаты:" - ) - ) + ), + ]) + + message_text = "\n".join(message_lines) base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language) keyboard_rows = [] @@ -854,14 +895,25 @@ async def handle_simple_subscription_payment_method( keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) # Подготавливаем текст сообщения - message_text = ( - f"💳 Оплата подписки через YooKassa\n\n" - f"📅 Период: {subscription_params['period_days']} дней\n" - f"📱 Устройства: {subscription_params['device_limit']}\n" - f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n" - f"💰 Сумма: {settings.format_price(price_kopeks)}\n" - f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" - ) + show_devices = settings.is_devices_selection_enabled() + + message_lines = [ + "💳 Оплата подписки через YooKassa", + "", + f"📅 Период: {subscription_params['period_days']} дней", + ] + + if show_devices: + message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + + message_lines.extend([ + f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}", + f"💰 Сумма: {settings.format_price(price_kopeks)}", + f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...", + "", + ]) + + message_text = "\n".join(message_lines) # Добавляем инструкции в зависимости от доступных способов оплаты if not confirmation_url: diff --git a/app/handlers/subscription/autopay.py b/app/handlers/subscription/autopay.py index 7921bb1f..0ae8897c 100644 --- a/app/handlers/subscription/autopay.py +++ b/app/handlers/subscription/autopay.py @@ -208,39 +208,20 @@ async def handle_subscription_config_back( await state.set_state(SubscriptionStates.selecting_period) elif current_state == SubscriptionStates.selecting_devices.state: - if await _should_show_countries_management(db_user): - countries = await _get_available_countries(db_user.promo_group_id) - data = await state.get_data() - selected_countries = data.get('countries', []) - - await callback.message.edit_text( - texts.SELECT_COUNTRIES, - reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language) - ) - await state.set_state(SubscriptionStates.selecting_countries) - elif settings.is_traffic_selectable(): - await callback.message.edit_text( - texts.SELECT_TRAFFIC, - reply_markup=get_traffic_packages_keyboard(db_user.language) - ) - await state.set_state(SubscriptionStates.selecting_traffic) - else: - await callback.message.edit_text( - await _build_subscription_period_prompt(db_user, texts, db), - reply_markup=get_subscription_period_keyboard(db_user.language), - parse_mode="HTML", - ) - await state.set_state(SubscriptionStates.selecting_period) + await _show_previous_configuration_step(callback, state, db_user, texts, db) elif current_state == SubscriptionStates.confirming_purchase.state: - data = await state.get_data() - selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + if settings.is_devices_selection_enabled(): + data = await state.get_data() + selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) - await callback.message.edit_text( - texts.SELECT_DEVICES, - reply_markup=get_devices_keyboard(selected_devices, db_user.language) - ) - await state.set_state(SubscriptionStates.selecting_devices) + await callback.message.edit_text( + texts.SELECT_DEVICES, + reply_markup=get_devices_keyboard(selected_devices, db_user.language) + ) + await state.set_state(SubscriptionStates.selecting_devices) + else: + await _show_previous_configuration_step(callback, state, db_user, texts, db) else: from app.handlers.menu import show_main_menu @@ -267,3 +248,37 @@ async def handle_subscription_cancel( await show_main_menu(callback, db_user, db) await callback.answer("❌ Покупка отменена") +async def _show_previous_configuration_step( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + texts, + db: AsyncSession, +): + if await _should_show_countries_management(db_user): + countries = await _get_available_countries(db_user.promo_group_id) + data = await state.get_data() + selected_countries = data.get('countries', []) + + await callback.message.edit_text( + texts.SELECT_COUNTRIES, + reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language) + ) + await state.set_state(SubscriptionStates.selecting_countries) + return + + if settings.is_traffic_selectable(): + await callback.message.edit_text( + texts.SELECT_TRAFFIC, + reply_markup=get_traffic_packages_keyboard(db_user.language) + ) + await state.set_state(SubscriptionStates.selecting_traffic) + return + + await callback.message.edit_text( + await _build_subscription_period_prompt(db_user, texts, db), + reply_markup=get_subscription_period_keyboard(db_user.language), + parse_mode="HTML", + ) + await state.set_state(SubscriptionStates.selecting_period) + diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py index 1e964189..997ef357 100644 --- a/app/handlers/subscription/countries.py +++ b/app/handlers/subscription/countries.py @@ -79,6 +79,7 @@ from app.utils.promo_offer import ( ) from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger +from .summary import present_subscription_summary async def handle_add_countries( callback: types.CallbackQuery, @@ -588,6 +589,11 @@ async def countries_continue( await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True) return + if not settings.is_devices_selection_enabled(): + if await present_subscription_summary(callback, state, db_user, texts): + await callback.answer() + return + selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index 3ca44442..cecfdf22 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -183,6 +183,13 @@ async def handle_change_devices( texts = get_texts(db_user.language) subscription = db_user.subscription + if not settings.is_devices_selection_enabled(): + await callback.answer( + texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + show_alert=True, + ) + return + if not subscription or subscription.is_trial: await callback.answer( texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"), @@ -233,6 +240,13 @@ async def confirm_change_devices( texts = get_texts(db_user.language) subscription = db_user.subscription + if not settings.is_devices_selection_enabled(): + await callback.answer( + texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + show_alert=True, + ) + return + current_devices = subscription.device_limit if new_devices_count == current_devices: @@ -379,6 +393,13 @@ async def execute_change_devices( subscription = db_user.subscription current_devices = subscription.device_limit + if not settings.is_devices_selection_enabled(): + await callback.answer( + texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + show_alert=True, + ) + return + try: if price > 0: success = await subtract_user_balance( @@ -863,6 +884,13 @@ async def confirm_add_devices( texts = get_texts(db_user.language) subscription = db_user.subscription + if not settings.is_devices_selection_enabled(): + await callback.answer( + texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + show_alert=True, + ) + return + resume_callback = None new_total_devices = subscription.device_limit + devices_count diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py index b1a589f4..61d98c1d 100644 --- a/app/handlers/subscription/pricing.py +++ b/app/handlers/subscription/pricing.py @@ -158,7 +158,18 @@ async def _prepare_subscription_summary( total_servers_discount += total_discount_for_server selected_server_prices.append(total_price_for_server) - devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + devices_selection_enabled = settings.is_devices_selection_enabled() + forced_disabled_limit: Optional[int] = None + if devices_selection_enabled: + devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + else: + forced_disabled_limit = settings.get_disabled_mode_device_limit() + if forced_disabled_limit is None: + devices_selected = settings.DEFAULT_DEVICE_LIMIT + else: + devices_selected = forced_disabled_limit + + summary_data['devices'] = devices_selected additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_discount_percent = db_user.get_promo_discount( @@ -275,7 +286,7 @@ async def _prepare_subscription_summary( f" -{texts.format_price(total_servers_discount)})" ) details_lines.append(servers_line) - if total_devices_price > 0: + if devices_selection_enabled and total_devices_price > 0: devices_line = ( f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}" f" = {texts.format_price(total_devices_price)}" @@ -300,17 +311,28 @@ async def _prepare_subscription_summary( details_text = "\n".join(details_lines) - summary_text = ( - "📋 Сводка заказа\n\n" - f"📅 Период: {period_display}\n" - f"📊 Трафик: {traffic_display}\n" - f"🌍 Страны: {', '.join(selected_countries_names)}\n" - f"📱 Устройства: {devices_selected}\n\n" - "💰 Детализация стоимости:\n" - f"{details_text}\n\n" - f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" - "Подтверждаете покупку?" - ) + summary_lines = [ + "📋 Сводка заказа", + "", + f"📅 Период: {period_display}", + f"📊 Трафик: {traffic_display}", + f"🌍 Страны: {', '.join(selected_countries_names)}", + ] + + if devices_selection_enabled: + summary_lines.append(f"📱 Устройства: {devices_selected}") + + summary_lines.extend([ + "", + "💰 Детализация стоимости:", + details_text, + "", + f"💎 Общая стоимость: {texts.format_price(total_price)}", + "", + "Подтверждаете покупку?", + ]) + + summary_text = "\n".join(summary_lines) return summary_text, summary_data @@ -382,7 +404,18 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: ) traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb) - devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE + device_limit = subscription.device_limit + if device_limit is None: + if settings.is_devices_selection_enabled(): + device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + forced_limit = settings.get_disabled_mode_device_limit() + if forced_limit is None: + device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + device_limit = forced_limit + + devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE total_cost = base_cost + servers_cost + traffic_cost + devices_cost @@ -410,7 +443,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: return 0 async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession): - devices_used = await get_current_devices_count(db_user) + devices_selection_enabled = settings.is_devices_selection_enabled() + + if devices_selection_enabled: + devices_used = await get_current_devices_count(db_user) + else: + devices_used = 0 countries_info = await _get_countries_info(subscription.connected_squads) countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" @@ -439,7 +477,18 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess subscription_cost = await get_subscription_cost(subscription, db) - info_text = texts.SUBSCRIPTION_INFO.format( + info_template = texts.SUBSCRIPTION_INFO + + if not devices_selection_enabled: + info_template = info_template.replace( + "\n📱 Устройства: {devices_used} / {devices_limit}", + "", + ).replace( + "\n📱 Devices: {devices_used} / {devices_limit}", + "", + ) + + info_text = info_template.format( status=status_text, type=type_text, end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index d9f8bbcc..f85536ab 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -72,9 +72,10 @@ from app.utils.pricing_utils import ( apply_percentage_discount, ) from app.utils.subscription_utils import ( + convert_subscription_link_to_happ_scheme, get_display_subscription_link, get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, + resolve_simple_subscription_device_limit, ) from app.utils.promo_offer import ( build_promo_offer_hint, @@ -140,6 +141,7 @@ from .traffic import ( handle_switch_traffic, select_traffic, ) +from .summary import present_subscription_summary async def show_subscription_info( callback: types.CallbackQuery, @@ -237,26 +239,32 @@ async def show_subscription_info( devices_list = [] devices_count = 0 - try: - if db_user.remnawave_uuid: - from app.services.remnawave_service import RemnaWaveService - service = RemnaWaveService() + show_devices = settings.is_devices_selection_enabled() + devices_used_str = "" + devices_list: List[Dict[str, Any]] = [] - async with service.get_api_client() as api: - response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') + if show_devices: + try: + if db_user.remnawave_uuid: + from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() - if response and 'response' in response: - devices_info = response['response'] - devices_count = devices_info.get('total', 0) - devices_list = devices_info.get('devices', []) - devices_used_str = str(devices_count) - logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}") - else: - logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}") + async with service.get_api_client() as api: + response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - except Exception as e: - logger.error(f"Ошибка получения устройств для отображения: {e}") - devices_used_str = await get_current_devices_count(db_user) + if response and 'response' in response: + devices_info = response['response'] + devices_count = devices_info.get('total', 0) + devices_list = devices_info.get('devices', []) + devices_used_str = str(devices_count) + logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}") + else: + logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}") + + except Exception as e: + logger.error(f"Ошибка получения устройств для отображения: {e}") + devices_used = await get_current_devices_count(db_user) + devices_used_str = str(devices_used) servers_names = await get_servers_display_names(subscription.connected_squads) servers_display = ( @@ -265,7 +273,7 @@ async def show_subscription_info( else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов") ) - message = texts.t( + message_template = texts.t( "SUBSCRIPTION_OVERVIEW_TEMPLATE", """👤 {full_name} 💰 Баланс: {balance} @@ -278,7 +286,15 @@ async def show_subscription_info( 📈 Трафик: {traffic} 🌍 Серверы: {servers} 📱 Устройства: {devices_used} / {device_limit}""", - ).format( + ) + + if not show_devices: + message_template = message_template.replace( + "\n📱 Устройства: {devices_used} / {device_limit}", + "", + ) + + message = message_template.format( full_name=db_user.full_name, balance=settings.format_price(db_user.balance_kopeks), status_emoji=status_emoji, @@ -293,7 +309,7 @@ async def show_subscription_info( device_limit=subscription.device_limit, ) - if devices_list and len(devices_list) > 0: + if show_devices and devices_list: message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECTED_DEVICES_TITLE", "
📱 Подключенные устройства:\n", @@ -384,10 +400,20 @@ async def show_trial_offer( except Exception as e: logger.error(f"Ошибка получения триального сервера: {e}") + devices_line = "" + if settings.is_devices_selection_enabled(): + devices_line_template = texts.t( + "TRIAL_AVAILABLE_DEVICES_LINE", + "\n📱 Устройства: {devices} шт.", + ) + devices_line = devices_line_template.format( + devices=settings.TRIAL_DEVICE_LIMIT, + ) + trial_text = texts.TRIAL_AVAILABLE.format( days=settings.TRIAL_DURATION_DAYS, traffic=settings.TRIAL_TRAFFIC_LIMIT_GB, - devices=settings.TRIAL_DEVICE_LIMIT, + devices_line=devices_line, server_name=trial_server_name ) @@ -415,7 +441,15 @@ async def activate_trial( return try: - subscription = await create_trial_subscription(db, db_user.id) + forced_devices = None + if not settings.is_devices_selection_enabled(): + forced_devices = settings.get_disabled_mode_device_limit() + + subscription = await create_trial_subscription( + db, + db_user.id, + device_limit=forced_devices, + ) await db.refresh(db_user) @@ -589,10 +623,17 @@ async def start_subscription_purchase( ) subscription = getattr(db_user, 'subscription', None) - initial_devices = settings.DEFAULT_DEVICE_LIMIT - if subscription and getattr(subscription, 'device_limit', None): - initial_devices = max(settings.DEFAULT_DEVICE_LIMIT, subscription.device_limit) + if settings.is_devices_selection_enabled(): + initial_devices = settings.DEFAULT_DEVICE_LIMIT + if subscription and getattr(subscription, 'device_limit', None) is not None: + initial_devices = max(settings.DEFAULT_DEVICE_LIMIT, subscription.device_limit) + else: + forced_limit = settings.get_disabled_mode_device_limit() + if forced_limit is None: + initial_devices = settings.DEFAULT_DEVICE_LIMIT + else: + initial_devices = forced_limit initial_data = { 'period_days': None, @@ -698,7 +739,63 @@ async def return_to_saved_cart( return texts = get_texts(db_user.language) - total_price = cart_data.get('total_price', 0) + + preserved_metadata_keys = { + 'saved_cart', + 'missing_amount', + 'return_to_cart', + 'user_id', + } + preserved_metadata = { + key: cart_data[key] + for key in preserved_metadata_keys + if key in cart_data + } + + prepared_cart_data = dict(cart_data) + + if not settings.is_devices_selection_enabled(): + try: + from .pricing import _prepare_subscription_summary + + _, recalculated_data = await _prepare_subscription_summary( + db_user, + prepared_cart_data, + texts, + ) + except ValueError as recalculation_error: + logger.error( + "Не удалось пересчитать сохраненную корзину пользователя %s: %s", + db_user.telegram_id, + recalculation_error, + ) + forced_limit = settings.get_disabled_mode_device_limit() + if forced_limit is None: + forced_limit = settings.DEFAULT_DEVICE_LIMIT + prepared_cart_data['devices'] = forced_limit + removed_devices_total = prepared_cart_data.pop('total_devices_price', 0) or 0 + if removed_devices_total: + prepared_cart_data['total_price'] = max( + 0, + prepared_cart_data.get('total_price', 0) - removed_devices_total, + ) + prepared_cart_data.pop('devices_discount_percent', None) + prepared_cart_data.pop('devices_discount_total', None) + prepared_cart_data.pop('devices_discounted_price_per_month', None) + prepared_cart_data.pop('devices_price_per_month', None) + else: + normalized_cart_data = dict(prepared_cart_data) + normalized_cart_data.update(recalculated_data) + + for key, value in preserved_metadata.items(): + normalized_cart_data[key] = value + + prepared_cart_data = normalized_cart_data + + if prepared_cart_data != cart_data: + await user_cart_service.save_user_cart(db_user.id, prepared_cart_data) + + total_price = prepared_cart_data.get('total_price', 0) if db_user.balance_kopeks < total_price: missing_amount = total_price - db_user.balance_kopeks @@ -717,30 +814,45 @@ async def return_to_saved_cart( countries = await _get_available_countries(db_user.promo_group_id) selected_countries_names = [] - months_in_period = calculate_months_from_days(cart_data['period_days']) - period_display = format_period_description(cart_data['period_days'], db_user.language) + period_display = format_period_description(prepared_cart_data['period_days'], db_user.language) for country in countries: - if country['uuid'] in cart_data['countries']: + if country['uuid'] in prepared_cart_data['countries']: selected_countries_names.append(country['name']) if settings.is_traffic_fixed(): - traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ" + traffic_value = prepared_cart_data.get('traffic_gb') + if traffic_value is None: + traffic_value = settings.get_fixed_traffic_limit() + traffic_display = "Безлимитный" if traffic_value == 0 else f"{traffic_value} ГБ" else: - traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ" + traffic_value = prepared_cart_data.get('traffic_gb', 0) or 0 + traffic_display = "Безлимитный" if traffic_value == 0 else f"{traffic_value} ГБ" - summary_text = ( - "🛒 Восстановленная корзина\n\n" - f"📅 Период: {period_display}\n" - f"📊 Трафик: {traffic_display}\n" - f"🌍 Страны: {', '.join(selected_countries_names)}\n" - f"📱 Устройства: {cart_data['devices']}\n\n" - f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" - "Подтверждаете покупку?" - ) + summary_lines = [ + "🛒 Восстановленная корзина", + "", + f"📅 Период: {period_display}", + f"📊 Трафик: {traffic_display}", + f"🌍 Страны: {', '.join(selected_countries_names)}", + ] + + if settings.is_devices_selection_enabled(): + devices_value = prepared_cart_data.get('devices') + if devices_value is not None: + summary_lines.append(f"📱 Устройства: {devices_value}") + + summary_lines.extend([ + "", + f"💎 Общая стоимость: {texts.format_price(total_price)}", + "", + "Подтверждаете покупку?", + ]) + + summary_text = "\n".join(summary_lines) # Устанавливаем данные в FSM для продолжения процесса - await state.set_data(cart_data) + await state.set_data(prepared_cart_data) await state.set_state(SubscriptionStates.confirming_purchase) await callback.message.edit_text( @@ -793,7 +905,18 @@ async def handle_extend_subscription( servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 total_servers_price = (servers_price_per_month - servers_discount_per_month) * months_in_period - additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) + device_limit = subscription.device_limit + if device_limit is None: + if settings.is_devices_selection_enabled(): + device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + forced_limit = settings.get_disabled_mode_device_limit() + if forced_limit is None: + device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + device_limit = forced_limit + + additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_discount_percent = db_user.get_promo_discount( "devices", @@ -872,16 +995,27 @@ async def handle_extend_subscription( texts=texts, ) - message_text = ( - "⏰ Продление подписки\n\n" - f"Осталось дней: {subscription.days_left}\n\n" - f"Ваша текущая конфигурация:\n" - f"🌍 Серверов: {len(subscription.connected_squads)}\n" - f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}\n" - f"📱 Устройств: {subscription.device_limit}\n\n" - f"Выберите период продления:\n" - f"{prices_text.rstrip()}\n\n" - ) + renewal_lines = [ + "⏰ Продление подписки", + "", + f"Осталось дней: {subscription.days_left}", + "", + "Ваша текущая конфигурация:", + f"🌍 Серверов: {len(subscription.connected_squads)}", + f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}", + ] + + if settings.is_devices_selection_enabled(): + renewal_lines.append(f"📱 Устройств: {subscription.device_limit}") + + renewal_lines.extend([ + "", + "Выберите период продления:", + prices_text.rstrip(), + "", + ]) + + message_text = "\n".join(renewal_lines) if promo_discounts_text: message_text += f"{promo_discounts_text}\n\n" @@ -958,7 +1092,18 @@ async def confirm_extend_subscription( servers_price_per_month * servers_discount_percent // 100 ) - additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) + device_limit = subscription.device_limit + if device_limit is None: + if settings.is_devices_selection_enabled(): + device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + forced_limit = settings.get_disabled_mode_device_limit() + if forced_limit is None: + device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + device_limit = forced_limit + + additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_discount_percent = db_user.get_promo_discount( "devices", @@ -1248,43 +1393,60 @@ async def select_period( reply_markup=get_traffic_packages_keyboard(db_user.language) ) await state.set_state(SubscriptionStates.selecting_traffic) - else: - if await _should_show_countries_management(db_user): - countries = await _get_available_countries(db_user.promo_group_id) - await callback.message.edit_text( - texts.SELECT_COUNTRIES, - reply_markup=get_countries_keyboard(countries, [], db_user.language) - ) - await state.set_state(SubscriptionStates.selecting_countries) - else: - countries = await _get_available_countries(db_user.promo_group_id) - available_countries = [c for c in countries if c.get('is_available', True)] - data['countries'] = [available_countries[0]['uuid']] if available_countries else [] - await state.set_data(data) + await callback.answer() + return - selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + if await _should_show_countries_management(db_user): + countries = await _get_available_countries(db_user.promo_group_id) + await callback.message.edit_text( + texts.SELECT_COUNTRIES, + reply_markup=get_countries_keyboard(countries, [], db_user.language) + ) + await state.set_state(SubscriptionStates.selecting_countries) + await callback.answer() + return - await callback.message.edit_text( - texts.SELECT_DEVICES, - reply_markup=get_devices_keyboard(selected_devices, db_user.language) - ) - await state.set_state(SubscriptionStates.selecting_devices) + countries = await _get_available_countries(db_user.promo_group_id) + available_countries = [c for c in countries if c.get('is_available', True)] + data['countries'] = [available_countries[0]['uuid']] if available_countries else [] + await state.set_data(data) - await callback.answer() + if settings.is_devices_selection_enabled(): + selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + + await callback.message.edit_text( + texts.SELECT_DEVICES, + reply_markup=get_devices_keyboard(selected_devices, db_user.language) + ) + await state.set_state(SubscriptionStates.selecting_devices) + await callback.answer() + return + + if await present_subscription_summary(callback, state, db_user, texts): + await callback.answer() async def select_devices( callback: types.CallbackQuery, state: FSMContext, db_user: User ): + texts = get_texts(db_user.language) + + if not settings.is_devices_selection_enabled(): + await callback.answer( + texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Выбор количества устройств недоступен"), + show_alert=True, + ) + return + if not callback.data.startswith("devices_") or callback.data == "devices_continue": - await callback.answer("❌ Некорректный запрос", show_alert=True) + await callback.answer(texts.t("DEVICES_INVALID_REQUEST", "❌ Некорректный запрос"), show_alert=True) return try: devices = int(callback.data.split('_')[1]) except (ValueError, IndexError): - await callback.answer("❌ Некорректное количество устройств", show_alert=True) + await callback.answer(texts.t("DEVICES_INVALID_COUNT", "❌ Некорректное количество устройств"), show_alert=True) return data = await state.get_data() @@ -1321,27 +1483,8 @@ async def devices_continue( await callback.answer("⚠️ Некорректный запрос", show_alert=True) return - data = await state.get_data() - texts = get_texts(db_user.language) - - try: - summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) - except ValueError: - logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") - await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) - return - - await state.set_data(prepared_data) - await save_subscription_checkout_draft(db_user.id, prepared_data) - - await callback.message.edit_text( - summary_text, - reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML", - ) - - await state.set_state(SubscriptionStates.confirming_purchase) - await callback.answer() + if await present_subscription_summary(callback, state, db_user): + await callback.answer() async def confirm_purchase( callback: types.CallbackQuery, @@ -1436,30 +1579,48 @@ async def confirm_purchase( total_servers_discount = data.get('servers_discount_total', 0) servers_discount_percent = data.get('servers_discount_percent', 0) - additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) + devices_selection_enabled = settings.is_devices_selection_enabled() + forced_disabled_limit: Optional[int] = None + if devices_selection_enabled: + devices_selected = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + else: + forced_disabled_limit = settings.get_disabled_mode_device_limit() + if forced_disabled_limit is None: + devices_selected = settings.DEFAULT_DEVICE_LIMIT + else: + devices_selected = forced_disabled_limit + + additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = data.get( 'devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE ) - if 'devices_discount_percent' in data: - devices_discount_percent = data.get('devices_discount_percent', 0) - discounted_devices_price_per_month = data.get( - 'devices_discounted_price_per_month', devices_price_per_month - ) - devices_discount_total = data.get('devices_discount_total', 0) - total_devices_price = data.get( - 'total_devices_price', discounted_devices_price_per_month * months_in_period - ) - else: - devices_discount_percent = db_user.get_promo_discount( - "devices", - data['period_days'], - ) - discounted_devices_price_per_month, discount_per_month = apply_percentage_discount( - devices_price_per_month, - devices_discount_percent, - ) - devices_discount_total = discount_per_month * months_in_period - total_devices_price = discounted_devices_price_per_month * months_in_period + + devices_discount_percent = 0 + discounted_devices_price_per_month = 0 + devices_discount_total = 0 + total_devices_price = 0 + + if devices_selection_enabled and additional_devices > 0: + if 'devices_discount_percent' in data: + devices_discount_percent = data.get('devices_discount_percent', 0) + discounted_devices_price_per_month = data.get( + 'devices_discounted_price_per_month', devices_price_per_month + ) + devices_discount_total = data.get('devices_discount_total', 0) + total_devices_price = data.get( + 'total_devices_price', discounted_devices_price_per_month * months_in_period + ) + else: + devices_discount_percent = db_user.get_promo_discount( + "devices", + data['period_days'], + ) + discounted_devices_price_per_month, discount_per_month = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + devices_discount_total = discount_per_month * months_in_period + total_devices_price = discounted_devices_price_per_month * months_in_period if settings.is_traffic_fixed(): final_traffic_gb = settings.get_fixed_traffic_limit() @@ -1666,6 +1827,13 @@ async def confirm_purchase( return existing_subscription = db_user.subscription + if devices_selection_enabled: + selected_devices = devices_selected + else: + selected_devices = forced_disabled_limit + + should_update_devices = selected_devices is not None + was_trial_conversion = False current_time = datetime.utcnow() @@ -1708,7 +1876,8 @@ async def confirm_purchase( existing_subscription.is_trial = False existing_subscription.status = SubscriptionStatus.ACTIVE.value existing_subscription.traffic_limit_gb = final_traffic_gb - existing_subscription.device_limit = data['devices'] + if should_update_devices: + existing_subscription.device_limit = selected_devices existing_subscription.connected_squads = data['countries'] existing_subscription.start_date = current_time @@ -1723,11 +1892,26 @@ async def confirm_purchase( else: logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}") + default_device_limit = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1) + resolved_device_limit = selected_devices + + if resolved_device_limit is None: + if devices_selection_enabled: + resolved_device_limit = default_device_limit + else: + if forced_disabled_limit is not None: + resolved_device_limit = forced_disabled_limit + else: + resolved_device_limit = default_device_limit + + if resolved_device_limit is None and devices_selection_enabled: + resolved_device_limit = default_device_limit + subscription = await create_paid_subscription_with_traffic_mode( db=db, user_id=db_user.id, duration_days=data['period_days'], - device_limit=data['devices'], + device_limit=resolved_device_limit, connected_squads=data['countries'], traffic_gb=final_traffic_gb ) @@ -1745,11 +1929,11 @@ async def confirm_purchase( await add_user_to_servers(db, server_ids) logger.info(f"Сохранены цены серверов за весь период: {server_prices}") - + await db.refresh(db_user) - + subscription_service = SubscriptionService() - + if db_user.remnawave_uuid: remnawave_user = await subscription_service.update_remnawave_user( db, @@ -1764,7 +1948,7 @@ async def confirm_purchase( reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, reset_reason="покупка подписки", ) - + if not remnawave_user: logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}") remnawave_user = await subscription_service.create_remnawave_user( @@ -1773,7 +1957,7 @@ async def confirm_purchase( reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, reset_reason="покупка подписки (повторная попытка)", ) - + transaction = await create_transaction( db=db, user_id=db_user.id, @@ -1781,7 +1965,7 @@ async def confirm_purchase( amount_kopeks=final_price, description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)" ) - + try: notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_purchase_notification( @@ -1988,7 +2172,7 @@ async def create_paid_subscription_with_traffic_mode( db: AsyncSession, user_id: int, duration_days: int, - device_limit: int, + device_limit: Optional[int], connected_squads: List[str], traffic_gb: Optional[int] = None ): @@ -2002,16 +2186,20 @@ async def create_paid_subscription_with_traffic_mode( else: traffic_limit_gb = traffic_gb - subscription = await create_paid_subscription( + create_kwargs = dict( db=db, user_id=user_id, duration_days=duration_days, traffic_limit_gb=traffic_limit_gb, - device_limit=device_limit, connected_squads=connected_squads, update_server_counters=False, ) + if device_limit is not None: + create_kwargs['device_limit'] = device_limit + + subscription = await create_paid_subscription(**create_kwargs) + logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})") return subscription @@ -2034,9 +2222,14 @@ async def handle_subscription_settings( ) return - devices_used = await get_current_devices_count(db_user) + show_devices = settings.is_devices_selection_enabled() - settings_text = texts.t( + if show_devices: + devices_used = await get_current_devices_count(db_user) + else: + devices_used = 0 + + settings_template = texts.t( "SUBSCRIPTION_SETTINGS_OVERVIEW", ( "⚙️ Настройки подписки\n\n" @@ -2046,7 +2239,15 @@ async def handle_subscription_settings( "📱 Устройства: {devices_used} / {devices_limit}\n\n" "Выберите что хотите изменить:" ), - ).format( + ) + + if not show_devices: + settings_template = settings_template.replace( + "\n📱 Устройства: {devices_used} / {devices_limit}", + "", + ) + + settings_text = settings_template.format( countries_count=len(subscription.connected_squads), traffic_used=texts.format_traffic(subscription.traffic_used_gb), traffic_limit=texts.format_traffic(subscription.traffic_limit_gb), @@ -2384,10 +2585,13 @@ async def handle_simple_subscription_purchase( await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True) return + # Определяем ограничение по устройствам для текущего режима + simple_device_limit = resolve_simple_subscription_device_limit() + # Проверяем, есть ли у пользователя активная подписка from app.database.crud.subscription import get_subscription_by_user_id current_subscription = await get_subscription_by_user_id(db, db_user.id) - + # Если у пользователя уже есть активная подписка, продлеваем её if current_subscription and current_subscription.is_active: # Продлеваем существующую подписку @@ -2397,16 +2601,16 @@ async def handle_simple_subscription_purchase( db=db, current_subscription=current_subscription, period_days=settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS, - device_limit=settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT, + device_limit=simple_device_limit, traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB, squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID ) return - + # Подготовим параметры простой подписки subscription_params = { "period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS, - "device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT, + "device_limit": simple_device_limit, "traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB, "squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID } @@ -2441,16 +2645,26 @@ async def handle_simple_subscription_purchase( if user_balance_kopeks >= price_kopeks: # Если баланс достаточный, предлагаем оплатить с баланса - message_text = ( - f"⚡ Простая покупка подписки\n\n" - f"📅 Период: {subscription_params['period_days']} дней\n" - f"📱 Устройства: {subscription_params['device_limit']}\n" - f"📊 Трафик: {traffic_text}\n" - f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n" - f"💰 Стоимость: {settings.format_price(price_kopeks)}\n" - f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n" - f"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты." - ) + simple_lines = [ + "⚡ Простая покупка подписки", + "", + f"📅 Период: {subscription_params['period_days']} дней", + ] + + if settings.is_devices_selection_enabled(): + simple_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + + simple_lines.extend([ + f"📊 Трафик: {traffic_text}", + f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}", + "", + f"💰 Стоимость: {settings.format_price(price_kopeks)}", + f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}", + "", + "Вы можете оплатить подписку с баланса или выбрать другой способ оплаты.", + ]) + + message_text = "\n".join(simple_lines) keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")], @@ -2459,16 +2673,26 @@ async def handle_simple_subscription_purchase( ]) else: # Если баланс недостаточный, предлагаем внешние способы оплаты - message_text = ( - f"⚡ Простая покупка подписки\n\n" - f"📅 Период: {subscription_params['period_days']} дней\n" - f"📱 Устройства: {subscription_params['device_limit']}\n" - f"📊 Трафик: {traffic_text}\n" - f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n" - f"💰 Стоимость: {settings.format_price(price_kopeks)}\n" - f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n" - f"Выберите способ оплаты:" - ) + simple_lines = [ + "⚡ Простая покупка подписки", + "", + f"📅 Период: {subscription_params['period_days']} дней", + ] + + if settings.is_devices_selection_enabled(): + simple_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + + simple_lines.extend([ + f"📊 Трафик: {traffic_text}", + f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}", + "", + f"💰 Стоимость: {settings.format_price(price_kopeks)}", + f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}", + "", + "Выберите способ оплаты:", + ]) + + message_text = "\n".join(simple_lines) keyboard = _get_simple_subscription_payment_keyboard(db_user.language) diff --git a/app/handlers/subscription/summary.py b/app/handlers/subscription/summary.py new file mode 100644 index 00000000..ab65b0f9 --- /dev/null +++ b/app/handlers/subscription/summary.py @@ -0,0 +1,59 @@ +import logging +from typing import Optional, TYPE_CHECKING + +from aiogram import types +from aiogram.fsm.context import FSMContext + +from app.localization.texts import get_texts +from app.services.subscription_checkout_service import save_subscription_checkout_draft +from app.states import SubscriptionStates +from app.keyboards.inline import get_subscription_confirm_keyboard + +if TYPE_CHECKING: # pragma: no cover - only for type checking + from .pricing import _prepare_subscription_summary + + +logger = logging.getLogger(__name__) + + +async def present_subscription_summary( + callback: types.CallbackQuery, + state: FSMContext, + db_user, + texts: Optional = None, +) -> bool: + """Render the subscription purchase summary and switch to the confirmation state. + + Returns ``True`` when the summary is shown successfully and ``False`` if + calculation failed (an error is shown to the user in this case). + """ + + if texts is None: + texts = get_texts(db_user.language) + + data = await state.get_data() + + from .pricing import _prepare_subscription_summary + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) + except ValueError as exc: + logger.error( + "Ошибка в расчете цены подписки для пользователя %s: %s", + db_user.telegram_id, + exc, + ) + await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) + return False + + await state.set_data(prepared_data) + await save_subscription_checkout_draft(db_user.id, prepared_data) + + await callback.message.edit_text( + summary_text, + reply_markup=get_subscription_confirm_keyboard(db_user.language), + parse_mode="HTML", + ) + + await state.set_state(SubscriptionStates.confirming_purchase) + return True diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index 6733db4b..b86d85bf 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -80,6 +80,7 @@ from app.utils.promo_offer import ( from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger from .countries import _get_available_countries, _should_show_countries_management +from .summary import present_subscription_summary async def handle_add_traffic( callback: types.CallbackQuery, @@ -352,12 +353,15 @@ async def select_traffic( reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) - else: - countries = await _get_available_countries(db_user.promo_group_id) - available_countries = [c for c in countries if c.get('is_available', True)] - data['countries'] = [available_countries[0]['uuid']] if available_countries else [] - await state.set_data(data) + await callback.answer() + return + countries = await _get_available_countries(db_user.promo_group_id) + available_countries = [c for c in countries if c.get('is_available', True)] + data['countries'] = [available_countries[0]['uuid']] if available_countries else [] + await state.set_data(data) + + if settings.is_devices_selection_enabled(): selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( @@ -365,8 +369,11 @@ async def select_traffic( reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) await state.set_state(SubscriptionStates.selecting_devices) + await callback.answer() + return - await callback.answer() + if await present_subscription_summary(callback, state, db_user, texts): + await callback.answer() async def add_traffic( callback: types.CallbackQuery, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 2656392f..4ce7a25d 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2085,33 +2085,39 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, texts = get_texts(language) keyboard = [] - + if show_countries_management: keyboard.append([ InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries") ]) - keyboard.extend([ - [ - InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices") - ], - [ - InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices") - ] - ]) - if settings.is_traffic_selectable(): - keyboard.insert(-2, [ - InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic") - ]) - keyboard.insert(-2, [ + keyboard.append([ InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic") ]) - + keyboard.append([ + InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic") + ]) + + if settings.is_devices_selection_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), + callback_data="subscription_change_devices" + ) + ]) + + keyboard.append([ + InlineKeyboardButton( + text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), + callback_data="subscription_manage_devices" + ) + ]) + keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index b4d0666e..53e069e2 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -856,6 +856,9 @@ "DEVICE_CHANGE_NO_REFUND": "Payments are not refunded", "DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Payments are not refunded", "DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n", + "DEVICES_INVALID_REQUEST": "❌ Invalid request", + "DEVICES_INVALID_COUNT": "❌ Invalid device count", + "DEVICES_SELECTION_DISABLED": "⚠️ Device selection is unavailable", "DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?", "DEVICE_FETCH_ERROR": "❌ Failed to load devices", "DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information", @@ -1378,7 +1381,8 @@ "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", - "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB\n📱 Devices: {devices} pcs\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", + "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB{devices_line}\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", + "TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 Devices: {devices} pcs", "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Access paused\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.", "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index aa75bdd6..764fb962 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -856,6 +856,9 @@ "DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится", "DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится", "DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n", + "DEVICES_INVALID_REQUEST": "❌ Некорректный запрос", + "DEVICES_INVALID_COUNT": "❌ Некорректное количество устройств", + "DEVICES_SELECTION_DISABLED": "⚠️ Выбор количества устройств недоступен", "DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?", "DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств", "DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах", @@ -1378,7 +1381,8 @@ "TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!", "TRIAL_ACTIVATE_BUTTON": "🎁 Активировать", "TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована", - "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ\n📱 Устройства: {devices} шт.\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", + "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ{devices_line}\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", + "TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 Устройства: {devices} шт.", "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Доступ приостановлен\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.", "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index fb79756c..ab95c1ba 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -186,6 +186,14 @@ class AdminNotificationService: promo_group = await self._get_user_promo_group(db, user) promo_block = self._format_promo_group_block(promo_group) + trial_device_limit = subscription.device_limit + if trial_device_limit is None: + fallback_forced_limit = settings.get_disabled_mode_device_limit() + if fallback_forced_limit is not None: + trial_device_limit = fallback_forced_limit + else: + trial_device_limit = settings.TRIAL_DEVICE_LIMIT + message = f"""🎯 АКТИВАЦИЯ ТРИАЛА 👤 Пользователь: {user.full_name} @@ -198,7 +206,7 @@ class AdminNotificationService: ⏰ Параметры триала: 📅 Период: {settings.TRIAL_DURATION_DAYS} дней 📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ -📱 Устройства: {settings.TRIAL_DEVICE_LIMIT} +📱 Устройства: {trial_device_limit} 🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'} 📆 Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')} diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py index d4a09ea4..94feee87 100644 --- a/app/services/campaign_service.py +++ b/app/services/campaign_service.py @@ -120,9 +120,9 @@ class AdvertisingCampaignService: return CampaignBonusResult(success=False) traffic_limit = campaign.subscription_traffic_gb - device_limit = ( - campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT - ) + device_limit = campaign.subscription_device_limit + if device_limit is None: + device_limit = settings.DEFAULT_DEVICE_LIMIT squads = list(campaign.subscription_squads or []) if not squads: diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 857e4ea4..10e4cfb5 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -38,6 +38,9 @@ from app.database.crud.user import ( subtract_user_balance, cleanup_expired_promo_offer_discounts, ) +from app.utils.subscription_utils import ( + resolve_hwid_device_limit_for_payload, +) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.localization.texts import get_texts from app.services.notification_settings_service import NotificationSettingsService @@ -283,20 +286,26 @@ class MonitoringService: logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'") async with self.api as api: - updated_user = await api.update_user( + hwid_limit = resolve_hwid_device_limit_for_payload(subscription) + + update_kwargs = dict( uuid=user.remnawave_uuid, status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED, expire_at=subscription.end_date, traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), traffic_limit_strategy=TrafficLimitStrategy.MONTH, - hwid_device_limit=subscription.device_limit, description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads + active_internal_squads=subscription.connected_squads, ) + + if hwid_limit is not None: + update_kwargs['hwid_device_limit'] = hwid_limit + + updated_user = await api.update_user(**update_kwargs) subscription.subscription_url = updated_user.subscription_url subscription.subscription_crypto_link = updated_user.happ_crypto_link diff --git a/app/services/promocode_service.py b/app/services/promocode_service.py index 13bf92b0..2ce3fa91 100644 --- a/app/services/promocode_service.py +++ b/app/services/promocode_service.py @@ -131,12 +131,20 @@ class PromoCodeService: if getattr(settings, 'TRIAL_SQUAD_UUID', None): trial_squads = [settings.TRIAL_SQUAD_UUID] + forced_devices = None + if not settings.is_devices_selection_enabled(): + forced_devices = settings.get_disabled_mode_device_limit() + + device_limit = settings.DEFAULT_DEVICE_LIMIT + if forced_devices is not None: + device_limit = forced_devices + new_subscription = await create_paid_subscription( db=db, user_id=user.id, duration_days=promocode.subscription_days, traffic_limit_gb=0, - device_limit=1, + device_limit=device_limit, connected_squads=trial_squads, update_server_counters=True, ) @@ -155,10 +163,15 @@ class PromoCodeService: if not subscription: trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS + forced_devices = None + if not settings.is_devices_selection_enabled(): + forced_devices = settings.get_disabled_mode_device_limit() + trial_subscription = await create_trial_subscription( db, user.id, - duration_days=trial_days + duration_days=trial_days, + device_limit=forced_devices, ) await self.subscription_service.create_remnawave_user(db, trial_subscription) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index c7d6a279..71c31eee 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -38,6 +38,9 @@ from app.database.models import ( SubscriptionStatus, ServerSquad, ) +from app.utils.subscription_utils import ( + resolve_hwid_device_limit_for_payload, +) logger = logging.getLogger(__name__) @@ -1216,44 +1219,53 @@ class RemnaWaveService: for user in users: if not user.subscription: continue - + try: subscription = user.subscription - + hwid_limit = resolve_hwid_device_limit_for_payload(subscription) + if user.remnawave_uuid: - await api.update_user( + update_kwargs = dict( uuid=user.remnawave_uuid, status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, expire_at=subscription.end_date, traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, traffic_limit_strategy=TrafficLimitStrategy.MONTH, - hwid_device_limit=subscription.device_limit, description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads + active_internal_squads=subscription.connected_squads, ) + + if hwid_limit is not None: + update_kwargs['hwid_device_limit'] = hwid_limit + + await api.update_user(**update_kwargs) stats["updated"] += 1 else: username = f"user_{user.telegram_id}" - - new_user = await api.create_user( + + create_kwargs = dict( username=username, expire_at=subscription.end_date, status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, traffic_limit_strategy=TrafficLimitStrategy.MONTH, telegram_id=user.telegram_id, - hwid_device_limit=subscription.device_limit, description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads + active_internal_squads=subscription.connected_squads, ) + + if hwid_limit is not None: + create_kwargs['hwid_device_limit'] = hwid_limit + + new_user = await api.create_user(**create_kwargs) await update_user(db, user, remnawave_uuid=new_user.uuid) subscription.remnawave_short_uuid = new_user.short_uuid diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 74f0877a..08be31cb 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -17,6 +17,9 @@ from app.utils.pricing_utils import ( calculate_prorated_price, validate_pricing_calculation ) +from app.utils.subscription_utils import ( + resolve_hwid_device_limit_for_payload, +) logger = logging.getLogger(__name__) @@ -172,6 +175,7 @@ class SubscriptionService: return None async with self.get_api_client() as api: + hwid_limit = resolve_hwid_device_limit_for_payload(subscription) existing_users = await api.get_user_by_telegram_id(user.telegram_id) if existing_users: logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}") @@ -183,20 +187,24 @@ class SubscriptionService: except Exception as hwid_error: logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}") - updated_user = await api.update_user( + update_kwargs = dict( uuid=remnawave_user.uuid, 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(), - hwid_device_limit=subscription.device_limit, description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads + active_internal_squads=subscription.connected_squads, ) + + if hwid_limit is not None: + update_kwargs['hwid_device_limit'] = hwid_limit + + updated_user = await api.update_user(**update_kwargs) if reset_traffic: await self._reset_user_traffic( @@ -209,22 +217,26 @@ class SubscriptionService: else: logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}") username = f"user_{user.telegram_id}" - updated_user = await api.create_user( + create_kwargs = dict( username=username, 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(), telegram_id=user.telegram_id, - hwid_device_limit=subscription.device_limit, description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads + active_internal_squads=subscription.connected_squads, ) + if hwid_limit is not None: + create_kwargs['hwid_device_limit'] = hwid_limit + + updated_user = await api.create_user(**create_kwargs) + if reset_traffic: await self._reset_user_traffic( api, @@ -282,20 +294,26 @@ class SubscriptionService: logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'") async with self.get_api_client() as api: - updated_user = await api.update_user( + hwid_limit = resolve_hwid_device_limit_for_payload(subscription) + + update_kwargs = dict( uuid=user.remnawave_uuid, 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(), - hwid_device_limit=subscription.device_limit, description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads + active_internal_squads=subscription.connected_squads, ) + + if hwid_limit is not None: + update_kwargs['hwid_device_limit'] = hwid_limit + + updated_user = await api.update_user(**update_kwargs) if reset_traffic: await self._reset_user_traffic( @@ -565,7 +583,18 @@ class SubscriptionService: servers_discount = servers_price * servers_discount_percent // 100 discounted_servers_price = servers_price - servers_discount - devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE + device_limit = subscription.device_limit + if device_limit is None: + if settings.is_devices_selection_enabled(): + device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + forced_limit = settings.get_disabled_mode_device_limit() + if forced_limit is None: + device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + device_limit = forced_limit + + devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE devices_discount_percent = _resolve_discount_percent( user, promo_group, @@ -617,7 +646,7 @@ class SubscriptionService: ) logger.info(message) if devices_price > 0: - message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}₽" + message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}₽" if devices_discount > 0: message += ( f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)" @@ -894,7 +923,18 @@ class SubscriptionService: discounted_servers_per_month = servers_price_per_month - servers_discount_per_month total_servers_price = discounted_servers_per_month * months_in_period - additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) + device_limit = subscription.device_limit + if device_limit is None: + if settings.is_devices_selection_enabled(): + device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + forced_limit = settings.get_disabled_mode_device_limit() + if forced_limit is None: + device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + device_limit = forced_limit + + additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_discount_percent = _resolve_discount_percent( user, diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index ef0921bc..6ede739a 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -195,6 +195,8 @@ class BotConfigurationService: "DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE", "MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE", "PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE", + "DEVICES_SELECTION_ENABLED": "SUBSCRIPTIONS_CORE", + "DEVICES_SELECTION_DISABLED_AMOUNT": "SUBSCRIPTIONS_CORE", "BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE", "DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC", "RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC", @@ -450,6 +452,21 @@ class BotConfigurationService: "example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba", "warning": "Убедитесь, что выбранный сквад активен и доступен для подписки.", }, + "DEVICES_SELECTION_ENABLED": { + "description": "Разрешает пользователям выбирать количество устройств при покупке и продлении подписки.", + "format": "Булево значение.", + "example": "false", + "warning": "При отключении пользователи не смогут докупать устройства из интерфейса бота.", + }, + "DEVICES_SELECTION_DISABLED_AMOUNT": { + "description": ( + "Лимит устройств, который автоматически назначается, когда выбор количества устройств выключен. " + "Значение 0 отключает назначение устройств." + ), + "format": "Целое число от 0 и выше.", + "example": "3", + "warning": "При 0 RemnaWave не получит лимит устройств, пользователям не показываются цифры в интерфейсе.", + }, "CRYPTOBOT_ENABLED": { "description": "Разрешает принимать криптоплатежи через CryptoBot.", "format": "Булево значение.", diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index 17d5dc6f..11375394 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -174,3 +174,58 @@ def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) - return subscription_link return urlunparse(parsed_link._replace(scheme="happ")) + + +def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[int]: + """Return a device limit value for RemnaWave payloads when selection is enabled.""" + + if subscription is None: + return None + + if not settings.is_devices_selection_enabled(): + forced_limit = settings.get_disabled_mode_device_limit() + return forced_limit + + limit = getattr(subscription, "device_limit", None) + if limit is None or limit <= 0: + return None + + return limit + + +def resolve_hwid_device_limit_for_payload( + subscription: Optional[Subscription], +) -> Optional[int]: + """Return the device limit that should be sent to RemnaWave APIs. + + When device selection is disabled and no explicit override is configured, + RemnaWave should continue receiving the subscription's stored limit so the + external panel stays aligned with the bot configuration. + """ + + resolved_limit = resolve_hwid_device_limit(subscription) + + if resolved_limit is not None: + return resolved_limit + + if subscription is None: + return None + + fallback_limit = getattr(subscription, "device_limit", None) + if fallback_limit is None or fallback_limit <= 0: + return None + + return fallback_limit + + +def resolve_simple_subscription_device_limit() -> int: + """Return the effective device limit for simple subscription flows.""" + + if settings.is_devices_selection_enabled(): + return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0) + + forced_limit = settings.get_disabled_mode_device_limit() + if forced_limit is not None: + return forced_limit + + return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 24c54603..f4baefb5 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3121,8 +3121,16 @@ async def activate_subscription_trial_endpoint( }, ) + forced_devices = None + if not settings.is_devices_selection_enabled(): + forced_devices = settings.get_disabled_mode_device_limit() + try: - subscription = await create_trial_subscription(db, user.id) + subscription = await create_trial_subscription( + db, + user.id, + device_limit=forced_devices, + ) except Exception as error: # pragma: no cover - defensive logging logger.error( "Failed to activate trial subscription for user %s: %s", @@ -3638,7 +3646,9 @@ async def _calculate_subscription_renewal_pricing( if traffic_limit is None: traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB - devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT + devices_limit = subscription.device_limit + if devices_limit is None: + devices_limit = settings.DEFAULT_DEVICE_LIMIT total_cost, details = await calculate_subscription_total_cost( db, @@ -5044,7 +5054,12 @@ async def update_subscription_devices_endpoint( }, ) - current_devices = int(subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT or 1) + current_devices_value = subscription.device_limit + if current_devices_value is None: + fallback_value = settings.DEFAULT_DEVICE_LIMIT or 1 + current_devices_value = fallback_value + + current_devices = int(current_devices_value) old_devices = current_devices if new_devices == current_devices: diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py index b509da9b..3545f474 100644 --- a/app/webapi/routes/subscriptions.py +++ b/app/webapi/routes/subscriptions.py @@ -112,24 +112,38 @@ async def create_subscription( if existing: raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription") + forced_devices = None + if not settings.is_devices_selection_enabled(): + forced_devices = settings.get_disabled_mode_device_limit() + if payload.is_trial: + trial_device_limit = payload.device_limit + if trial_device_limit is None: + trial_device_limit = forced_devices + subscription = await create_trial_subscription( db, user_id=payload.user_id, duration_days=payload.duration_days, traffic_limit_gb=payload.traffic_limit_gb, - device_limit=payload.device_limit, + device_limit=trial_device_limit, squad_uuid=payload.squad_uuid, ) else: if payload.duration_days is None: raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions") + device_limit = payload.device_limit + if device_limit is None: + if forced_devices is not None: + device_limit = forced_devices + else: + device_limit = settings.DEFAULT_DEVICE_LIMIT subscription = await create_paid_subscription( db, user_id=payload.user_id, duration_days=payload.duration_days, traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB, - device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT, + device_limit=device_limit, connected_squads=payload.connected_squads or [], update_server_counters=True, ) diff --git a/tests/test_device_limit_resolution.py b/tests/test_device_limit_resolution.py new file mode 100644 index 00000000..cb4ccb40 --- /dev/null +++ b/tests/test_device_limit_resolution.py @@ -0,0 +1,167 @@ +import pytest + +from app.utils import subscription_utils +from app.utils.subscription_utils import ( + resolve_hwid_device_limit, + resolve_simple_subscription_device_limit, + resolve_hwid_device_limit_for_payload, +) + + +class DummySubscription: + def __init__(self, device_limit=None): + self.device_limit = device_limit + + +class StubSettings: + def __init__( + self, + enabled: bool, + disabled_amount, + *, + simple_limit: int = 3, + disabled_selection_amount=None, + ): + self._enabled = enabled + self._disabled_amount = disabled_amount + self._disabled_selection_amount = disabled_selection_amount + self.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT = simple_limit + + def is_devices_selection_enabled(self) -> bool: + return self._enabled + + def get_disabled_mode_device_limit(self): + return self._disabled_amount + + def get_devices_selection_disabled_amount(self): + return self._disabled_selection_amount + + +@pytest.mark.parametrize( + "forced_amount, expected", + [ + (None, None), + (0, 0), + (5, 5), + ], +) +def test_resolve_hwid_device_limit_disabled_mode(monkeypatch, forced_amount, expected): + subscription = DummySubscription(device_limit=42) + + monkeypatch.setattr( + subscription_utils, + "settings", + StubSettings( + enabled=False, + disabled_amount=forced_amount, + disabled_selection_amount=forced_amount, + ), + ) + + assert resolve_hwid_device_limit(subscription) == expected + + +def test_resolve_hwid_device_limit_enabled_mode(monkeypatch): + subscription = DummySubscription(device_limit=4) + + monkeypatch.setattr( + subscription_utils, + "settings", + StubSettings(enabled=True, disabled_amount=None), + ) + + assert resolve_hwid_device_limit(subscription) == 4 + + +def test_resolve_hwid_device_limit_enabled_ignores_non_positive(monkeypatch): + subscription = DummySubscription(device_limit=0) + + monkeypatch.setattr( + subscription_utils, + "settings", + StubSettings(enabled=True, disabled_amount=None), + ) + + assert resolve_hwid_device_limit(subscription) is None + + +def test_resolve_hwid_device_limit_for_payload_returns_subscription_limit(monkeypatch): + subscription = DummySubscription(device_limit=42) + + monkeypatch.setattr( + subscription_utils, + "settings", + StubSettings(enabled=False, disabled_amount=None, disabled_selection_amount=None), + ) + + assert resolve_hwid_device_limit(subscription) is None + assert resolve_hwid_device_limit_for_payload(subscription) == 42 + + +def test_resolve_hwid_device_limit_for_payload_ignores_non_positive(monkeypatch): + subscription = DummySubscription(device_limit=0) + + monkeypatch.setattr( + subscription_utils, + "settings", + StubSettings(enabled=False, disabled_amount=None, disabled_selection_amount=None), + ) + + assert resolve_hwid_device_limit(subscription) is None + assert resolve_hwid_device_limit_for_payload(subscription) is None + + +def test_resolve_hwid_device_limit_for_payload_prefers_forced_limit(monkeypatch): + subscription = DummySubscription(device_limit=42) + + monkeypatch.setattr( + subscription_utils, + "settings", + StubSettings(enabled=False, disabled_amount=7, disabled_selection_amount=7), + ) + + assert resolve_hwid_device_limit_for_payload(subscription) == 7 + + +def test_resolve_hwid_device_limit_for_payload_handles_zero(monkeypatch): + subscription = DummySubscription(device_limit=42) + + monkeypatch.setattr( + subscription_utils, + "settings", + StubSettings(enabled=False, disabled_amount=0, disabled_selection_amount=0), + ) + + assert resolve_hwid_device_limit(subscription) == 0 + assert resolve_hwid_device_limit_for_payload(subscription) == 0 + + +@pytest.mark.parametrize( + "enabled, simple_limit, disabled_amount, disabled_selection_amount, expected", + [ + (True, 4, None, None, 4), + (False, 4, None, None, 4), + (False, 4, 0, 0, 0), + (False, 4, 7, 7, 7), + ], +) +def test_resolve_simple_subscription_device_limit( + monkeypatch, + enabled, + simple_limit, + disabled_amount, + disabled_selection_amount, + expected, +): + monkeypatch.setattr( + subscription_utils, + "settings", + StubSettings( + enabled=enabled, + disabled_amount=disabled_amount, + simple_limit=simple_limit, + disabled_selection_amount=disabled_selection_amount, + ), + ) + + assert resolve_simple_subscription_device_limit() == expected diff --git a/tests/test_subscription_cart_integration.py b/tests/test_subscription_cart_integration.py index eb65f2bf..4f633e4b 100644 --- a/tests/test_subscription_cart_integration.py +++ b/tests/test_subscription_cart_integration.py @@ -142,6 +142,80 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc # В успешном сценарии вызывается callback.answer() mock_callback_query.answer.assert_called_once() + +@pytest.mark.asyncio +async def test_return_to_saved_cart_normalizes_devices_when_disabled( + mock_callback_query, + mock_state, + mock_user, + mock_db, +): + cart_data = { + 'period_days': 30, + 'countries': ['ru', 'us'], + 'devices': 5, + 'traffic_gb': 20, + 'total_price': 45000, + 'total_devices_price': 15000, + 'saved_cart': True, + 'user_id': mock_user.id, + } + + sanitized_summary_data = { + 'period_days': 30, + 'countries': ['ru', 'us'], + 'devices': 3, + 'traffic_gb': 20, + 'total_price': 30000, + 'total_devices_price': 0, + } + + with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ + patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \ + patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \ + patch('app.localization.texts.get_texts') as mock_get_texts, \ + patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, \ + patch('app.handlers.subscription.purchase.settings') as mock_settings, \ + patch('app.handlers.subscription.pricing._prepare_subscription_summary', new=AsyncMock(return_value=("ignored", sanitized_summary_data))): + + mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) + mock_cart_service.save_user_cart = AsyncMock() + mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}] + mock_format_period.return_value = "30 дней" + mock_keyboard = AsyncMock() + mock_keyboard_func.return_value = mock_keyboard + + mock_texts = AsyncMock() + mock_texts.format_price = lambda x: f"{x/100} ₽" + mock_texts.t = lambda key, default=None: default or "" + mock_get_texts.return_value = mock_texts + + mock_settings.is_devices_selection_enabled.return_value = False + mock_settings.DEFAULT_DEVICE_LIMIT = 3 + mock_settings.is_traffic_fixed.return_value = False + mock_settings.get_fixed_traffic_limit.return_value = 0 + + mock_user.balance_kopeks = 60000 + + await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) + + mock_cart_service.save_user_cart.assert_called_once() + _, saved_payload = mock_cart_service.save_user_cart.call_args[0] + assert saved_payload['devices'] == 3 + assert saved_payload['total_price'] == 30000 + assert saved_payload['saved_cart'] is True + + mock_state.set_data.assert_called_once() + normalized_data = mock_state.set_data.call_args[0][0] + assert normalized_data['devices'] == 3 + assert normalized_data['total_price'] == 30000 + assert normalized_data['saved_cart'] is True + + edited_text = mock_callback_query.message.edit_text.call_args[0][0] + assert "📱" not in edited_text + + mock_callback_query.answer.assert_called_once() + @pytest.mark.asyncio async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db): """Тест возврата к сохраненной корзине с недостаточным балансом"""