fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard

- 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
This commit is contained in:
Fringg
2026-04-22 04:25:42 +03:00
parent 7be404b918
commit da855a7c89
2 changed files with 62 additions and 14 deletions
+54 -10
View File
@@ -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
+8 -4
View File
@@ -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