From 9ed4f086b0102c20d7861e03f4d212ff57e28245 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 22 Apr 2026 04:54:17 +0300 Subject: [PATCH] feat: tariff switch direction control, fix device pricing within tariff limit Tariff switch direction: - Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED settings to control allowed switch directions - Guard all 10 entry points: instant switch (list, preview, confirm), legacy switch (list, select, confirm, daily confirm), cabinet (preview, execute), purchase-options API - Filter tariff lists by allowed direction, show "unavailable" when both directions disabled - Expose settings in cabinet purchase-options response for frontend Device pricing fix: - Devices within tariff.device_limit are now free when restoring (was charging for all devices regardless of tariff inclusion) - Fix max(100, price) minimum enforcing 1 RUB even when chargeable_devices is 0 - Apply fix across all endpoints: bot handlers (confirm_change, execute_change, confirm_add), cabinet API (legacy purchase, modern purchase, get-price, save-cart), inline keyboard display --- .env.example | 9 ++ .../routes/subscription_modules/devices.py | 82 ++++++++++- .../routes/subscription_modules/purchase.py | 3 + .../subscription_modules/tariff_switch.py | 25 ++++ app/config.py | 2 + app/handlers/subscription/devices.py | 45 ++++-- app/handlers/subscription/tariff_purchase.py | 130 +++++++++++++++++- app/keyboards/inline.py | 4 +- 8 files changed, 280 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 7e6b8325..ce3ff93d 100644 --- a/.env.example +++ b/.env.example @@ -254,6 +254,15 @@ WEBHOOK_NOTIFY_DEVICES=true # - Подходит для продажи готовых пакетов услуг SALES_MODE=tariffs +# Управление сменой тарифа (для SALES_MODE=tariffs) +# UPGRADE / DOWNGRADE: +# true / true = все направления разрешены +# true / false = только повышение (на более дорогой тариф) +# false / true = только понижение (на более дешёвый тариф) +# false / false = смена тарифа полностью отключена +TARIFF_SWITCH_UPGRADE_ENABLED=true +TARIFF_SWITCH_DOWNGRADE_ENABLED=true + # ===== ТРИАЛ ПОДПИСКА ===== TRIAL_DURATION_DAYS=3 TRIAL_TRAFFIC_LIMIT_GB=10 diff --git a/app/cabinet/routes/subscription_modules/devices.py b/app/cabinet/routes/subscription_modules/devices.py index a2103c9d..60728108 100644 --- a/app/cabinet/routes/subscription_modules/devices.py +++ b/app/cabinet/routes/subscription_modules/devices.py @@ -108,7 +108,24 @@ async def purchase_devices_legacy( detail='Докупка устройств недоступна', ) - base_total_price = device_price * request.devices + # Устройства в пределах тарифного лимита — бесплатные + current_devices = subscription.device_limit or 1 + if tariff: + tariff_included = tariff.device_limit or 0 + if current_devices < tariff_included: + free_devices = tariff_included - current_devices + chargeable_devices = max(0, request.devices - free_devices) + else: + chargeable_devices = request.devices + else: + free_baseline = settings.DEFAULT_DEVICE_LIMIT + if current_devices < free_baseline: + free_devices = free_baseline - current_devices + chargeable_devices = max(0, request.devices - free_devices) + else: + chargeable_devices = request.devices + + base_total_price = device_price * chargeable_devices # Lock user row to prevent TOCTOU on promo-offer state from app.database.crud.user import lock_user_for_pricing @@ -366,10 +383,27 @@ async def purchase_devices( days_left = max(1, (end_date - now).days) total_days = 30 # Base period for device price calculation + # Устройства в пределах тарифного лимита — бесплатные + if tariff: + tariff_included = tariff.device_limit or 0 + if current_devices < tariff_included: + free_devices = tariff_included - current_devices + chargeable_devices = max(0, request.devices - free_devices) + else: + chargeable_devices = request.devices + else: + free_baseline = settings.DEFAULT_DEVICE_LIMIT + if current_devices < free_baseline: + free_devices = free_baseline - current_devices + chargeable_devices = max(0, request.devices - free_devices) + else: + chargeable_devices = request.devices + # Calculate base price before discount - base_price_per_month = device_price * request.devices + base_price_per_month = device_price * chargeable_devices base_price_prorated = int(base_price_per_month * days_left / total_days) - base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble + if chargeable_devices > 0: + base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble # Lock user BEFORE discount computation to prevent TOCTOU on promo group from app.database.crud.user import lock_user_for_pricing @@ -627,8 +661,25 @@ async def save_devices_cart( days_left = max(1, (end_date - now).days) total_days = 30 - base_total_price = int(device_price * request.devices * days_left / total_days) - base_total_price = max(100, base_total_price) # Minimum 1 ruble + # Устройства в пределах тарифного лимита — бесплатные + if tariff: + tariff_included = tariff.device_limit or 0 + if current_devices < tariff_included: + free_devices = tariff_included - current_devices + chargeable_devices = max(0, request.devices - free_devices) + else: + chargeable_devices = request.devices + else: + free_baseline = settings.DEFAULT_DEVICE_LIMIT + if current_devices < free_baseline: + free_devices = free_baseline - current_devices + chargeable_devices = max(0, request.devices - free_devices) + else: + chargeable_devices = request.devices + + base_total_price = int(device_price * chargeable_devices * days_left / total_days) + if chargeable_devices > 0: + base_total_price = max(100, base_total_price) # Minimum 1 ruble # Apply discount from promo group period_hint_days = days_left @@ -724,9 +775,26 @@ async def get_device_price( days_left = max(1, (end_date - now).days) total_days = 30 + # Устройства в пределах тарифного лимита — бесплатные + if tariff: + tariff_included = tariff.device_limit or 0 + if current_devices < tariff_included: + free_devices = tariff_included - current_devices + chargeable_devices = max(0, devices - free_devices) + else: + chargeable_devices = devices + else: + free_baseline = settings.DEFAULT_DEVICE_LIMIT + if current_devices < free_baseline: + free_devices = free_baseline - current_devices + chargeable_devices = max(0, devices - free_devices) + else: + chargeable_devices = devices + # Calculate base price before discount (total first, then floor) - base_total_price = int(device_price * devices * days_left / total_days) - base_total_price = max(100, base_total_price) + base_total_price = int(device_price * chargeable_devices * days_left / total_days) + if chargeable_devices > 0: + base_total_price = max(100, base_total_price) # Apply discount from promo group period_hint_days = days_left diff --git a/app/cabinet/routes/subscription_modules/purchase.py b/app/cabinet/routes/subscription_modules/purchase.py index 1ad2f11e..1c56429d 100644 --- a/app/cabinet/routes/subscription_modules/purchase.py +++ b/app/cabinet/routes/subscription_modules/purchase.py @@ -353,6 +353,9 @@ async def get_purchase_options( 'all_tariffs_purchased': len(purchased_tariff_ids) >= len(tariffs) if settings.is_multi_tariff_enabled() else False, + # Направления смены тарифа + 'tariff_switch_upgrade_enabled': settings.TARIFF_SWITCH_UPGRADE_ENABLED, + 'tariff_switch_downgrade_enabled': settings.TARIFF_SWITCH_DOWNGRADE_ENABLED, } # Classic mode - return periods diff --git a/app/cabinet/routes/subscription_modules/tariff_switch.py b/app/cabinet/routes/subscription_modules/tariff_switch.py index 3c52fe03..b08e15f7 100644 --- a/app/cabinet/routes/subscription_modules/tariff_switch.py +++ b/app/cabinet/routes/subscription_modules/tariff_switch.py @@ -119,6 +119,18 @@ async def preview_tariff_switch( ) upgrade_cost = switch_result.upgrade_cost is_upgrade = switch_result.is_upgrade + + # Проверяем разрешение на смену в данном направлении + if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Повышение тарифа недоступно', + ) + if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Понижение тарифа недоступно', + ) base_upgrade_cost = switch_result.raw_cost discount_value = switch_result.discount_value period_discount_percent = switch_result.effective_discount_pct @@ -263,11 +275,24 @@ async def switch_tariff( user=user, ) upgrade_cost = switch_result.upgrade_cost + is_upgrade = switch_result.is_upgrade base_upgrade_cost = switch_result.raw_cost discount_value = switch_result.discount_value period_discount_percent = switch_result.effective_discount_pct new_period_days = switch_result.new_period_days + # Проверяем разрешение на смену в данном направлении + if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Повышение тарифа недоступно', + ) + if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Понижение тарифа недоступно', + ) + # Validate daily price for switching TO daily new_is_daily = getattr(new_tariff, 'is_daily', False) current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False diff --git a/app/config.py b/app/config.py index 61eae72a..4d0a2499 100644 --- a/app/config.py +++ b/app/config.py @@ -148,6 +148,8 @@ class Settings(BaseSettings): DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH' RESET_TRAFFIC_ON_PAYMENT: bool = False RESET_TRAFFIC_ON_TARIFF_SWITCH: bool = True + TARIFF_SWITCH_UPGRADE_ENABLED: bool = True + TARIFF_SWITCH_DOWNGRADE_ENABLED: bool = True MAX_DEVICES_LIMIT: int = 20 TRIAL_WARNING_HOURS: int = 2 diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index c8e534bb..63087c89 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -325,9 +325,14 @@ async def confirm_change_devices( if devices_difference > 0: additional_devices = devices_difference - # Для тарифов - все устройства платные (нет бесплатного лимита) + # Устройства в пределах тарифного лимита — бесплатные if tariff: - chargeable_devices = additional_devices + tariff_included = tariff.device_limit or 0 + if current_devices < tariff_included: + free_devices = tariff_included - current_devices + chargeable_devices = max(0, additional_devices - free_devices) + else: + chargeable_devices = additional_devices elif current_devices < settings.DEFAULT_DEVICE_LIMIT: free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices chargeable_devices = max(0, additional_devices - free_devices) @@ -352,7 +357,8 @@ async def confirm_change_devices( ) # Цена = месячная_цена * days_left / 30 price = int(discounted_per_month * days_left / 30) - price = max(100, price) # Минимум 1 рубль + if chargeable_devices > 0: + price = max(100, price) # Минимум 1 рубль (только для платных устройств) total_discount = int(discount_per_month * days_left / 30) period_label = f'{days_left} дн.' if days_left > 1 else '1 день' @@ -553,7 +559,12 @@ async def execute_change_devices( devices_difference = new_devices_count - current_devices if devices_difference > 0: if tariff: - chargeable_devices = devices_difference + tariff_included = tariff.device_limit or 0 + if current_devices < tariff_included: + free_devices = tariff_included - current_devices + chargeable_devices = max(0, devices_difference - free_devices) + else: + chargeable_devices = devices_difference elif current_devices < settings.DEFAULT_DEVICE_LIMIT: free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices chargeable_devices = max(0, devices_difference - free_devices) @@ -572,7 +583,8 @@ async def execute_change_devices( devices_discount_percent, ) price = int(discounted_per_month * days_left / 30) - price = max(100, price) + if chargeable_devices > 0: + price = max(100, price) else: price = 0 @@ -1215,7 +1227,22 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: ) return - devices_price_per_month = devices_count * price_per_device + # Устройства в пределах тарифного лимита — бесплатные + current_devices = subscription.device_limit or 1 + if tariff: + tariff_included = tariff.device_limit or 0 + if current_devices < tariff_included: + free_devices = tariff_included - current_devices + chargeable_devices = max(0, devices_count - free_devices) + else: + chargeable_devices = devices_count + elif current_devices < settings.DEFAULT_DEVICE_LIMIT: + free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices + chargeable_devices = max(0, devices_count - free_devices) + else: + chargeable_devices = devices_count + + devices_price_per_month = chargeable_devices * price_per_device # TOCTOU: lock user row before reading promo/discount state db_user = await lock_user_for_pricing(db, db_user.id) @@ -1240,7 +1267,8 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: ) # Цена = месячная_цена * days_left / 30 price = int(discounted_per_month * days_left / 30) - price = max(100, price) # Минимум 1 рубль + if chargeable_devices > 0: + price = max(100, price) # Минимум 1 рубль (только для платных устройств) total_discount = int(discount_per_month * days_left / 30) period_label = f'{days_left} дн.' if days_left > 1 else '1 день' else: @@ -1260,7 +1288,8 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: ) # Цена = месячная_цена * days_left / 30 price = int(discounted_per_month * days_left / 30) - price = max(100, price) # Минимум 1 рубль + if chargeable_devices > 0: + price = max(100, price) # Минимум 1 рубль (только для платных устройств) total_discount = int(discount_per_month * days_left / 30) period_label = f'{days_left} дн.' if days_left > 1 else '1 день' diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index dbe70bcd..870c61d2 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -2666,6 +2666,18 @@ async def show_tariff_switch_list( current_tariff_id = subscription.tariff_id + # Проверяем, разрешена ли смена тарифа хотя бы в одном направлении + if not settings.TARIFF_SWITCH_UPGRADE_ENABLED and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED: + await callback.message.edit_text( + '🚫 Смена тарифа недоступна\n\nАдминистратор отключил возможность смены тарифа.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]] + ), + parse_mode='HTML', + ) + await callback.answer() + return + # Получаем доступные тарифы promo_group_id = getattr(db_user, 'promo_group_id', None) tariffs = await get_tariffs_for_user(db, promo_group_id) @@ -2678,6 +2690,14 @@ async def show_tariff_switch_list( else: available_tariffs = [t for t in tariffs if t.id != current_tariff_id] + # Фильтруем по разрешённым направлениям (upgrade/downgrade) + current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff_id else None + if current_tariff: + remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0 + available_tariffs = _filter_tariffs_by_switch_direction( + available_tariffs, current_tariff, remaining_days, db_user + ) + if not available_tariffs: await callback.message.edit_text( '😔 Нет доступных тарифов для переключения\n\nВы уже используете единственный доступный тариф.', @@ -2739,6 +2759,24 @@ async def select_tariff_switch( await callback.answer('Тариф недоступен', show_alert=True) return + # Проверяем разрешение на смену в данном направлении + current_subscription_sw, _sw_sub_id_check = await _resolve_subscription(callback, db_user, db, state) + if current_subscription_sw and current_subscription_sw.tariff_id: + cur_tariff_sw = await get_tariff_by_id(db, current_subscription_sw.tariff_id) + if cur_tariff_sw: + rem_days = ( + max(0, (current_subscription_sw.end_date - datetime.now(UTC)).days) + if current_subscription_sw.end_date + else 0 + ) + _, is_up = _calculate_instant_switch_cost(cur_tariff_sw, tariff, rem_days, db_user) + if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED: + await callback.answer('Повышение тарифа недоступно', show_alert=True) + return + if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED: + await callback.answer('Понижение тарифа недоступно', show_alert=True) + return + traffic = format_traffic(tariff.traffic_limit_gb) # Проверяем, суточный ли это тариф @@ -2960,6 +2998,19 @@ async def confirm_tariff_switch( await callback.answer('У вас нет активной подписки', show_alert=True) return + # Проверяем разрешение на смену в данном направлении + if subscription.tariff_id and subscription.tariff_id != tariff_id: + cur_tariff_obj = await get_tariff_by_id(db, subscription.tariff_id) + if cur_tariff_obj: + rem_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0 + _, is_up = _calculate_instant_switch_cost(cur_tariff_obj, tariff, rem_days, db_user) + if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED: + await callback.answer('Повышение тарифа недоступно', show_alert=True) + return + if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED: + await callback.answer('Понижение тарифа недоступно', show_alert=True) + return + # Calculate price via PricingEngine (handles per-category discounts + extra devices) from app.services.pricing_engine import pricing_engine @@ -3212,6 +3263,19 @@ async def confirm_daily_tariff_switch( await callback.answer('У вас нет активной подписки', show_alert=True) return + # Проверяем разрешение на смену в данном направлении + if subscription.tariff_id and subscription.tariff_id != tariff_id: + cur_tariff_daily = await get_tariff_by_id(db, subscription.tariff_id) + if cur_tariff_daily: + rem_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0 + _, is_up = _calculate_instant_switch_cost(cur_tariff_daily, tariff, rem_days, db_user) + if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED: + await callback.answer('Повышение тарифа недоступно', show_alert=True) + return + if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED: + await callback.answer('Понижение тарифа недоступно', show_alert=True) + return + texts = get_texts(db_user.language) try: @@ -3443,6 +3507,30 @@ def _calculate_instant_switch_cost( return result.upgrade_cost, result.is_upgrade +def _filter_tariffs_by_switch_direction( + tariffs: list[Tariff], + current_tariff: Tariff, + remaining_days: int, + db_user: User | None = None, +) -> list[Tariff]: + """Фильтрует тарифы по разрешённым направлениям смены (upgrade/downgrade).""" + upgrade_ok = settings.TARIFF_SWITCH_UPGRADE_ENABLED + downgrade_ok = settings.TARIFF_SWITCH_DOWNGRADE_ENABLED + + if upgrade_ok and downgrade_ok: + return tariffs + + filtered = [] + for tariff in tariffs: + if tariff.id == current_tariff.id: + filtered.append(tariff) + continue + _, is_upgrade = _calculate_instant_switch_cost(current_tariff, tariff, remaining_days, db_user) + if (is_upgrade and upgrade_ok) or (not is_upgrade and downgrade_ok): + filtered.append(tariff) + return filtered + + def format_instant_switch_list_text( tariffs: list[Tariff], current_tariff: Tariff, @@ -3450,16 +3538,21 @@ def format_instant_switch_list_text( db_user: User | None = None, ) -> str: """Форматирует текст со списком тарифов для мгновенного переключения.""" + upgrade_ok = settings.TARIFF_SWITCH_UPGRADE_ENABLED + downgrade_ok = settings.TARIFF_SWITCH_DOWNGRADE_ENABLED + lines = [ '📦 Мгновенная смена тарифа', f'📌 Текущий: {html.escape(current_tariff.name)}', f'⏰ Осталось: {remaining_days} дн.', '', '💡 При переключении остаток дней сохраняется.', - '⬆️ Повышение тарифа = доплата за разницу', - '⬇️ Понижение = бесплатно', - '', ] + if upgrade_ok: + lines.append('⬆️ Повышение тарифа = доплата за разницу') + if downgrade_ok: + lines.append('⬇️ Понижение = бесплатно') + lines.append('') for tariff in tariffs: if tariff.id == current_tariff.id: @@ -3591,6 +3684,18 @@ async def show_instant_switch_list( await callback.answer() return + # Проверяем, разрешена ли смена тарифа хотя бы в одном направлении + if not settings.TARIFF_SWITCH_UPGRADE_ENABLED and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED: + await callback.message.edit_text( + '🚫 Смена тарифа недоступна\n\nАдминистратор отключил возможность смены тарифа.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]] + ), + parse_mode='HTML', + ) + await callback.answer() + return + # Получаем доступные тарифы promo_group_id = getattr(db_user, 'promo_group_id', None) tariffs = await get_tariffs_for_user(db, promo_group_id) @@ -3603,6 +3708,9 @@ async def show_instant_switch_list( else: available_tariffs = [t for t in tariffs if t.id != current_tariff.id] + # Фильтруем по разрешённым направлениям (upgrade/downgrade) + available_tariffs = _filter_tariffs_by_switch_direction(available_tariffs, current_tariff, remaining_days, db_user) + if not available_tariffs: await callback.message.edit_text( '😔 Нет доступных тарифов для переключения\n\nВы уже используете единственный доступный тариф.', @@ -3672,6 +3780,14 @@ async def preview_instant_switch( # Рассчитываем стоимость переключения upgrade_cost, is_upgrade = _calculate_instant_switch_cost(current_tariff, new_tariff, remaining_days, db_user) + # Проверяем разрешение на смену в данном направлении + if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED: + await callback.answer('Повышение тарифа недоступно', show_alert=True) + return + if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED: + await callback.answer('Понижение тарифа недоступно', show_alert=True) + return + # Проверяем баланс user_balance = db_user.balance_kopeks or 0 @@ -3846,6 +3962,14 @@ async def confirm_instant_switch( is_upgrade = switch_result.is_upgrade consume_promo = switch_result.offer_discount_pct > 0 + # Проверяем разрешение на смену в данном направлении + if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED: + await callback.answer('Повышение тарифа недоступно', show_alert=True) + return + if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED: + await callback.answer('Понижение тарифа недоступно', show_alert=True) + return + # Проверяем баланс если это upgrade (use locked user's fresh balance) user_balance = db_user.balance_kopeks or 0 if is_upgrade and user_balance < upgrade_cost: diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index f3a60665..0fe51cdd 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2310,8 +2310,8 @@ def get_change_devices_keyboard( tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None if tariff and tariff_device_price: device_price_per_month = tariff_device_price - # Для тарифов все устройства платные (нет бесплатного лимита) - default_device_limit = 0 + # Устройства в пределах тарифного лимита — бесплатные + default_device_limit = tariff.device_limit if tariff else 0 else: device_price_per_month = settings.PRICE_PER_DEVICE default_device_limit = settings.DEFAULT_DEVICE_LIMIT