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
This commit is contained in:
Fringg
2026-03-08 23:08:32 +03:00
parent 770b31d3d0
commit f9f07f360c
4 changed files with 153 additions and 31 deletions
+45 -8
View File
@@ -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
+36 -9
View File
@@ -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)
@@ -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)
+44 -10
View File
@@ -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}). Баланс возвращён.',
},
)