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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}). Баланс возвращён.',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user