From 1cfede28b7570bcaf77cb53d6b2a9f3b0e4e9408 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Mar 2026 07:33:09 +0300 Subject: [PATCH] fix: prevent concurrent device purchases exceeding max device limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SELECT FOR UPDATE row lock on subscription before checking device limit in all 3 device purchase endpoints (cabinet new, cabinet legacy, miniapp). Without the lock, two concurrent requests both read the old device_limit, both pass validation, and both increment — resulting in device count exceeding max_device_limit (e.g., 5 devices when limit is 3). Also moved max-devices check before balance check in legacy endpoint to fail fast under lock. --- app/cabinet/routes/subscription.py | 41 +++++++++++++++++------------- app/webapi/routes/miniapp.py | 6 +++++ 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 85cbb629..a4aaa0e0 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -998,9 +998,13 @@ async def purchase_devices_legacy( detail='Subscription purchases are restricted for this account', ) - await db.refresh(user, ['subscription']) + # Lock subscription row to prevent concurrent device purchases exceeding the limit + result = await db.execute( + select(Subscription).where(Subscription.user_id == user.id).with_for_update() + ) + subscription = result.scalar_one_or_none() - if not user.subscription: + if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No subscription found', @@ -1018,6 +1022,17 @@ async def purchase_devices_legacy( if devices_discount_percent < 100 and total_price > 0: total_price = max(100, total_price) + # 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: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Maximum device limit is {max_devices}', + ) + # Check balance if user.balance_kopeks < total_price: missing = total_price - user.balance_kopeks @@ -1053,17 +1068,6 @@ async def purchase_devices_legacy( }, ) - # Check max devices limit - current_devices = user.subscription.device_limit or 1 - new_devices = current_devices + request.devices - max_devices = settings.MAX_DEVICES_LIMIT - - if new_devices > max_devices: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Maximum device limit is {max_devices}', - ) - # Deduct balance and create transaction from app.database.crud.user import subtract_user_balance from app.database.models import PaymentMethod @@ -1089,7 +1093,7 @@ async def purchase_devices_legacy( ) # Add devices - user.subscription.device_limit = new_devices + subscription.device_limit = new_devices await db.commit() await db.refresh(user) @@ -2292,8 +2296,11 @@ async def purchase_devices( ) try: - await db.refresh(user, ['subscription']) - subscription = user.subscription + # Lock subscription row to prevent concurrent device purchases exceeding the limit + result = await db.execute( + select(Subscription).where(Subscription.user_id == user.id).with_for_update() + ) + subscription = result.scalar_one_or_none() if not subscription: raise HTTPException( @@ -2329,7 +2336,7 @@ async def purchase_devices( detail='Докупка устройств недоступна', ) - # Check max device limit + # Check max device limit (under row lock — prevents concurrent purchases exceeding limit) current_devices = subscription.device_limit or 1 new_device_count = current_devices + request.devices if max_device_limit and new_device_count > max_device_limit: diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 718bdfdb..2f6c1344 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6113,6 +6113,12 @@ async def update_subscription_devices_endpoint( }, ) + # Re-read subscription under row lock to prevent concurrent device purchases exceeding limit + locked_result = await db.execute( + select(Subscription).where(Subscription.id == subscription.id).with_for_update() + ) + subscription = locked_result.scalar_one() + current_devices_value = subscription.device_limit if current_devices_value is None: fallback_value = settings.DEFAULT_DEVICE_LIMIT or 1