From f9f07f360c36ce1eade8a27fa0fa5bf22808db93 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Mar 2026 23:08:32 +0300 Subject: [PATCH] fix: enforce tariff device_price and max_device_limit across all purchase paths The miniapp, legacy cabinet endpoint, auto-purchase service, and Telegram bot handlers were using only global settings (PRICE_PER_DEVICE, MAX_DEVICES_LIMIT) for device purchases, completely ignoring tariff-level device_price_kopeks and max_device_limit. This allowed users to buy devices when tariff price was 0 (should be blocked) and exceed the tariff's max device limit. Fixed in all 4 code paths: - miniapp _build_subscription_settings + update_subscription_devices_endpoint - cabinet legacy POST /devices (+ added subscription status check, RemnaWave sync) - subscription_auto_purchase_service._auto_add_devices - telegram bot handlers confirm_change_devices, execute_change_devices, confirm_add_devices --- app/cabinet/routes/subscription.py | 53 +++++++++++++++--- app/handlers/subscription/devices.py | 45 ++++++++++++---- .../subscription_auto_purchase_service.py | 32 +++++++++-- app/webapi/routes/miniapp.py | 54 +++++++++++++++---- 4 files changed, 153 insertions(+), 31 deletions(-) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 17378071..c433972b 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -1008,9 +1008,10 @@ async def purchase_devices_legacy( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): - """Purchase additional device slots (legacy endpoint without tariff support). + """Purchase additional device slots (legacy endpoint). DEPRECATED: Use /devices/purchase instead for full tariff and discount support. + Now uses tariff-aware pricing when subscription has a tariff_id. """ if getattr(user, 'restriction_subscription', False): raise HTTPException( @@ -1033,8 +1034,34 @@ async def purchase_devices_legacy( detail='No subscription found', ) - price_per_device = settings.PRICE_PER_DEVICE - base_total_price = price_per_device * request.devices + if subscription.status not in ['active', 'trial']: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Ваша подписка неактивна', + ) + + # Get tariff for device price (if exists) + tariff = None + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + # Determine device price and max limit from tariff or settings + if tariff and tariff.device_price_kopeks is not None: + device_price = tariff.device_price_kopeks + max_device_limit = tariff.max_device_limit + else: + device_price = settings.PRICE_PER_DEVICE + max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None + + if not device_price or device_price <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Докупка устройств недоступна', + ) + + base_total_price = device_price * request.devices # Apply discount from promo group discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30) @@ -1048,12 +1075,11 @@ async def purchase_devices_legacy( # Check max devices limit (under row lock — prevents concurrent purchases exceeding limit) current_devices = subscription.device_limit or 1 new_devices = current_devices + request.devices - max_devices = settings.MAX_DEVICES_LIMIT - if new_devices > max_devices: + if max_device_limit and new_devices > max_device_limit: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Maximum device limit is {max_devices}', + detail=f'Максимальное количество устройств: {max_device_limit}', ) # Check balance @@ -1127,7 +1153,7 @@ async def purchase_devices_legacy( actual_current = subscription.device_limit or 1 actual_new = actual_current + request.devices - if max_devices > 0 and actual_new > max_devices: + if max_device_limit and actual_new > max_device_limit: # Concurrent purchase already exceeded limit — refund balance user_refund = await db.execute( select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True) @@ -1137,14 +1163,25 @@ async def purchase_devices_legacy( await db.commit() raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=f'Maximum device limit is {max_devices}. Balance refunded.', + detail=f'Максимальное количество устройств: {max_device_limit}. Баланс возвращён.', ) # Add devices (under lock) subscription.device_limit = actual_new await db.commit() + await db.refresh(subscription) await db.refresh(user) + # Sync with RemnaWave + try: + service = SubscriptionService() + if getattr(user, 'remnawave_uuid', None): + await service.update_remnawave_user(db, subscription) + else: + await service.create_remnawave_user(db, subscription) + except Exception as e: + logger.error('Failed to sync devices with RemnaWave (legacy endpoint)', error=e) + # Отправляем уведомление админам try: from aiogram import Bot diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index 2732409b..24d6a9bc 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -276,12 +276,19 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d ) return - if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT: + # Используем max_device_limit из тарифа если есть, иначе глобальную настройку + tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None + effective_max = ( + tariff_max_devices + if tariff_max_devices + else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) + ) + if effective_max and new_devices_count > effective_max: await callback.answer( texts.t( 'DEVICES_LIMIT_EXCEEDED', '⚠️ Превышен максимальный лимит устройств ({limit})', - ).format(limit=settings.MAX_DEVICES_LIMIT), + ).format(limit=effective_max), show_alert=True, ) return @@ -564,8 +571,13 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d # Re-validate: prevent double-charge and max-limit violation if new_devices_count > current_devices: - max_devices = settings.MAX_DEVICES_LIMIT - if max_devices > 0 and new_devices_count > max_devices: + tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None + max_devices = ( + tariff_max_recheck + if tariff_max_recheck + else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) + ) + if max_devices and new_devices_count > max_devices: if price > 0: user_refund = await db.execute( select(User) @@ -1126,10 +1138,20 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: new_total_devices = subscription.device_limit + devices_count - if settings.MAX_DEVICES_LIMIT > 0 and new_total_devices > settings.MAX_DEVICES_LIMIT: + # Используем max_device_limit из тарифа если есть, иначе глобальную настройку + tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None + effective_max = ( + tariff_max_devices + if tariff_max_devices + else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) + ) + if effective_max and new_total_devices > effective_max: await callback.answer( - f'⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). ' - f'У вас: {subscription.device_limit}, добавляете: {devices_count}', + texts.t( + 'DEVICES_LIMIT_EXCEEDED_DETAIL', + '⚠️ Превышен максимальный лимит устройств ({limit}). ' + 'У вас: {current}, добавляете: {adding}', + ).format(limit=effective_max, current=subscription.device_limit, adding=devices_count), show_alert=True, ) return @@ -1257,8 +1279,13 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: # Re-validate max device limit after re-lock actual_current = subscription.device_limit or 1 actual_new = actual_current + devices_count - max_devices = settings.MAX_DEVICES_LIMIT - if max_devices > 0 and actual_new > max_devices: + tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None + max_devices = ( + tariff_max_recheck + if tariff_max_recheck + else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) + ) + if max_devices and actual_new > max_devices: # Concurrent purchase exceeded limit — refund user_refund = await db.execute( select(User).where(User.id == db_user.id).with_for_update().execution_options(populate_existing=True) diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 59bedd01..3eac96ab 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -1241,17 +1241,41 @@ async def _auto_add_devices( await user_cart_service.delete_user_cart(user.id) return False + # Load tariff for device price and max limit + tariff = None + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + if tariff and tariff.device_price_kopeks is not None: + tariff_device_price = tariff.device_price_kopeks + tariff_max_device_limit = tariff.max_device_limit + else: + tariff_device_price = settings.PRICE_PER_DEVICE + tariff_max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None + + # Block purchase if device price is 0 or negative (purchase unavailable for this tariff) + if not tariff_device_price or tariff_device_price <= 0: + logger.warning( + '🔁 Автопокупка устройств: докупка устройств недоступна для тарифа, корзина удалена', + format_user_id=_format_user_id(user), + tariff_id=subscription.tariff_id, + tariff_device_price=tariff_device_price, + ) + await user_cart_service.delete_user_cart(user.id) + return False + # Check max device limit before charging old_device_limit = subscription.device_limit or 1 new_device_limit = old_device_limit + devices_to_add - max_devices = settings.MAX_DEVICES_LIMIT - if max_devices > 0 and new_device_limit > max_devices: + if tariff_max_device_limit and new_device_limit > tariff_max_device_limit: logger.warning( '🔁 Автопокупка устройств: превышен лимит устройств', format_user_id=_format_user_id(user), current=old_device_limit, requested=new_device_limit, - max_devices=max_devices, + tariff_max_device_limit=tariff_max_device_limit, ) await user_cart_service.delete_user_cart(user.id) return False @@ -1293,7 +1317,7 @@ async def _auto_add_devices( old_device_limit = subscription.device_limit or 1 new_device_limit = old_device_limit + devices_to_add - if max_devices > 0 and new_device_limit > max_devices: + if tariff_max_device_limit and new_device_limit > tariff_max_device_limit: # Concurrent modification exceeded limit — refund user_refund = await db.execute( select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 17bafe01..a1f0377c 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -5024,14 +5024,29 @@ async def _build_subscription_settings( default_device_limit = max(settings.DEFAULT_DEVICE_LIMIT, 1) current_device_limit = int(subscription.device_limit or default_device_limit) - max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None + # Load tariff for device price and max limit + tariff = None + if subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + # Determine device price and max limit from tariff or global settings + if tariff and tariff.device_price_kopeks is not None: + base_device_price = tariff.device_price_kopeks + max_devices_setting = tariff.max_device_limit + else: + base_device_price = settings.PRICE_PER_DEVICE + max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None + + # If device price is 0 or negative, device purchase is unavailable + devices_can_update = bool(base_device_price and base_device_price > 0) + if max_devices_setting is not None: max_devices = max(max_devices_setting, current_device_limit, default_device_limit) else: max_devices = max(current_device_limit, default_device_limit) + 10 discounted_single_device, _ = apply_percentage_discount( - settings.PRICE_PER_DEVICE, + base_device_price, devices_discount, ) @@ -5039,7 +5054,7 @@ async def _build_subscription_settings( for value in range(1, max_devices + 1): chargeable = max(0, value - default_device_limit) discounted_per_month, _ = apply_percentage_discount( - chargeable * settings.PRICE_PER_DEVICE, + chargeable * base_device_price, devices_discount, ) devices_options.append( @@ -5074,7 +5089,7 @@ async def _build_subscription_settings( ), devices=MiniAppSubscriptionDevicesSettings( options=devices_options, - can_update=True, + can_update=devices_can_update, min=1, max=max_devices_setting or 0, step=1, @@ -6102,12 +6117,32 @@ async def update_subscription_devices_endpoint( detail={'code': 'validation_error', 'message': 'Device limit must be positive'}, ) - if settings.MAX_DEVICES_LIMIT > 0 and new_devices > settings.MAX_DEVICES_LIMIT: + # Load tariff for device price and max limit + tariff = None + if subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + if tariff and tariff.device_price_kopeks is not None: + tariff_device_price = tariff.device_price_kopeks + tariff_max_device_limit = tariff.max_device_limit + else: + tariff_device_price = settings.PRICE_PER_DEVICE + tariff_max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None + + # Block purchase if device price is 0 (purchase unavailable for this tariff) + if not tariff_device_price or tariff_device_price <= 0: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={'code': 'devices_unavailable', 'message': 'Докупка устройств недоступна'}, + ) + + # Enforce tariff max device limit + if tariff_max_device_limit and new_devices > tariff_max_device_limit: raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ 'code': 'devices_limit_exceeded', - 'message': (f'Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT})'), + 'message': f'Превышен максимальный лимит устройств ({tariff_max_device_limit})', }, ) @@ -6140,7 +6175,7 @@ async def update_subscription_devices_endpoint( new_chargeable = max(0, new_devices - settings.DEFAULT_DEVICE_LIMIT) chargeable_diff = new_chargeable - current_chargeable - price_per_month = chargeable_diff * settings.PRICE_PER_DEVICE + price_per_month = chargeable_diff * tariff_device_price months_remaining = get_remaining_months(subscription.end_date) period_hint_days = months_remaining * 30 if months_remaining > 0 else None devices_discount = _get_addon_discount_percent_for_user( @@ -6206,9 +6241,8 @@ async def update_subscription_devices_endpoint( actual_current = subscription.device_limit or 1 actual_delta = new_devices - actual_current - max_devices_limit = settings.MAX_DEVICES_LIMIT - if actual_delta <= 0 or (max_devices_limit > 0 and new_devices > max_devices_limit): + if actual_delta <= 0 or (tariff_max_device_limit and new_devices > tariff_max_device_limit): # Concurrent request already applied the change or pushed limit beyond max — refund user_refund = await db.execute( select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True) @@ -6228,7 +6262,7 @@ async def update_subscription_devices_endpoint( status.HTTP_409_CONFLICT, detail={ 'code': 'devices_limit_exceeded', - 'message': f'Превышен максимальный лимит устройств ({max_devices_limit}). Баланс возвращён.', + 'message': f'Превышен максимальный лимит устройств ({tariff_max_device_limit}). Баланс возвращён.', }, )