From da855a7c8955dcbf3543dbbaedc00e16b9c35193 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 22 Apr 2026 04:25:42 +0300 Subject: [PATCH] fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pricing_engine: use shortest period for daily rate comparison instead of period closest to remaining_days — fixes incorrect free/zero cost for upgrades when tariffs have different period sets - pricing_engine: remove unused target_days parameter from get_tariff_daily_rate_fraction - admin_users: add duplicate subscription check before create, change_tariff and activate actions to prevent UniqueViolationError on uq_subscriptions_user_tariff_active constraint - admin_users: add IntegrityError fallback on create as TOCTOU safety net --- app/cabinet/routes/admin_users.py | 64 ++++++++++++++++++++++++++----- app/services/pricing_engine.py | 12 ++++-- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index ad053d26..93f59f3d 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -1050,6 +1050,17 @@ async def update_user_subscription( detail='User already has a subscription. Enable multi-tariff mode to add more.', ) + # Проверка: нельзя создать вторую активную подписку с тем же тарифом + if is_multi_tariff and request.tariff_id: + from app.database.crud.subscription import get_subscription_by_user_and_tariff + + existing = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='User already has an active subscription for this tariff. Extend it instead.', + ) + from app.database.crud.subscription import create_paid_subscription days = request.days or 30 @@ -1069,16 +1080,25 @@ async def update_user_subscription( if tariff.allowed_squads: connected_squads = tariff.allowed_squads - new_sub = await create_paid_subscription( - db=db, - user_id=user.id, - duration_days=days, - traffic_limit_gb=traffic_limit, - device_limit=device_limit, - is_trial=is_trial, - tariff_id=request.tariff_id, - connected_squads=connected_squads, - ) + from sqlalchemy.exc import IntegrityError + + try: + new_sub = await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=days, + traffic_limit_gb=traffic_limit, + device_limit=device_limit, + is_trial=is_trial, + tariff_id=request.tariff_id, + connected_squads=connected_squads, + ) + except IntegrityError: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='User already has an active subscription for this tariff. Extend it instead.', + ) # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, new_sub) @@ -1191,6 +1211,18 @@ async def update_user_subscription( detail='Tariff not found', ) + # Проверка: нельзя сменить тариф, если у пользователя уже есть + # другая активная подписка с целевым тарифом + if is_multi_tariff and request.tariff_id != subscription.tariff_id: + from app.database.crud.subscription import get_subscription_by_user_and_tariff + + existing = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id) + if existing and existing.id != subscription.id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='User already has an active subscription for the target tariff', + ) + # Preserve extra purchased devices above the old tariff's base limit from app.database.crud.subscription import calc_device_limit_on_tariff_switch @@ -1322,6 +1354,18 @@ async def update_user_subscription( ) if request.action == 'activate': + # Проверка: нельзя активировать, если у пользователя уже есть + # другая активная подписка с тем же тарифом + if is_multi_tariff and subscription.tariff_id: + from app.database.crud.subscription import get_subscription_by_user_and_tariff + + existing = await get_subscription_by_user_and_tariff(db, user.id, subscription.tariff_id) + if existing and existing.id != subscription.id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Cannot activate: user already has an active subscription for this tariff', + ) + subscription.status = SubscriptionStatus.ACTIVE.value if subscription.end_date and subscription.end_date <= datetime.now(UTC): # Extend by 30 days if expired diff --git a/app/services/pricing_engine.py b/app/services/pricing_engine.py index 8e72267c..499da965 100644 --- a/app/services/pricing_engine.py +++ b/app/services/pricing_engine.py @@ -202,16 +202,20 @@ class PricingEngine: # ------------------------------------------------------------------ @staticmethod - def get_tariff_daily_rate_fraction(tariff: Tariff, target_days: int) -> tuple[int, int]: + def get_tariff_daily_rate_fraction(tariff: Tariff) -> tuple[int, int]: """Дневная ставка тарифа как (price, period_days) для целочисленных вычислений. Возвращает числитель и знаменатель дроби price/period_days, чтобы избежать float-ошибок в финансовых расчётах. + + Всегда использует кратчайший доступный период тарифа, чтобы + гарантировать корректное сравнение дневных ставок при смене + тарифов с разными наборами периодов. """ periods = tariff.get_available_periods() if not periods: return 0, 1 - best_period = min(periods, key=lambda p: abs(p - target_days)) + best_period = min(periods) price = tariff.get_price_for_period(best_period) if not price or best_period <= 0: return 0, 1 @@ -270,8 +274,8 @@ class PricingEngine: # raw_cost = (new_p/new_d - cur_p/cur_d) * remaining # = (new_p * cur_d - cur_p * new_d) * remaining / (new_d * cur_d) # Floor division (//) округляет дробные копейки вниз — в пользу пользователя. - cur_price, cur_period = self.get_tariff_daily_rate_fraction(current_tariff, remaining_days) - new_price, new_period = self.get_tariff_daily_rate_fraction(new_tariff, remaining_days) + cur_price, cur_period = self.get_tariff_daily_rate_fraction(current_tariff) + new_price, new_period = self.get_tariff_daily_rate_fraction(new_tariff) numerator = (new_price * cur_period - cur_price * new_period) * remaining_days denominator = new_period * cur_period