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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user