Files
remnawave-bedolaga-telegram…/app/cabinet/routes/subscription.py
T
Fringg 8d3cd50098 refactor: централизация всех расчётов цен в PricingEngine
- Мигрирован confirm_purchase() на calculate_classic_new_subscription_price()
- Мигрирован compute_simple_subscription_price на делегацию в PricingEngine
- Мигрирован handle_custom_confirm на calculate_tariff_purchase_price()
- Мигрированы daily confirm handlers (confirm_daily_tariff_purchase,
  confirm_daily_tariff_switch, confirm_instant_switch daily path)
- Мигрирован gift.py на calculate_tariff_purchase_price()
- Мигрированы FSM cache prices (select_period, select_devices, toggle_country)
- Добавлен lock_user_for_pricing в admin_buy_tariff_execute (TOCTOU fix)
- Добавлен lock + recompute в _auto_add_devices и _auto_add_traffic
- Исправлено двойное применение promo-offer в simple_subscription (критический баг)
- Унифицирован daily price display (group+offer) на всех 6 поверхностях
- PricingEngine.get_addon_discount_percent: добавлен promo_group= kwarg
- PricingEngine._calculate_switch_to/from_daily: добавлен promo-offer discount
- Удалён мёртвый код из common.py (_get_addon_discount_percent_for_user)
- Miniapp period_discounts: исправлен доступ через get_discount_percent()
2026-03-16 03:10:22 +03:00

4649 lines
182 KiB
Python

"""Subscription management routes for cabinet."""
import base64
import re
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.crud.subscription import (
create_paid_subscription,
create_trial_subscription,
extend_subscription,
get_subscription_by_user_id,
)
from app.database.crud.tariff import get_tariff_by_id, get_tariffs_for_user
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod, ServerSquad, Subscription, Tariff, TransactionType, User
from app.services.notification_delivery_service import (
NotificationType,
notification_delivery_service,
)
from app.services.pricing_engine import pricing_engine
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_purchase_service import (
MiniAppSubscriptionPurchaseService,
PurchaseBalanceError,
PurchaseValidationError,
)
from app.services.subscription_renewal_service import (
SubscriptionRenewalChargeError,
SubscriptionRenewalService,
)
from app.services.subscription_service import SubscriptionService
from app.services.system_settings_service import bot_configuration_service
from app.services.user_cart_service import user_cart_service
from app.utils.cache import RateLimitCache, cache, cache_key
from app.utils.pricing_utils import format_period_description
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.subscription import (
AutopayUpdateRequest,
DevicePurchaseRequest,
PurchasePreviewRequest,
RenewalOptionResponse,
RenewalRequest,
ServerInfo,
SubscriptionData,
SubscriptionResponse,
SubscriptionStatusResponse,
TariffPurchaseRequest,
TrafficPackageResponse,
TrafficPurchaseRequest,
TrialInfoResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription'])
def _get_addon_discount_percent(
user: User | None,
category: str,
period_days_hint: int | None = None,
) -> int:
"""Get addon discount percent for user — delegates to PricingEngine."""
from app.services.pricing_engine import PricingEngine
return PricingEngine.get_addon_discount_percent(user, category, period_days_hint)
def _apply_addon_discount(
user: User,
category: str,
amount: int,
period_days: int | None = None,
) -> dict[str, int]:
"""Apply addon discount to amount.
Returns dict with keys: discounted, discount, percent
"""
from app.utils.pricing_utils import apply_percentage_discount
percent = _get_addon_discount_percent(user, category, period_days)
if percent <= 0 or amount <= 0:
return {'discounted': amount, 'discount': 0, 'percent': 0}
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
return {
'discounted': discounted_amount,
'discount': discount_value,
'percent': percent,
}
def _subscription_to_response(
subscription: Subscription,
servers: list[ServerInfo] | None = None,
tariff_name: str | None = None,
traffic_purchases: list[dict[str, Any]] | None = None,
user: User | None = None,
) -> SubscriptionData:
"""Convert Subscription model to response."""
now = datetime.now(UTC)
# Use actual_status property for correct status (same as bot uses)
actual_status = subscription.actual_status
is_expired = actual_status == 'expired'
is_active = actual_status in ('active', 'trial')
is_limited = actual_status == 'limited'
# Calculate time remaining
days_left = 0
hours_left = 0
minutes_left = 0
time_left_display = ''
if subscription.end_date and not is_expired:
time_delta = subscription.end_date - now
total_seconds = max(0, int(time_delta.total_seconds()))
days_left = total_seconds // 86400 # 86400 seconds in a day
remaining_seconds = total_seconds % 86400
hours_left = remaining_seconds // 3600
minutes_left = (remaining_seconds % 3600) // 60
# Create human-readable display
if days_left > 0:
time_left_display = f'{days_left}d {hours_left}h'
elif hours_left > 0:
time_left_display = f'{hours_left}h {minutes_left}m'
elif minutes_left > 0:
time_left_display = f'{minutes_left}m'
else:
time_left_display = '0m'
else:
time_left_display = '0m'
traffic_limit_gb = subscription.traffic_limit_gb or 0
traffic_used_gb = subscription.traffic_used_gb or 0.0
if traffic_limit_gb > 0:
traffic_used_percent = min(100, (traffic_used_gb / traffic_limit_gb) * 100)
else:
traffic_used_percent = 0
# Check if this is a daily tariff
is_daily_paused = getattr(subscription, 'is_daily_paused', False) or False
tariff_id = getattr(subscription, 'tariff_id', None)
# Use subscription's is_daily_tariff property if available
is_daily = False
daily_price_kopeks = None
if hasattr(subscription, 'is_daily_tariff'):
is_daily = subscription.is_daily_tariff
elif tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
is_daily = getattr(subscription.tariff, 'is_daily', False)
# Get daily_price_kopeks, tariff_name, traffic_reset_mode from tariff
traffic_reset_mode = None
if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None)
# Применяем скидку промогруппы + promo-offer для отображения
if daily_price_kopeks and daily_price_kopeks > 0 and user:
from app.services.pricing_engine import PricingEngine
from app.utils.promo_offer import get_user_active_promo_discount_percent
_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
_group_pct = _promo_group.get_discount_percent('period', 1) if _promo_group else 0
_offer_pct = get_user_active_promo_discount_percent(user)
if _group_pct > 0 or _offer_pct > 0:
daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts(
daily_price_kopeks, _group_pct, _offer_pct
)
if not tariff_name: # Only set if not passed as parameter
tariff_name = getattr(subscription.tariff, 'name', None)
traffic_reset_mode = (
getattr(subscription.tariff, 'traffic_reset_mode', None) or settings.DEFAULT_TRAFFIC_RESET_STRATEGY
)
# Calculate next daily charge time (24 hours after last charge)
next_daily_charge_at = None
if is_daily and not is_daily_paused:
last_charge = getattr(subscription, 'last_daily_charge_at', None)
if last_charge:
next_charge = last_charge + timedelta(days=1)
# Если время списания уже прошло — не показываем (DailySubscriptionService обработает)
if next_charge > datetime.now(UTC):
next_daily_charge_at = next_charge
# Проверяем настройку скрытия ссылки (скрывается только текст, кнопки работают)
hide_link = settings.should_hide_subscription_link()
return SubscriptionResponse(
id=subscription.id,
status=actual_status, # Use actual_status instead of raw status
is_trial=subscription.is_trial or actual_status == 'trial',
start_date=subscription.start_date,
end_date=subscription.end_date,
days_left=days_left,
hours_left=hours_left,
minutes_left=minutes_left,
time_left_display=time_left_display,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=round(traffic_used_gb, 2),
traffic_used_percent=round(traffic_used_percent, 1),
device_limit=subscription.device_limit or 0,
connected_squads=subscription.connected_squads or [],
servers=servers or [],
autopay_enabled=subscription.autopay_enabled or False,
autopay_days_before=subscription.autopay_days_before or 3,
subscription_url=subscription.subscription_url,
hide_subscription_link=hide_link,
is_active=is_active,
is_expired=is_expired,
is_limited=is_limited,
traffic_purchases=traffic_purchases or [],
is_daily=is_daily,
is_daily_paused=is_daily_paused,
daily_price_kopeks=daily_price_kopeks,
next_daily_charge_at=next_daily_charge_at,
tariff_id=tariff_id,
tariff_name=tariff_name,
traffic_reset_mode=traffic_reset_mode,
)
@router.get('', response_model=SubscriptionStatusResponse)
async def get_subscription(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get current user's subscription details."""
# Reload user from current session to get fresh data
# (user object is from different session in get_current_cabinet_user)
from app.database.crud.user import get_user_by_id
fresh_user = await get_user_by_id(db, user.id)
if not fresh_user or not fresh_user.subscription:
# Return 200 with has_subscription: false instead of 404
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
# Load tariff for daily subscription check and tariff name
tariff_name = None
if fresh_user.subscription.tariff_id:
tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id)
if tariff:
fresh_user.subscription.tariff = tariff
tariff_name = tariff.name
# Fetch server names for connected squads
servers: list[ServerInfo] = []
connected_squads = fresh_user.subscription.connected_squads or []
if connected_squads:
result = await db.execute(select(ServerSquad).where(ServerSquad.squad_uuid.in_(connected_squads)))
server_squads = result.scalars().all()
servers = [
ServerInfo(uuid=sq.squad_uuid, name=sq.display_name, country_code=sq.country_code) for sq in server_squads
]
# Fetch traffic purchases (monthly packages)
traffic_purchases_data = []
from app.database.models import TrafficPurchase
now = datetime.now(UTC)
purchases_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == fresh_user.subscription.id)
.where(TrafficPurchase.expires_at > now)
.order_by(TrafficPurchase.expires_at.asc())
)
purchases_result = await db.execute(purchases_query)
purchases = purchases_result.scalars().all()
for purchase in purchases:
time_remaining = purchase.expires_at - now
days_remaining = max(0, int(time_remaining.total_seconds() / 86400))
total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds()
elapsed_seconds = (now - purchase.created_at).total_seconds()
progress_percent = min(
100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)
)
traffic_purchases_data.append(
{
'id': purchase.id,
'traffic_gb': purchase.traffic_gb,
'expires_at': purchase.expires_at,
'created_at': purchase.created_at,
'days_remaining': days_remaining,
'progress_percent': round(progress_percent, 1),
}
)
subscription_data = _subscription_to_response(
fresh_user.subscription, servers, tariff_name, traffic_purchases_data, user=fresh_user
)
return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data)
@router.get('/renewal-options', response_model=list[RenewalOptionResponse])
async def get_renewal_options(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get available subscription renewal options with prices."""
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
return []
# Determine available periods
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
else:
periods = settings.get_available_renewal_periods()
options = []
for period in periods:
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
if pricing.final_total <= 0 and pricing.base_price <= 0:
continue
original_price = pricing.original_total
combined_discount = 0
if original_price > 0 and original_price != pricing.final_total:
combined_discount = int((original_price - pricing.final_total) * 100 / original_price)
options.append(
RenewalOptionResponse(
period_days=period,
price_kopeks=pricing.final_total,
price_rubles=pricing.final_total / 100,
discount_percent=combined_discount,
original_price_kopeks=original_price if combined_discount > 0 else None,
)
)
return options
@router.post('/renew')
async def renew_subscription(
request: RenewalRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Renew subscription (pay from balance)."""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription renewal is restricted for this account',
)
await db.refresh(user, ['subscription'])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
# Validate period_days against available periods (prevent arbitrary periods)
subscription = user.subscription
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
else:
available_periods = settings.get_available_renewal_periods()
if request.period_days not in available_periods:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Selected renewal period is not available',
)
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Unified pricing via PricingEngine
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
request.period_days,
user=user,
)
price_kopeks = pricing.final_total
promo_offer_discount_value = pricing.promo_offer_discount
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
if price_kopeks <= 0 and pricing.base_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid renewal period',
)
original_price_kopeks = pricing.original_total
discount_percent = 0
if original_price_kopeks > 0 and original_price_kopeks != price_kopeks:
discount_percent = int((original_price_kopeks - price_kopeks) * 100 / original_price_kopeks)
tariff = user.subscription.tariff if user.subscription.tariff_id else None
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Get tariff info for cart
tariff_id = user.subscription.tariff_id
tariff_name = None
tariff_traffic_limit_gb = None
tariff_allowed_squads = None
if tariff_id:
tariff = await get_tariff_by_id(db, tariff_id)
if tariff:
tariff_name = tariff.name
tariff_traffic_limit_gb = tariff.traffic_limit_gb
tariff_allowed_squads = tariff.allowed_squads or []
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'extend',
'subscription_id': user.subscription.id,
'tariff_id': tariff_id,
'period_days': request.period_days,
'total_price': price_kopeks,
'user_id': user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f'Продление подписки на {request.period_days} дней'
+ (f' ({tariff_name})' if tariff_name else ''),
'discount_percent': discount_percent,
'consume_promo_offer': promo_offer_discount_value > 0,
'source': 'cabinet',
}
# Add subscription parameters for auto-purchase
if tariff_id:
cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
# Сохраняем актуальный device_limit подписки (включая докупленные устройства)
cart_data['device_limit'] = user.subscription.device_limit
cart_data['allowed_squads'] = tariff_allowed_squads
else:
# Classic mode: сохраняем текущие параметры подписки для корректной автопокупки
cart_data['device_limit'] = user.subscription.device_limit
cart_data['traffic_limit_gb'] = user.subscription.traffic_limit_gb
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info('Cart saved for auto-renewal (cabinet) user', user_id=user.id)
except Exception as e:
logger.error('Error saving cart for auto-renewal (cabinet)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
'missing_amount': missing,
'cart_saved': True,
'cart_mode': 'extend',
},
)
# Centralized renewal: balance deduction, extension, RemnaWave sync, admin notification,
# server price recording, and compensating refund on failure.
renewal_description = f'Продление подписки на {request.period_days} дней' + (f' ({tariff.name})' if tariff else '')
renewal_service = SubscriptionRenewalService()
try:
result = await renewal_service.finalize(
db,
user,
subscription,
pricing,
description=renewal_description,
payment_method=PaymentMethod.BALANCE,
)
except SubscriptionRenewalChargeError:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'message': 'Недостаточно средств (concurrent check)',
},
)
response = {
'message': 'Subscription renewed successfully',
'new_end_date': result.subscription.end_date.isoformat(),
'amount_paid_kopeks': price_kopeks,
}
# Add discount info to response
if promo_offer_discount_value > 0:
response['promo_discount_percent'] = promo_offer_discount_percent
response['promo_discount_amount_kopeks'] = promo_offer_discount_value
response['original_price_kopeks'] = original_price_kopeks
return response
@router.get('/traffic-packages', response_model=list[TrafficPackageResponse])
async def get_traffic_packages(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get available traffic packages."""
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.user import get_user_by_id
fresh_user = await get_user_by_id(db, user.id)
if not fresh_user or not fresh_user.subscription:
return []
# Режим тарифов - берём пакеты из тарифа
if settings.is_tariffs_mode() and fresh_user.subscription.tariff_id:
tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id)
if not tariff:
return []
# Проверяем, разрешена ли докупка для этого тарифа
if not getattr(tariff, 'traffic_topup_enabled', False):
return []
# Проверяем безлимит
if tariff.traffic_limit_gb == 0:
return []
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
result = []
for gb, price in packages.items():
if price <= 0:
continue
result.append(
TrafficPackageResponse(
gb=gb,
price_kopeks=price,
price_rubles=price / 100,
is_unlimited=False,
)
)
return sorted(result, key=lambda x: x.gb)
# Classic режим - глобальные настройки
if not settings.is_traffic_topup_enabled():
return []
# Проверяем настройку тарифа пользователя (allow_traffic_topup)
if fresh_user.subscription.tariff_id:
tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id)
if tariff and not tariff.allow_traffic_topup:
return []
packages = settings.get_traffic_topup_packages()
result = []
for pkg in packages:
if not pkg.get('enabled', True):
continue
if pkg['price'] <= 0:
continue
result.append(
TrafficPackageResponse(
gb=pkg['gb'],
price_kopeks=pkg['price'],
price_rubles=pkg['price'] / 100,
is_unlimited=pkg['gb'] == 0,
)
)
return result
@router.post('/traffic')
async def purchase_traffic(
request: TrafficPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Purchase additional traffic."""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription purchases are restricted for this account',
)
from app.database.crud.subscription import add_subscription_traffic
from app.database.crud.tariff import get_tariff_by_id
from app.utils.pricing_utils import calculate_prorated_price
await db.refresh(user, ['subscription'])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
subscription = user.subscription
tariff = None
base_price_kopeks = 0
is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id
# Режим тарифов
if is_tariff_mode:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
# Проверяем, разрешена ли докупка
if not getattr(tariff, 'traffic_topup_enabled', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic top-up is disabled for this tariff',
)
# Проверяем безлимит
if tariff.traffic_limit_gb == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot add traffic to unlimited subscription',
)
# Проверяем лимит докупки
max_topup_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
if max_topup_limit > 0:
current_traffic = subscription.traffic_limit_gb or 0
new_traffic = current_traffic + request.gb
if new_traffic > max_topup_limit:
available_gb = max(0, max_topup_limit - current_traffic)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic limit exceeded. Max: {max_topup_limit} GB, available: {available_gb} GB',
)
# Получаем цену из тарифа
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if request.gb not in packages:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic package {request.gb}GB is not available',
)
base_price_kopeks = packages[request.gb]
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic package {request.gb}GB has no price configured',
)
else:
# Classic режим
if not settings.is_traffic_topup_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic top-up feature is disabled',
)
# Проверяем настройку тарифа (allow_traffic_topup)
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and not tariff.allow_traffic_topup:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic top-up is not available for your tariff',
)
# Получаем цену из глобальных настроек
packages = settings.get_traffic_topup_packages()
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
if not matching_pkg:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid traffic package',
)
base_price_kopeks = matching_pkg['price']
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic package has no price configured',
)
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
# Пропорциональный расчёт применяем только в классическом режиме.
if is_tariff_mode:
prorated_price = base_price_kopeks
days_charged = 30
else:
prorated_price, days_charged = calculate_prorated_price(
base_price_kopeks,
subscription.end_date,
)
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply discount from promo group using proper method
period_hint_days = days_charged if days_charged > 0 else 30
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days)
final_price = discount_result['discounted']
traffic_discount_percent = discount_result['percent']
discount_value = discount_result['discount']
# Ensure minimum price after discount (except for 100% discount)
if traffic_discount_percent < 100 and final_price > 0:
final_price = max(100, final_price)
# Проверяем баланс
if user.balance_kopeks < final_price:
missing = final_price - user.balance_kopeks
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_traffic',
'subscription_id': subscription.id,
'traffic_gb': request.gb,
'price_kopeks': final_price,
'base_price_kopeks': prorated_price,
'discount_percent': traffic_discount_percent,
'source': 'cabinet',
'description': f'Докупка {request.gb} ГБ трафика',
}
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(
'Cart saved for traffic purchase (cabinet) user + discount',
user_id=user.id,
gb=request.gb,
traffic_discount_percent=traffic_discount_percent,
)
except Exception as e:
logger.error('Error saving cart for traffic purchase (cabinet)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
'missing_amount': missing,
'cart_saved': True,
'cart_mode': 'add_traffic',
},
)
# Формируем описание
if traffic_discount_percent > 0:
traffic_description = f'Докупка {request.gb} ГБ трафика (скидка {traffic_discount_percent}%)'
else:
traffic_description = f'Докупка {request.gb} ГБ трафика'
# Списываем баланс
success = await subtract_user_balance(db, user, final_price, traffic_description)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to charge balance',
)
# Добавляем трафик (add_subscription_traffic обновляет purchased_traffic_gb, traffic_reset_at и коммитит)
await add_subscription_traffic(db, subscription, request.gb)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if subscription.status == 'active':
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
else:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync traffic with RemnaWave', error=e)
# Создаём транзакцию
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_price,
description=traffic_description,
)
await db.refresh(user)
await db.refresh(subscription)
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
old_traffic = subscription.traffic_limit_gb - request.gb
await notification_service.send_subscription_update_notification(
db=db,
user=user,
subscription=subscription,
update_type='traffic',
old_value=old_traffic,
new_value=subscription.traffic_limit_gb,
price_paid=final_price,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for traffic purchase', error=e)
response = {
'success': True,
'message': 'Traffic purchased successfully',
'gb_added': request.gb,
'new_traffic_limit_gb': subscription.traffic_limit_gb,
'amount_paid_kopeks': final_price,
'new_balance_kopeks': user.balance_kopeks,
}
if traffic_discount_percent > 0:
response['discount_percent'] = traffic_discount_percent
response['discount_kopeks'] = discount_value
response['base_price_kopeks'] = prorated_price
return response
@router.post('/devices')
async def purchase_devices_legacy(
request: DevicePurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""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(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription purchases are restricted for this account',
)
# 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()
.execution_options(populate_existing=True)
)
subscription = result.scalar_one_or_none()
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
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
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply discount from promo group
discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30)
total_price = discount_result['discounted']
devices_discount_percent = discount_result['percent']
# Ensure minimum price after discount (except for 100% discount)
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
if max_device_limit and new_devices > max_device_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Максимальное количество устройств: {max_device_limit}',
)
# Check balance
if user.balance_kopeks < total_price:
missing = total_price - user.balance_kopeks
# Сохраняем корзину для автопокупки после пополнения
try:
cart_data = {
'cart_mode': 'add_devices',
'devices_to_add': request.devices,
'price_kopeks': total_price,
'base_price_kopeks': base_total_price,
'discount_percent': devices_discount_percent,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(
'Cart saved for device purchase (cabinet /devices) user + devices',
user_id=user.id,
devices=request.devices,
)
except Exception as e:
logger.error('Error saving cart for device purchase (cabinet /devices)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'error': 'Insufficient balance',
'required_kopeks': total_price,
'current_kopeks': user.balance_kopeks,
'missing_kopeks': missing,
'cart_saved': True,
},
)
# Deduct balance and create transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod
# Build description with discount info
if devices_discount_percent > 0:
description = f'Покупка {request.devices} доп. устройств (скидка {devices_discount_percent}%)'
else:
description = f'Покупка {request.devices} доп. устройств'
success = await subtract_user_balance(
db=db,
user=user,
amount_kopeks=total_price,
description=description,
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
)
if not success:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail='Insufficient funds',
)
# Re-lock subscription after subtract_user_balance committed (which released all locks).
# Re-validate max device limit to prevent concurrent purchases exceeding the limit.
relock_result = await db.execute(
select(Subscription)
.where(Subscription.id == subscription.id)
.with_for_update()
.execution_options(populate_existing=True)
)
subscription = relock_result.scalar_one()
actual_current = subscription.device_limit or 1
actual_new = actual_current + request.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)
)
refund_user = user_refund.scalar_one()
refund_user.balance_kopeks += total_price
await db.commit()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
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
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_update_notification(
db=db,
user=user,
subscription=subscription,
update_type='devices',
old_value=current_devices,
new_value=actual_new,
price_paid=total_price,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for device purchase', error=e)
response = {
'message': 'Devices added successfully',
'devices_added': request.devices,
'new_device_limit': actual_new,
'amount_paid_kopeks': total_price,
}
if devices_discount_percent > 0:
response['discount_percent'] = devices_discount_percent
response['discount_kopeks'] = discount_result['discount']
response['base_price_kopeks'] = base_total_price
return response
@router.patch('/autopay')
async def update_autopay(
request: AutopayUpdateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update autopay settings."""
await db.refresh(user, ['subscription'])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
# глобальный autopay для них запрещён
if request.enabled:
await db.refresh(user.subscription, ['tariff'])
if user.subscription.tariff and getattr(user.subscription.tariff, 'is_daily', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Autopay is not available for daily subscriptions',
)
user.subscription.autopay_enabled = request.enabled
if request.days_before is not None:
user.subscription.autopay_days_before = request.days_before
await db.commit()
return {
'message': 'Autopay settings updated',
'autopay_enabled': user.subscription.autopay_enabled,
'autopay_days_before': user.subscription.autopay_days_before,
}
@router.get('/trial', response_model=TrialInfoResponse)
async def get_trial_info(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get trial subscription info and availability."""
await db.refresh(user, ['subscription'])
# Проверяем, отключён ли триал для этого типа пользователя
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
return TrialInfoResponse(
is_available=False,
duration_days=settings.TRIAL_DURATION_DAYS,
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
device_limit=settings.TRIAL_DEVICE_LIMIT,
requires_payment=bool(settings.TRIAL_PAYMENT_ENABLED),
price_kopeks=0,
price_rubles=0,
reason_unavailable='Trial is not available for your account type',
)
duration_days = settings.TRIAL_DURATION_DAYS
traffic_limit_gb = settings.TRIAL_TRAFFIC_LIMIT_GB
device_limit = settings.TRIAL_DEVICE_LIMIT
requires_payment = bool(settings.TRIAL_PAYMENT_ENABLED)
price_kopeks = settings.TRIAL_ACTIVATION_PRICE if requires_payment else 0
# Get trial parameters from tariff if configured (same logic as activate_trial)
try:
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
trial_tariff = await get_trial_tariff(db)
if not trial_tariff:
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
traffic_limit_gb = trial_tariff.traffic_limit_gb
device_limit = trial_tariff.device_limit
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
duration_days = tariff_trial_days
except Exception as e:
logger.error('Error getting trial tariff for info', error=e)
# Check if user already has an active subscription
if user.subscription:
now = datetime.now(UTC)
is_active = (
user.subscription.status == 'active' and user.subscription.end_date and user.subscription.end_date > now
)
if is_active:
return TrialInfoResponse(
is_available=False,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
requires_payment=requires_payment,
price_kopeks=price_kopeks,
price_rubles=price_kopeks / 100,
reason_unavailable='You already have an active subscription',
)
# Check if user already used trial
if user.subscription.is_trial or user.has_had_paid_subscription:
return TrialInfoResponse(
is_available=False,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
requires_payment=requires_payment,
price_kopeks=price_kopeks,
price_rubles=price_kopeks / 100,
reason_unavailable='Trial already used',
)
return TrialInfoResponse(
is_available=True,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
requires_payment=requires_payment,
price_kopeks=price_kopeks,
price_rubles=price_kopeks / 100,
)
@router.post('/trial', response_model=SubscriptionResponse)
async def activate_trial(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate trial subscription."""
await db.refresh(user, ['subscription'])
# Проверяем, отключён ли триал для этого типа пользователя
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Trial is not available for your account type',
)
# Check if user already has an active subscription
if user.subscription:
now = datetime.now(UTC)
is_active = (
user.subscription.status == 'active' and user.subscription.end_date and user.subscription.end_date > now
)
if is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You already have an active subscription',
)
# Check if user already used trial
if user.subscription.is_trial or user.has_had_paid_subscription:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Trial already used',
)
# Check if trial requires payment
requires_payment = bool(settings.TRIAL_PAYMENT_ENABLED)
if requires_payment:
from app.database.crud.user import subtract_user_balance
price_kopeks = settings.TRIAL_ACTIVATION_PRICE
if user.balance_kopeks < price_kopeks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Insufficient balance. Need {price_kopeks / 100:.2f} RUB',
)
trial_description = 'Активация триальной подписки'
success = await subtract_user_balance(
db,
user,
price_kopeks,
trial_description,
mark_as_paid_subscription=True,
)
if not success:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail='Failed to charge trial activation fee',
)
# Создаём транзакцию для учёта списания за триал
await create_transaction(
db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_kopeks,
description=trial_description,
payment_method=PaymentMethod.BALANCE,
)
logger.info('User paid kopeks for trial activation', user_id=user.id, price_kopeks=price_kopeks)
# Get trial parameters from tariff if configured (same logic as bot handler)
trial_duration = settings.TRIAL_DURATION_DAYS
trial_traffic_limit = settings.TRIAL_TRAFFIC_LIMIT_GB
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
trial_squads = []
tariff_id_for_trial = None
# First check for tariff with is_trial_available flag in DB (set via admin panel)
# Then fallback to TRIAL_TARIFF_ID from settings
trial_tariff = None
try:
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
trial_tariff = await get_trial_tariff(db)
if not trial_tariff:
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
trial_duration = tariff_trial_days
logger.info(
'Using trial tariff (ID: ) with squads',
trial_tariff_name=trial_tariff.name,
trial_tariff_id=trial_tariff.id,
trial_squads=trial_squads,
)
except Exception as e:
logger.error('Error getting trial tariff', error=e)
# Create trial subscription
subscription = await create_trial_subscription(
db=db,
user_id=user.id,
duration_days=trial_duration,
traffic_limit_gb=trial_traffic_limit,
device_limit=trial_device_limit,
connected_squads=trial_squads or None,
tariff_id=tariff_id_for_trial,
)
logger.info('Trial subscription activated for user', user_id=user.id)
# Create RemnaWave user
try:
subscription_service = SubscriptionService()
if subscription_service.is_configured:
await subscription_service.create_remnawave_user(db, subscription)
await db.refresh(subscription)
except Exception as e:
logger.error('Failed to create RemnaWave user for trial', error=e)
# Send admin notification about trial activation
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
charged_amount = settings.TRIAL_ACTIVATION_PRICE if requires_payment else None
await notification_service.send_trial_activation_notification(
db, user, subscription, charged_amount_kopeks=charged_amount
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send trial activation notification', error=e)
return _subscription_to_response(subscription, user=user)
# ============ Full Purchase Flow (like MiniApp) ============
purchase_service = MiniAppSubscriptionPurchaseService()
async def _build_tariff_response(
db: AsyncSession,
tariff: Tariff,
current_tariff_id: int | None = None,
language: str = 'ru',
user: User | None = None,
subscription: 'Subscription | None' = None,
) -> dict[str, Any]:
"""Build tariff model for API response with promo group discounts applied."""
servers = []
servers_count = 0
if tariff.allowed_squads:
servers_count = len(tariff.allowed_squads)
for squad_uuid in tariff.allowed_squads[:5]: # Limit for preview
server = await get_server_squad_by_uuid(db, squad_uuid)
if server:
servers.append(
{
'uuid': squad_uuid,
'name': server.display_name or squad_uuid[:8],
}
)
# Get promo group for discount calculation
# Use get_primary_promo_group() for correct promo group resolution
promo_group = user.get_primary_promo_group() if user and hasattr(user, 'get_primary_promo_group') else None
if promo_group is None and user:
# Fallback to legacy promo_group attribute
promo_group = getattr(user, 'promo_group', None)
promo_group_name = promo_group.name if promo_group else None
# Вычисляем доп. устройства для текущего тарифа (при продлении)
extra_devices_count = 0
extra_device_price_per_month = 0
if subscription and subscription.tariff_id == tariff.id:
extra_devices_count = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0))
if extra_devices_count > 0:
extra_device_price_per_month = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
periods = []
if tariff.period_prices:
for period_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])):
if int(price_kopeks) < 0:
continue # Skip disabled periods (negative price)
period_days = int(period_str)
months = max(1, period_days // 30)
# Базовая цена тарифа
base_tariff_price = int(price_kopeks)
# Стоимость доп. устройств за этот период
extra_devices_cost = extra_devices_count * extra_device_price_per_month * months
# Apply per-category promo group discounts
original_price = base_tariff_price + extra_devices_cost
discount_amount = 0
if promo_group:
period_pct = promo_group.get_discount_percent('period', period_days)
devices_pct = promo_group.get_discount_percent('devices', period_days)
discounted_base = (
pricing_engine.apply_discount(base_tariff_price, period_pct)
if period_pct > 0
else base_tariff_price
)
discounted_devices = (
pricing_engine.apply_discount(extra_devices_cost, devices_pct)
if devices_pct > 0
else extra_devices_cost
)
final_price = discounted_base + discounted_devices
discount_amount = original_price - final_price
discount_percent = max(period_pct, devices_pct)
else:
discount_percent = 0
final_price = original_price
per_month = final_price // months if months > 0 else final_price
original_per_month = original_price // months if months > 0 else original_price
period_data = {
'days': period_days,
'months': months,
'label': format_period_description(period_days, language),
'price_kopeks': final_price,
'price_label': settings.format_price(final_price),
'price_per_month_kopeks': per_month,
'price_per_month_label': settings.format_price(per_month),
}
# Информация о доп. устройствах в цене
if extra_devices_count > 0:
period_data['extra_devices_count'] = extra_devices_count
period_data['extra_devices_cost_kopeks'] = extra_devices_cost
period_data['extra_devices_cost_label'] = settings.format_price(extra_devices_cost)
period_data['base_tariff_price_kopeks'] = base_tariff_price
period_data['base_tariff_price_label'] = settings.format_price(base_tariff_price)
# Add discount info if discount is applied
if discount_percent > 0:
period_data['original_price_kopeks'] = original_price
period_data['original_price_label'] = settings.format_price(original_price)
period_data['original_per_month_kopeks'] = original_per_month
period_data['original_per_month_label'] = settings.format_price(original_per_month)
period_data['discount_percent'] = discount_percent
period_data['discount_amount_kopeks'] = discount_amount
period_data['discount_label'] = f'-{discount_percent}%'
periods.append(period_data)
traffic_label = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
# Apply discount to daily price if applicable (group + promo-offer)
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
original_daily_price = daily_price
daily_discount_percent = 0
if daily_price > 0:
from app.services.pricing_engine import PricingEngine
from app.utils.promo_offer import get_user_active_promo_discount_percent
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
daily_offer_pct = get_user_active_promo_discount_percent(user) if user else 0
if daily_group_pct > 0 or daily_offer_pct > 0:
daily_price, _, _ = PricingEngine.apply_stacked_discounts(daily_price, daily_group_pct, daily_offer_pct)
# Комбинированный процент для отображения
remaining = (100 - daily_group_pct) * (100 - daily_offer_pct)
daily_discount_percent = 100 - remaining // 100
# Apply discount to custom price_per_day if applicable
price_per_day = tariff.price_per_day_kopeks
original_price_per_day = price_per_day
custom_days_discount_percent = 0
if promo_group and price_per_day > 0:
custom_days_discount_percent = promo_group.get_discount_percent('period', 30) # Use 30-day rate as base
if custom_days_discount_percent > 0:
price_per_day = pricing_engine.apply_discount(price_per_day, custom_days_discount_percent)
# Apply discount to device price if applicable
device_price = tariff.device_price_kopeks if tariff.device_price_kopeks is not None else 0
original_device_price = device_price
device_discount_percent = 0
if promo_group and device_price > 0:
device_discount_percent = promo_group.get_discount_percent('devices', 30)
if device_discount_percent > 0:
device_price = pricing_engine.apply_discount(device_price, device_discount_percent)
# Показываем реальное количество устройств (с докупленными) для текущего тарифа
actual_device_limit = tariff.device_limit
if subscription and subscription.tariff_id == tariff.id:
actual_device_limit = max(tariff.device_limit or 0, subscription.device_limit or 0)
response = {
'id': tariff.id,
'name': tariff.name,
'description': tariff.description,
'tier_level': tariff.tier_level,
'traffic_limit_gb': tariff.traffic_limit_gb,
'traffic_limit_label': traffic_label,
'is_unlimited_traffic': tariff.traffic_limit_gb == 0,
'device_limit': actual_device_limit,
'base_device_limit': tariff.device_limit,
'extra_devices_count': extra_devices_count,
'device_price_kopeks': device_price,
'servers_count': servers_count,
'servers': servers,
'periods': periods,
'is_current': current_tariff_id == tariff.id if current_tariff_id else False,
'is_available': tariff.is_active,
# Произвольное количество дней
'custom_days_enabled': tariff.custom_days_enabled,
'price_per_day_kopeks': price_per_day,
'min_days': tariff.min_days,
'max_days': tariff.max_days,
# Произвольный трафик при покупке
'custom_traffic_enabled': tariff.custom_traffic_enabled,
'traffic_price_per_gb_kopeks': tariff.traffic_price_per_gb_kopeks,
'min_traffic_gb': tariff.min_traffic_gb,
'max_traffic_gb': tariff.max_traffic_gb,
# Докупка трафика
'traffic_topup_enabled': tariff.traffic_topup_enabled,
'traffic_topup_packages': tariff.get_traffic_topup_packages()
if hasattr(tariff, 'get_traffic_topup_packages')
else {},
'max_topup_traffic_gb': tariff.max_topup_traffic_gb,
# Дневной тариф
'is_daily': getattr(tariff, 'is_daily', False),
'daily_price_kopeks': daily_price,
# Сброс трафика
'traffic_reset_mode': tariff.traffic_reset_mode or settings.DEFAULT_TRAFFIC_RESET_STRATEGY,
}
# Add promo group info if user has discounts
if promo_group_name:
response['promo_group_name'] = promo_group_name
# Add original prices if discounts were applied
if device_discount_percent > 0:
response['original_device_price_kopeks'] = original_device_price
response['device_discount_percent'] = device_discount_percent
if daily_discount_percent > 0 and original_daily_price > 0:
response['original_daily_price_kopeks'] = original_daily_price
response['daily_discount_percent'] = daily_discount_percent
if custom_days_discount_percent > 0 and original_price_per_day > 0:
response['original_price_per_day_kopeks'] = original_price_per_day
response['custom_days_discount_percent'] = custom_days_discount_percent
return response
@router.get('/purchase-options')
async def get_purchase_options(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get all subscription purchase options (periods, servers, traffic, devices)."""
try:
settings.get_sales_mode()
# Tariffs mode - return list of tariffs
if settings.is_tariffs_mode():
# Use get_primary_promo_group() for correct promo group resolution
# (handles both legacy promo_group FK and new user_promo_groups M2M)
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group is None:
# Fallback to legacy promo_group attribute
promo_group = getattr(user, 'promo_group', None)
promo_group_id = promo_group.id if promo_group else None
tariffs = await get_tariffs_for_user(db, promo_group_id)
subscription = await get_subscription_by_user_id(db, user.id)
current_tariff_id = subscription.tariff_id if subscription else None
language = getattr(user, 'language', 'ru') or 'ru'
# Determine subscription status for frontend to decide purchase vs switch flow
subscription_status = None
subscription_is_expired = False
if subscription:
subscription_status = subscription.actual_status
subscription_is_expired = subscription_status == 'expired'
tariff_responses = []
for tariff in tariffs:
tariff_data = await _build_tariff_response(db, tariff, current_tariff_id, language, user, subscription)
tariff_responses.append(tariff_data)
return {
'sales_mode': 'tariffs',
'tariffs': tariff_responses,
'current_tariff_id': current_tariff_id,
'balance_kopeks': user.balance_kopeks,
'balance_label': settings.format_price(user.balance_kopeks),
# Include subscription status info for frontend decision making
'subscription_status': subscription_status,
'subscription_is_expired': subscription_is_expired,
'has_subscription': subscription is not None,
}
# Classic mode - return periods
context = await purchase_service.build_options(db, user)
payload = context.payload
payload['sales_mode'] = 'classic'
return payload
except PurchaseValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
logger.error('Failed to build purchase options for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load purchase options',
)
@router.post('/purchase-preview')
async def preview_purchase(
request: PurchasePreviewRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Calculate and preview the total price for selected options (classic mode only)."""
# This endpoint is for classic mode only, tariffs mode uses /purchase-tariff
if settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This endpoint is not available in tariffs mode. Use /purchase-tariff instead.',
)
try:
context = await purchase_service.build_options(db, user)
# Convert request to dict for parsing
selection_dict = {
'period_id': request.selection.period_id,
'period_days': request.selection.period_days,
'traffic_value': request.selection.traffic_value,
'servers': request.selection.servers,
'devices': request.selection.devices,
}
selection = purchase_service.parse_selection(context, selection_dict)
pricing = await purchase_service.calculate_pricing(db, context, selection)
preview = purchase_service.build_preview_payload(context, pricing)
return preview
except PurchaseValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
logger.error('Failed to calculate purchase preview for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to calculate price',
)
@router.post('/purchase')
async def submit_purchase(
request: PurchasePreviewRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Submit subscription purchase (deduct from balance, classic mode only)."""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription purchases are restricted for this account',
)
# This endpoint is for classic mode only, tariffs mode uses /purchase-tariff
if settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This endpoint is not available in tariffs mode. Use /purchase-tariff instead.',
)
try:
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
context = await purchase_service.build_options(db, user)
# Convert request to dict for parsing
selection_dict = {
'period_id': request.selection.period_id,
'period_days': request.selection.period_days,
'traffic_value': request.selection.traffic_value,
'servers': request.selection.servers,
'devices': request.selection.devices,
}
selection = purchase_service.parse_selection(context, selection_dict)
pricing = await purchase_service.calculate_pricing(db, context, selection)
result = await purchase_service.submit_purchase(db, context, pricing)
subscription = result['subscription']
# Send email notification for email-only users
if not user.telegram_id and user.email and user.email_verified:
try:
is_new_subscription = result.get('was_trial_conversion') or not context.subscription
notification_type = (
NotificationType.SUBSCRIPTION_ACTIVATED
if is_new_subscription
else NotificationType.SUBSCRIPTION_RENEWED
)
end_date_str = subscription.end_date.strftime('%d.%m.%Y') if subscription.end_date else ''
await notification_delivery_service.send_notification(
user=user,
notification_type=notification_type,
context={
'expires_at': end_date_str, # for SUBSCRIPTION_ACTIVATED
'new_expires_at': end_date_str, # for SUBSCRIPTION_RENEWED
'traffic_limit_gb': subscription.traffic_limit_gb,
'device_limit': subscription.device_limit,
'tariff_name': '', # classic mode has no tariff
},
bot=None,
)
except Exception as notif_error:
logger.warning('Failed to send subscription notification to', email=user.email, notif_error=notif_error)
# Отправляем уведомление админам о покупке подписки
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
is_new_subscription = result.get('was_trial_conversion') or not context.subscription
await notification_service.send_subscription_purchase_notification(
db=db,
user=user,
subscription=subscription,
transaction=result.get('transaction'),
period_days=selection.period.days,
was_trial_conversion=result.get('was_trial_conversion', False),
amount_kopeks=pricing.final_total,
purchase_type='renewal' if not is_new_subscription else 'first_purchase',
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for subscription purchase', error=e)
return {
'success': True,
'message': result['message'],
'subscription': _subscription_to_response(subscription, user=user),
'was_trial_conversion': result.get('was_trial_conversion', False),
}
except PurchaseValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except PurchaseBalanceError as e:
# Save cart for auto-purchase after balance top-up
try:
total_price = pricing.final_total if 'pricing' in locals() else 0
cart_data = {
'cart_mode': 'subscription_purchase',
'period_id': request.selection.period_id,
'period_days': request.selection.period_days,
'traffic_gb': request.selection.traffic_value, # _prepare_auto_purchase expects traffic_gb
'countries': request.selection.servers, # _prepare_auto_purchase expects countries
'devices': request.selection.devices,
'total_price': total_price,
'user_id': user.id,
'saved_cart': True,
'return_to_cart': True,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info('Cart saved for auto-purchase (cabinet /purchase) user', user_id=user.id)
except Exception as cart_error:
logger.error('Error saving cart for auto-purchase (cabinet /purchase)', cart_error=cart_error)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'message': str(e),
'cart_saved': True,
'cart_mode': 'subscription_purchase',
},
)
except Exception as e:
logger.error('Failed to submit purchase for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to process purchase',
)
# ============ Tariff Purchase (for tariffs mode) ============
@router.post('/purchase-tariff')
async def purchase_tariff(
request: TariffPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Purchase a tariff (for tariffs mode)."""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription purchases are restricted for this account',
)
try:
# Check tariffs mode
if not settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariffs mode is not enabled',
)
# Get tariff
tariff = await get_tariff_by_id(db, request.tariff_id)
if not tariff or not tariff.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found or inactive',
)
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Check tariff availability for user's promo group and get promo group for discounts
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
promo_group_id = promo_group.id if promo_group else None
if not tariff.is_available_for_promo_group(promo_group_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='This tariff is not available for your promo group',
)
# Handle daily tariffs specially
is_daily_tariff = getattr(tariff, 'is_daily', False)
if is_daily_tariff:
period_days = 1
else:
period_days = request.period_days
# Determine traffic limit (custom traffic support)
traffic_limit_gb = tariff.traffic_limit_gb
custom_traffic_gb = None
if request.traffic_gb is not None and tariff.can_purchase_custom_traffic():
custom_traffic_gb = request.traffic_gb
traffic_limit_gb = request.traffic_gb
# Determine device_limit for renewal pricing
existing_subscription = await get_subscription_by_user_id(db, user.id)
device_limit = None
effective_device_limit = tariff.device_limit
if existing_subscription and existing_subscription.tariff_id == tariff.id:
device_limit = existing_subscription.device_limit
if (existing_subscription.device_limit or 0) > (tariff.device_limit or 0):
effective_device_limit = existing_subscription.device_limit
# Calculate price via PricingEngine (single source of truth)
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period_days,
device_limit=device_limit,
custom_traffic_gb=custom_traffic_gb,
user=user,
)
price_kopeks = result.final_total
original_price = result.original_total
bd = result.breakdown
group_pcts = bd.get('group_discount_pct', {})
discount_percent = group_pcts.get('period', 0)
promo_offer_discount_percent = bd.get('offer_discount_pct', 0)
promo_offer_discount_value = result.promo_offer_discount
price_before_promo_offer = price_kopeks + promo_offer_discount_value
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Save cart for auto-purchase after balance top-up
if is_daily_tariff:
cart_data = {
'cart_mode': 'daily_tariff_purchase',
'tariff_id': tariff.id,
'is_daily': True,
'daily_price_kopeks': price_kopeks,
'total_price': price_kopeks,
'user_id': user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f'Покупка суточного тарифа {tariff.name}',
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': effective_device_limit,
'allowed_squads': tariff.allowed_squads or [],
'consume_promo_offer': promo_offer_discount_value > 0,
'source': 'cabinet',
}
else:
cart_data = {
'cart_mode': 'tariff_purchase',
'tariff_id': tariff.id,
'period_days': period_days,
'total_price': price_kopeks,
'user_id': user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f'Покупка тарифа {tariff.name} на {period_days} дней',
'traffic_limit_gb': traffic_limit_gb,
'device_limit': effective_device_limit,
'allowed_squads': tariff.allowed_squads or [],
'discount_percent': discount_percent,
'consume_promo_offer': promo_offer_discount_value > 0,
'source': 'cabinet',
}
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info('Cart saved for auto-purchase (cabinet) user tariff', user_id=user.id, tariff_id=tariff.id)
except Exception as e:
logger.error('Error saving cart for auto-purchase (cabinet)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
'missing_amount': missing,
'cart_saved': True,
'cart_mode': cart_data['cart_mode'],
},
)
subscription = existing_subscription
# Get server squads from tariff
squads = tariff.allowed_squads or []
# If allowed_squads is empty, it means "all servers"
if not squads:
from app.database.crud.server_squad import get_all_server_squads
all_servers, _ = await get_all_server_squads(db, available_only=True)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
# Charge balance
if is_daily_tariff:
description = f"Активация суточного тарифа '{tariff.name}'"
else:
description = f"Покупка тарифа '{tariff.name}' на {period_days} дней"
if discount_percent > 0:
description += f' (скидка {discount_percent}%)'
if promo_offer_discount_value > 0:
description += f' (промо -{promo_offer_discount_percent}%)'
success = await subtract_user_balance(
db,
user,
price_kopeks,
description,
consume_promo_offer=promo_offer_discount_value > 0,
mark_as_paid_subscription=True,
)
if not success:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Failed to charge balance',
)
# Create transaction
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_kopeks,
description=description,
payment_method=PaymentMethod.BALANCE,
)
if subscription:
# Extend/change tariff — сохраняем докупленные устройства при продлении того же тарифа
subscription = await extend_subscription(
db=db,
subscription=subscription,
days=period_days,
tariff_id=tariff.id,
traffic_limit_gb=traffic_limit_gb,
device_limit=effective_device_limit,
connected_squads=squads,
)
else:
# Create new subscription
subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=period_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
tariff_id=tariff.id,
)
# For daily tariffs, set last_daily_charge_at
if is_daily_tariff:
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.is_daily_paused = False
await db.commit()
await db.refresh(subscription)
# Sync with RemnaWave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
service = SubscriptionService()
try:
if getattr(user, 'remnawave_uuid', None):
await service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа (cabinet)',
)
else:
await service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа (cabinet)',
)
except Exception as remnawave_error:
logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error)
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
if not is_daily_tariff:
try:
cart_data = {
'cart_mode': 'extend',
'subscription_id': subscription.id,
'period_days': period_days,
'total_price': price_kopeks,
'tariff_id': tariff.id,
'description': f'Продление тарифа {tariff.name} на {period_days} дней',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info('Tariff cart saved for auto-renewal (cabinet) user', user_id=user.id)
except Exception as e:
logger.error('Error saving tariff cart (cabinet)', error=e)
await db.refresh(user)
response = {
'success': True,
'message': f"Тариф '{tariff.name}' успешно активирован",
'subscription': _subscription_to_response(subscription, user=user),
'tariff_id': tariff.id,
'tariff_name': tariff.name,
'charged_amount': price_kopeks,
'charged_label': settings.format_price(price_kopeks),
'balance_kopeks': user.balance_kopeks,
'balance_label': settings.format_price(user.balance_kopeks),
}
# Add discount info if discount was applied
if discount_percent > 0:
response['discount_percent'] = discount_percent
response['original_price_kopeks'] = original_price
response['original_price_label'] = settings.format_price(original_price)
response['discount_amount_kopeks'] = original_price - price_before_promo_offer
response['discount_label'] = settings.format_price(original_price - price_before_promo_offer)
if promo_group:
response['promo_group_name'] = promo_group.name
# Add promo offer discount info if it was applied
if promo_offer_discount_value > 0:
response['promo_offer_discount_percent'] = promo_offer_discount_percent
response['promo_offer_discount_amount_kopeks'] = promo_offer_discount_value
response['promo_offer_discount_label'] = settings.format_price(promo_offer_discount_value)
response['price_before_promo_offer_kopeks'] = price_before_promo_offer
# Send email notification for email-only users
if not user.telegram_id and user.email and user.email_verified:
try:
# Determine if this is a new subscription or extension
was_new_subscription = (
subscription.start_date and (datetime.now(UTC) - subscription.start_date).total_seconds() < 60
)
notification_type = (
NotificationType.SUBSCRIPTION_ACTIVATED
if was_new_subscription
else NotificationType.SUBSCRIPTION_RENEWED
)
end_date_str = subscription.end_date.strftime('%d.%m.%Y') if subscription.end_date else ''
await notification_delivery_service.send_notification(
user=user,
notification_type=notification_type,
context={
'expires_at': end_date_str, # for SUBSCRIPTION_ACTIVATED
'new_expires_at': end_date_str, # for SUBSCRIPTION_RENEWED
'traffic_limit_gb': subscription.traffic_limit_gb,
'device_limit': subscription.device_limit,
'tariff_name': tariff.name,
},
bot=None,
)
except Exception as notif_error:
logger.warning('Failed to send subscription notification to', email=user.email, notif_error=notif_error)
# Отправляем уведомление админам о покупке/продлении тарифа
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
# Определяем тип покупки: новая подписка или продление
was_new_subscription = (
subscription.start_date and (datetime.now(UTC) - subscription.start_date).total_seconds() < 60
)
await notification_service.send_subscription_purchase_notification(
db=db,
user=user,
subscription=subscription,
transaction=transaction,
period_days=period_days,
was_trial_conversion=False,
amount_kopeks=price_kopeks,
purchase_type='renewal' if not was_new_subscription else 'first_purchase',
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for tariff purchase', error=e)
return response
except HTTPException:
raise
except Exception as e:
logger.error('Failed to purchase tariff for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to process tariff purchase',
)
# ============ Device Purchase ============
@router.post('/devices/purchase')
async def purchase_devices(
request: DevicePurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Purchase additional device slots for subscription."""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription purchases are restricted for this account',
)
try:
# 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()
.execution_options(populate_existing=True)
)
subscription = result.scalar_one_or_none()
if not subscription:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='У вас нет активной подписки',
)
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:
# Classic mode - use settings
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='Докупка устройств недоступна',
)
# 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:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Максимальное количество устройств: {max_device_limit}',
)
# Calculate prorated price based on remaining days
now = datetime.now(UTC)
end_date = subscription.end_date
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
total_days = 30 # Base period for device price calculation
# Calculate base price before discount
base_price_per_month = device_price * request.devices
base_price_prorated = int(base_price_per_month * days_left / total_days)
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply discount from promo group
period_hint_days = days_left
discount_result = _apply_addon_discount(user, 'devices', base_price_prorated, period_hint_days)
price_kopeks = discount_result['discounted']
devices_discount_percent = discount_result['percent']
discount_value = discount_result['discount']
# Ensure minimum price after discount (except for 100% discount)
if devices_discount_percent < 100:
price_kopeks = max(100, price_kopeks)
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Сохраняем корзину для автопокупки после пополнения
try:
cart_data = {
'cart_mode': 'add_devices',
'devices_to_add': request.devices,
'price_kopeks': price_kopeks,
'base_price_kopeks': base_price_prorated,
'discount_percent': devices_discount_percent,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(
'Cart saved for device purchase (cabinet) user + devices, discount',
user_id=user.id,
devices=request.devices,
devices_discount_percent=devices_discount_percent,
)
except Exception as e:
logger.error('Error saving cart for device purchase (cabinet)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'error': 'Insufficient balance',
'required_kopeks': price_kopeks,
'current_kopeks': user.balance_kopeks,
'missing_kopeks': missing,
'cart_saved': True,
},
)
# Deduct balance and create transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod
# Build description with discount info
if devices_discount_percent > 0:
description = f'Покупка {request.devices} доп. устройств (скидка {devices_discount_percent}%)'
else:
description = f'Покупка {request.devices} доп. устройств'
success = await subtract_user_balance(
db=db,
user=user,
amount_kopeks=price_kopeks,
description=description,
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
)
if not success:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail='Insufficient funds',
)
# Re-lock subscription after subtract_user_balance committed (which released all locks).
# Re-validate max device limit to prevent concurrent purchases exceeding the limit.
relock_result = await db.execute(
select(Subscription)
.where(Subscription.id == subscription.id)
.with_for_update()
.execution_options(populate_existing=True)
)
subscription = relock_result.scalar_one()
actual_current = subscription.device_limit or 1
actual_new = actual_current + request.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)
)
refund_user = user_refund.scalar_one()
refund_user.balance_kopeks += price_kopeks
await db.commit()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f'Максимальное количество устройств: {max_device_limit}. Баланс возвращён.',
)
# Increase device limit (under lock)
subscription.device_limit = actual_new
await db.commit()
await db.refresh(subscription)
# Sync with RemnaWave
service = SubscriptionService()
try:
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', error=e)
await db.refresh(user)
if devices_discount_percent > 0:
logger.info(
'User purchased devices for kopeks (discount saved kopeks)',
user_id=user.id,
devices=request.devices,
price_kopeks=price_kopeks,
devices_discount_percent=devices_discount_percent,
discount_value=discount_value,
)
else:
logger.info(
'User purchased devices for kopeks', user_id=user.id, devices=request.devices, price_kopeks=price_kopeks
)
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_update_notification(
db=db,
user=user,
subscription=subscription,
update_type='devices',
old_value=current_devices,
new_value=subscription.device_limit,
price_paid=price_kopeks,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for device purchase', error=e)
response = {
'success': True,
'message': f'Добавлено {request.devices} устройств',
'devices_added': request.devices,
'new_device_limit': subscription.device_limit,
'price_kopeks': price_kopeks,
'price_label': settings.format_price(price_kopeks),
'balance_kopeks': user.balance_kopeks,
'balance_label': settings.format_price(user.balance_kopeks),
}
if devices_discount_percent > 0:
response['discount_percent'] = devices_discount_percent
response['discount_kopeks'] = discount_value
response['base_price_kopeks'] = base_price_prorated
return response
except HTTPException:
raise
except Exception as e:
logger.error('Failed to purchase devices for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Не удалось обработать покупку устройств',
)
@router.post('/traffic/save-cart')
async def save_traffic_cart(
request: TrafficPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, bool]:
"""Save cart for traffic purchase (for insufficient balance flow)."""
await db.refresh(user, ['subscription'])
subscription = user.subscription
if not subscription:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='У вас нет активной подписки',
)
if subscription.status not in ['active', 'trial']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Ваша подписка неактивна',
)
if subscription.is_trial:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка трафика недоступна на пробном периоде',
)
if subscription.traffic_limit_gb == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='У вас уже безлимитный трафик',
)
# Get traffic price from tariff or settings
tariff = None
base_price_kopeks = 0
is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id
if is_tariff_mode:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Тариф не найден',
)
if not getattr(tariff, 'traffic_topup_enabled', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка трафика недоступна на вашем тарифе',
)
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if request.gb not in packages:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Пакет трафика {request.gb} ГБ недоступен',
)
base_price_kopeks = packages[request.gb]
else:
if not settings.is_traffic_topup_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка трафика отключена',
)
packages = settings.get_traffic_topup_packages()
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
if not matching_pkg:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Недоступный пакет трафика',
)
base_price_kopeks = matching_pkg['price']
# Calculate prorated price (days-based), then apply discount
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
prorated_price, _ = _calc_prorated(
base_price_kopeks,
subscription.end_date,
)
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, days_left)
final_price = discount_result['discounted']
traffic_discount_percent = discount_result['percent']
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_traffic',
'subscription_id': subscription.id,
'traffic_gb': request.gb,
'price_kopeks': final_price,
'base_price_kopeks': base_price_kopeks,
'discount_percent': traffic_discount_percent,
'source': 'cabinet',
'description': f'Докупка {request.gb} ГБ трафика',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info('Cart saved for traffic purchase (cabinet save-cart) user +', user_id=user.id, gb=request.gb)
return {'success': True, 'cart_saved': True}
@router.post('/devices/save-cart')
async def save_devices_cart(
request: DevicePurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, bool]:
"""Save cart for device purchase (for insufficient balance flow)."""
await db.refresh(user, ['subscription'])
subscription = user.subscription
if not subscription:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='У вас нет активной подписки',
)
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:
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='Докупка устройств недоступна',
)
# Check max device 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:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Максимальное количество устройств: {max_device_limit}',
)
# Calculate prorated price based on remaining days
now = datetime.now(UTC)
end_date = subscription.end_date
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
total_days = 30
base_total_price = int(device_price * request.devices * days_left / total_days)
base_total_price = max(100, base_total_price) # Minimum 1 ruble
# Apply discount from promo group
period_hint_days = days_left
discount_result = _apply_addon_discount(user, 'devices', base_total_price, period_hint_days)
price_kopeks = discount_result['discounted']
devices_discount_percent = discount_result['percent']
# Ensure minimum price after discount (except for 100% discount)
if devices_discount_percent < 100 and price_kopeks > 0:
price_kopeks = max(100, price_kopeks)
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_devices',
'devices_to_add': request.devices,
'price_kopeks': price_kopeks,
'base_price_kopeks': base_total_price,
'discount_percent': devices_discount_percent,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(
'Cart saved for device purchase (cabinet save-cart) user + devices', user_id=user.id, devices=request.devices
)
return {'success': True, 'cart_saved': True}
@router.get('/devices/price')
async def get_device_price(
devices: int = 1,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get price for additional devices."""
await db.refresh(user, ['subscription'])
subscription = user.subscription
if not subscription or subscription.status not in ['active', 'trial']:
return {
'available': False,
'reason': 'Нет активной подписки',
}
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:
# Classic mode - use settings
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:
return {
'available': False,
'reason': 'Докупка устройств недоступна',
}
# Check max device limit
current_devices = subscription.device_limit or 1
can_add = max_device_limit - current_devices if max_device_limit else None
if max_device_limit and current_devices >= max_device_limit:
return {
'available': False,
'reason': f'Достигнут максимум устройств ({max_device_limit})',
'current_device_limit': current_devices,
'max_device_limit': max_device_limit,
}
if max_device_limit and current_devices + devices > max_device_limit:
return {
'available': False,
'reason': f'Можно добавить максимум {can_add} устройств',
'current_device_limit': current_devices,
'max_device_limit': max_device_limit,
'can_add': can_add,
}
# Calculate prorated price
now = datetime.now(UTC)
end_date = subscription.end_date
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
total_days = 30
# Calculate base price before discount (total first, then floor)
base_total_price = int(device_price * devices * days_left / total_days)
base_total_price = max(100, base_total_price)
# Apply discount from promo group
period_hint_days = days_left
discount_result = _apply_addon_discount(user, 'devices', base_total_price, period_hint_days)
total_price_kopeks = discount_result['discounted']
devices_discount_percent = discount_result['percent']
discount_value = discount_result['discount']
# Ensure minimum price after discount (except for 100% discount)
if devices_discount_percent < 100 and total_price_kopeks > 0:
total_price_kopeks = max(100, total_price_kopeks)
price_per_device_kopeks = total_price_kopeks // devices if devices > 0 else 0
response = {
'available': True,
'devices': devices,
'price_per_device_kopeks': price_per_device_kopeks,
'price_per_device_label': settings.format_price(price_per_device_kopeks),
'total_price_kopeks': total_price_kopeks,
'total_price_label': settings.format_price(total_price_kopeks),
'current_device_limit': current_devices,
'max_device_limit': max_device_limit,
'can_add': can_add,
'days_left': days_left,
'base_device_price_kopeks': device_price,
}
# Add discount info if applicable
if devices_discount_percent > 0:
response['discount_percent'] = devices_discount_percent
response['discount_kopeks'] = discount_value
response['base_total_price_kopeks'] = base_total_price
return response
# ============ App Config for Connection ============
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
except Exception:
return settings.CABINET_REMNA_SUB_CONFIG
def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> tuple[str, bool]:
"""Extract URL scheme from buttons list.
Returns:
Tuple of (scheme, uses_crypto_link).
uses_crypto_link=True when the template is {{HAPP_CRYPT4_LINK}},
meaning subscription_crypto_link should be used as payload.
"""
for btn in buttons:
if not isinstance(btn, dict):
continue
link = btn.get('link', '') or btn.get('url', '') or btn.get('buttonLink', '')
if not link:
continue
link_upper = link.upper()
# Check for {{HAPP_CRYPT4_LINK}} -- uses crypto link as payload
if '{{HAPP_CRYPT4_LINK}}' in link_upper or 'HAPP_CRYPT4_LINK' in link_upper:
scheme = re.sub(r'\{\{HAPP_CRYPT4_LINK\}\}', '', link, flags=re.IGNORECASE)
if scheme and '://' in scheme:
return scheme, True
# Check for {{SUBSCRIPTION_LINK}} -- uses plain subscription_url as payload
if '{{SUBSCRIPTION_LINK}}' in link_upper or 'SUBSCRIPTION_LINK' in link_upper:
scheme = re.sub(r'\{\{SUBSCRIPTION_LINK\}\}', '', link, flags=re.IGNORECASE)
if scheme and '://' in scheme:
return scheme, False
# Also check for type="subscriptionLink" buttons with custom schemes
btn_type = btn.get('type', '')
if btn_type == 'subscriptionLink' and '://' in link and not link.startswith('http'):
scheme = link.split('{{')[0] if '{{' in link else link
if scheme and '://' in scheme:
return scheme, False
return '', False
def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
"""Get URL scheme for app - from config, buttons, or fallback by name.
Returns:
Tuple of (scheme, uses_crypto_link).
uses_crypto_link=True means the app template uses {{HAPP_CRYPT4_LINK}},
so subscription_crypto_link should be used as the deep link payload.
"""
# 1. Check urlScheme field (cabinet format stores usesCryptoLink alongside)
scheme = str(app.get('urlScheme', '')).strip()
if scheme:
uses_crypto = bool(app.get('usesCryptoLink', False))
return scheme, uses_crypto
# 2. Extract from buttons in blocks (RemnaWave format)
blocks = app.get('blocks', [])
for block in blocks:
if not isinstance(block, dict):
continue
buttons = block.get('buttons', [])
scheme, uses_crypto = _extract_scheme_from_buttons(buttons)
if scheme:
return scheme, uses_crypto
# 3. Check buttons directly in app (alternative structure)
direct_buttons = app.get('buttons', [])
if direct_buttons:
scheme, uses_crypto = _extract_scheme_from_buttons(direct_buttons)
if scheme:
return scheme, uses_crypto
# No scheme found
logger.debug(
'_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme',
get=app.get('name'),
get_2=bool(app.get('blocks')),
get_3=bool(app.get('buttons')),
get_4=bool(app.get('urlScheme')),
)
return '', False
async def _load_app_config_async() -> dict[str, Any] | None:
"""Load app config from RemnaWave API (if configured).
Returns None when no Remnawave config is set or API fails.
"""
remnawave_uuid = _get_remnawave_config_uuid()
if remnawave_uuid:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
config = await api.get_subscription_page_config(remnawave_uuid)
if config and config.config:
logger.debug('Loaded app config from RemnaWave', remnawave_uuid=remnawave_uuid)
raw = dict(config.config)
raw['_isRemnawave'] = True
return raw
except Exception as e:
logger.warning('Failed to load RemnaWave config', error=e)
return None
def _create_deep_link(
app: dict[str, Any], subscription_url: str, subscription_crypto_link: str | None = None
) -> str | None:
"""Create deep link for app with subscription URL.
Uses urlScheme from RemnaWave config (e.g. "happ://add/", "v2rayng://install-config?url=")
combined with the appropriate payload URL.
Two Happ schemes exist in RemnaWave:
- happ://add/{{SUBSCRIPTION_LINK}} -> uses plain subscription_url
- happ://crypt4/{{HAPP_CRYPT4_LINK}} -> uses subscription_crypto_link
"""
if not isinstance(app, dict):
return None
if not subscription_url and not subscription_crypto_link:
return None
scheme, uses_crypto = _get_url_scheme_for_app(app)
if not scheme:
logger.debug('_create_deep_link: no urlScheme for app', get=app.get('name', 'unknown'))
return None
# Pick the correct payload based on which template the app uses
if uses_crypto:
if not subscription_crypto_link:
logger.debug(
'_create_deep_link: app requires crypto link but none available', get=app.get('name', 'unknown')
)
return None
payload = subscription_crypto_link
else:
if not subscription_url:
logger.debug(
'_create_deep_link: app requires subscription_url but none available', get=app.get('name', 'unknown')
)
return None
payload = subscription_url
if app.get('isNeedBase64Encoding'):
try:
payload = base64.b64encode(payload.encode('utf-8')).decode('utf-8')
except Exception as e:
logger.warning('Failed to encode payload to base64', error=e)
return f'{scheme}{payload}'
# ============ Countries Management ============
@router.get('/countries')
async def get_available_countries(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get available countries/servers for the user."""
from app.database.crud.server_squad import get_available_server_squads
from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price
await db.refresh(user, ['subscription'])
promo_group_id = user.promo_group_id
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
connected_squads = []
days_left = 0
if user.subscription:
connected_squads = user.subscription.connected_squads or []
# Calculate days left for prorated pricing
if user.subscription.end_date:
delta = user.subscription.end_date - datetime.now(UTC)
days_left = max(0, delta.days)
# Get discount from promo group via PricingEngine (respects apply_discounts_to_addons flag)
from app.services.pricing_engine import PricingEngine
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
countries = []
for server in available_servers:
base_price = server.price_kopeks
# Apply discount
if servers_discount_percent > 0:
discounted_price, _ = apply_percentage_discount(base_price, servers_discount_percent)
else:
discounted_price = base_price
# Calculate prorated price if subscription exists
prorated_price = discounted_price
if user.subscription and user.subscription.end_date:
prorated_price, _ = calculate_prorated_price(
discounted_price,
user.subscription.end_date,
)
countries.append(
{
'uuid': server.squad_uuid,
'name': server.display_name,
'country_code': server.country_code,
'base_price_kopeks': base_price,
'price_kopeks': prorated_price, # Prorated price with discount
'price_per_month_kopeks': discounted_price, # Monthly price with discount
'price_rubles': prorated_price / 100,
'is_available': server.is_available and not server.is_full,
'is_connected': server.squad_uuid in connected_squads,
'has_discount': servers_discount_percent > 0,
'discount_percent': servers_discount_percent,
}
)
return {
'countries': countries,
'connected_count': len(connected_squads),
'has_subscription': user.subscription is not None,
'days_left': days_left,
'discount_percent': servers_discount_percent,
}
@router.post('/countries')
async def update_countries(
request: dict[str, Any],
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Update subscription countries/servers."""
from app.database.crud.server_squad import add_user_to_servers, get_available_server_squads, get_server_ids_by_uuids
from app.database.crud.subscription import add_subscription_servers
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import TransactionType
from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price
await db.refresh(user, ['subscription'])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
if user.subscription.is_trial:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Country management is not available for trial subscriptions',
)
selected_countries = request.get('countries', [])
if not selected_countries:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='At least one country must be selected',
)
current_countries = user.subscription.connected_squads or []
promo_group_id = user.promo_group_id
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
allowed_country_ids = {server.squad_uuid for server in available_servers}
# Validate selected countries
for country_uuid in selected_countries:
if country_uuid not in allowed_country_ids and country_uuid not in current_countries:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Country {country_uuid} is not available',
)
added = [c for c in selected_countries if c not in current_countries]
removed = [c for c in current_countries if c not in selected_countries]
if not added and not removed:
return {
'message': 'No changes detected',
'connected_squads': current_countries,
}
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Calculate cost for added servers
total_cost = 0
added_names = []
removed_names = []
from app.services.pricing_engine import PricingEngine
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
added_server_prices = []
for server in available_servers:
if server.squad_uuid in added:
server_price_per_month = server.price_kopeks
if servers_discount_percent > 0:
discounted_per_month, _ = apply_percentage_discount(
server_price_per_month,
servers_discount_percent,
)
else:
discounted_per_month = server_price_per_month
charged_price, charged_days = calculate_prorated_price(
discounted_per_month,
user.subscription.end_date,
)
total_cost += charged_price
added_names.append(server.display_name)
added_server_prices.append(charged_price)
if server.squad_uuid in removed:
removed_names.append(server.display_name)
# Check balance
if total_cost > 0 and user.balance_kopeks < total_cost:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f'Insufficient balance. Need {total_cost / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB',
)
# Deduct balance and update subscription
if added and total_cost > 0:
success = await subtract_user_balance(db, user, total_cost, f'Adding countries: {", ".join(added_names)}')
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to charge balance',
)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=total_cost,
description=f'Adding countries to subscription: {", ".join(added_names)}',
)
# Add servers to subscription
if added:
added_server_ids = await get_server_ids_by_uuids(db, added)
if added_server_ids:
await add_subscription_servers(db, user.subscription, added_server_ids, added_server_prices)
try:
await add_user_to_servers(db, added_server_ids)
except Exception as e:
logger.error('Ошибка обновления счётчика серверов', error=e)
# Update connected squads
user.subscription.connected_squads = selected_countries
user.subscription.updated_at = datetime.now(UTC)
await db.commit()
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(db, user.subscription)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error('Failed to sync countries with RemnaWave', error=e)
await db.refresh(user.subscription)
return {
'message': 'Countries updated successfully',
'added': added_names,
'removed': removed_names,
'amount_paid_kopeks': total_cost,
'connected_squads': user.subscription.connected_squads,
}
# ============ Connection Link ============
@router.get('/connection-link')
async def get_connection_link(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get subscription connection link and instructions."""
from app.utils.subscription_utils import (
convert_subscription_link_to_happ_scheme,
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
)
await db.refresh(user, ['subscription'])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
subscription_url = user.subscription.subscription_url
if not subscription_url:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Subscription link not yet generated',
)
display_link = get_display_subscription_link(user.subscription)
happ_redirect = get_happ_cryptolink_redirect_link(subscription_url) if settings.is_happ_cryptolink_mode() else None
happ_scheme_link = (
convert_subscription_link_to_happ_scheme(subscription_url) if settings.is_happ_cryptolink_mode() else None
)
connect_mode = settings.CONNECT_BUTTON_MODE
hide_subscription_link = settings.should_hide_subscription_link()
return {
'subscription_url': subscription_url if not hide_subscription_link else None,
'display_link': display_link if not hide_subscription_link else None,
'happ_redirect_link': happ_redirect,
'happ_scheme_link': happ_scheme_link,
'connect_mode': connect_mode,
'hide_link': hide_subscription_link,
'instructions': {
'steps': [
'Copy the subscription link',
'Open your VPN application',
"Find 'Add subscription' or 'Import' option",
'Paste the copied link',
]
},
}
# ============ hApp Downloads ============
@router.get('/happ-downloads')
async def get_happ_downloads(
user: User = Depends(get_current_cabinet_user),
) -> dict[str, Any]:
"""Get hApp download links for different platforms."""
platforms = {
'ios': {
'name': 'iOS (iPhone/iPad)',
'icon': '🍎',
'link': settings.get_happ_download_link('ios'),
},
'android': {
'name': 'Android',
'icon': '🤖',
'link': settings.get_happ_download_link('android'),
},
'macos': {
'name': 'macOS',
'icon': '🖥️',
'link': settings.get_happ_download_link('macos'),
},
'windows': {
'name': 'Windows',
'icon': '💻',
'link': settings.get_happ_download_link('windows'),
},
}
# Filter out platforms without links
available_platforms = {k: v for k, v in platforms.items() if v['link']}
return {
'platforms': available_platforms,
'happ_enabled': bool(available_platforms),
}
def _resolve_button_url(
url: str,
subscription_url: str | None,
subscription_crypto_link: str | None,
) -> str:
"""Resolve template variables in button URLs.
Matches remnawave/subscription-page frontend TemplateEngine:
- {{SUBSCRIPTION_LINK}} -> plain subscription URL
- {{HAPP_CRYPT3_LINK}} -> crypto link
- {{HAPP_CRYPT4_LINK}} -> crypto link
"""
if not url:
return url
result = url
if subscription_url:
result = result.replace('{{SUBSCRIPTION_LINK}}', subscription_url)
if subscription_crypto_link:
result = result.replace('{{HAPP_CRYPT3_LINK}}', subscription_crypto_link)
result = result.replace('{{HAPP_CRYPT4_LINK}}', subscription_crypto_link)
return result
@router.get('/app-config')
async def get_app_config(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get app configuration for connection with deep links."""
await db.refresh(user, ['subscription'])
subscription_url = None
subscription_crypto_link = None
if user.subscription:
subscription_url = user.subscription.subscription_url
subscription_crypto_link = user.subscription.subscription_crypto_link
# Generate crypto link on the fly if subscription_url exists but crypto link is missing.
# This covers synced users where enrich_happ_links was not called.
if subscription_url and not subscription_crypto_link:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
encrypted = await api.encrypt_happ_crypto_link(subscription_url)
if encrypted:
subscription_crypto_link = encrypted
if user.subscription:
user.subscription.subscription_crypto_link = encrypted
await db.commit()
logger.info(
'Generated and saved crypto link for user',
user_id=user.id,
)
except Exception as e:
logger.debug('Could not generate crypto link', error=e)
config = await _load_app_config_async()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='App configuration not set up.',
)
config.pop('_isRemnawave', None)
hide_link = settings.should_hide_subscription_link()
# Build platformNames from displayName of each platform
platform_names: dict[str, Any] = {}
for pk, pd in config.get('platforms', {}).items():
if isinstance(pd, dict) and 'displayName' in pd:
platform_names[pk] = pd['displayName']
fallback_names = {
'ios': {'en': 'iPhone/iPad'},
'android': {'en': 'Android'},
'macos': {'en': 'macOS'},
'windows': {'en': 'Windows'},
'linux': {'en': 'Linux'},
'androidTV': {'en': 'Android TV'},
'appleTV': {'en': 'Apple TV'},
}
for k, v in fallback_names.items():
if k not in platform_names:
platform_names[k] = v
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
platforms: dict[str, Any] = {}
for platform_key, platform_data in config.get('platforms', {}).items():
if not isinstance(platform_data, dict):
continue
apps = platform_data.get('apps', [])
if not isinstance(apps, list):
continue
enriched_apps = []
for app in apps:
if not isinstance(app, dict):
continue
# Generate deep link
deep_link = None
if subscription_url or subscription_crypto_link:
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
app['deepLink'] = deep_link
# Resolve templates only for subscriptionLink and copyButton (not external)
for block in app.get('blocks', []):
if not isinstance(block, dict):
continue
for btn in block.get('buttons', []):
if not isinstance(btn, dict):
continue
btn_type = btn.get('type', '')
if btn_type in ('subscriptionLink', 'copyButton'):
url = btn.get('url', '') or btn.get('link', '')
if url and '{{' in url:
resolved = _resolve_button_url(
url,
subscription_url,
subscription_crypto_link,
)
# Only set resolvedUrl if ALL templates were resolved;
# otherwise let the frontend fall through to deepLink/subscriptionUrl
if '{{' not in resolved:
btn['resolvedUrl'] = resolved
enriched_apps.append(app)
if enriched_apps:
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
platform_output['apps'] = enriched_apps
platforms[platform_key] = platform_output
return {
'isRemnawave': True,
'platforms': platforms,
'svgLibrary': config.get('svgLibrary', {}),
'baseTranslations': config.get('baseTranslations'),
'baseSettings': config.get('baseSettings'),
'uiConfig': config.get('uiConfig', {}),
'platformNames': platform_names,
'hasSubscription': bool(subscription_url or subscription_crypto_link),
'subscriptionUrl': subscription_url,
'subscriptionCryptoLink': subscription_crypto_link,
'hideLink': hide_link,
'branding': config.get('brandingSettings', {}),
}
# ============ Device Management ============
@router.get('/devices')
async def get_devices(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get list of connected devices."""
from app.services.remnawave_service import RemnaWaveService
await db.refresh(user, ['subscription'])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
if not user.remnawave_uuid:
return {
'devices': [],
'total': 0,
'device_limit': user.subscription.device_limit or 0,
}
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
response = await api.get_user_devices(user.remnawave_uuid)
devices_list = response.get('devices', [])
formatted_devices = []
for device in devices_list:
hwid = device.get('hwid') or device.get('deviceId') or device.get('id')
platform = device.get('platform') or device.get('platformType') or 'Unknown'
model = device.get('deviceModel') or device.get('model') or device.get('name') or 'Unknown'
created_at = device.get('updatedAt') or device.get('lastSeen') or device.get('createdAt')
formatted_devices.append(
{
'hwid': hwid,
'platform': platform,
'device_model': model,
'created_at': created_at,
}
)
return {
'devices': formatted_devices,
'total': response.get('total', len(formatted_devices)),
'device_limit': user.subscription.device_limit or 0,
}
except Exception as e:
logger.error('Error fetching devices', error=e)
return {
'devices': [],
'total': 0,
'device_limit': user.subscription.device_limit or 0,
}
@router.delete('/devices/{hwid}')
async def delete_device(
hwid: str,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Delete a specific device by HWID."""
from app.services.remnawave_service import RemnaWaveService
await db.refresh(user, ['subscription'])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
if not user.remnawave_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='User UUID not found',
)
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
delete_data = {'userUuid': user.remnawave_uuid, 'hwid': hwid}
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
return {
'success': True,
'message': 'Device deleted successfully',
'deleted_hwid': hwid,
}
except Exception as e:
logger.error('Error deleting device', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete device',
)
@router.delete('/devices')
async def delete_all_devices(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Delete all connected devices."""
from app.services.remnawave_service import RemnaWaveService
await db.refresh(user, ['subscription'])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
if not user.remnawave_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='User UUID not found',
)
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
# Get all devices first
response = await api._make_request('GET', f'/api/hwid/devices/{user.remnawave_uuid}')
if not response or 'response' not in response:
return {
'success': True,
'message': 'No devices to delete',
'deleted_count': 0,
}
devices_list = response['response'].get('devices', [])
if not devices_list:
return {
'success': True,
'message': 'No devices to delete',
'deleted_count': 0,
}
deleted_count = 0
for device in devices_list:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {'userUuid': user.remnawave_uuid, 'hwid': device_hwid}
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
deleted_count += 1
except Exception as device_error:
logger.error('Error deleting device', device_hwid=device_hwid, device_error=device_error)
return {
'success': True,
'message': f'Deleted {deleted_count} devices',
'deleted_count': deleted_count,
}
except Exception as e:
logger.error('Error deleting all devices', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete devices',
)
# ============ Device Reduction ============
@router.get('/devices/reduction-info')
async def get_device_reduction_info(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get info about device limit reduction availability."""
from app.services.remnawave_service import RemnaWaveService
await db.refresh(user, ['subscription'])
if not user.subscription:
return {
'available': False,
'reason': 'No subscription found',
'current_device_limit': 0,
'min_device_limit': 1,
'can_reduce': 0,
'connected_devices_count': 0,
}
subscription = user.subscription
# Check if it's a trial subscription
if subscription.is_trial:
return {
'available': False,
'reason': 'Device reduction is not available for trial subscriptions',
'current_device_limit': subscription.device_limit or 1,
'min_device_limit': 1,
'can_reduce': 0,
'connected_devices_count': 0,
}
# Get tariff info for min device limit
tariff = None
min_device_limit = 1
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
min_device_limit = getattr(tariff, 'device_limit', 1) or 1
current_device_limit = subscription.device_limit or 1
# Can't reduce below minimum
if current_device_limit <= min_device_limit:
return {
'available': False,
'reason': 'Already at minimum device limit for your tariff',
'current_device_limit': current_device_limit,
'min_device_limit': min_device_limit,
'can_reduce': 0,
'connected_devices_count': 0,
}
# Get connected devices count
connected_devices_count = 0
if user.remnawave_uuid:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{user.remnawave_uuid}')
if response and 'response' in response:
connected_devices_count = response['response'].get('total', 0)
except Exception as e:
logger.error('Error getting connected devices count', error=e)
can_reduce = current_device_limit - min_device_limit
return {
'available': True,
'current_device_limit': current_device_limit,
'min_device_limit': min_device_limit,
'can_reduce': can_reduce,
'connected_devices_count': connected_devices_count,
}
@router.post('/devices/reduce')
async def reduce_devices(
request: dict[str, int],
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Reduce device limit (no refund)."""
from app.services.remnawave_service import RemnaWaveService
new_device_limit = request.get('new_device_limit')
if not new_device_limit or new_device_limit < 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid new_device_limit',
)
# Lock subscription to prevent concurrent device modifications
result = await db.execute(
select(Subscription)
.where(Subscription.user_id == user.id)
.with_for_update()
.execution_options(populate_existing=True)
)
subscription = result.scalar_one_or_none()
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
if subscription.is_trial:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Device reduction is not available for trial subscriptions',
)
# Get tariff info for min device limit
tariff = None
min_device_limit = 1
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
min_device_limit = getattr(tariff, 'device_limit', 1) or 1
current_device_limit = subscription.device_limit or 1
# Validate new limit
if new_device_limit >= current_device_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='New device limit must be less than current limit',
)
if new_device_limit < min_device_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Cannot reduce below minimum device limit ({min_device_limit}) for your tariff',
)
# Get connected devices and remove excess (last connected ones)
connected_devices_count = 0
devices_removed_count = 0
if user.remnawave_uuid:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{user.remnawave_uuid}')
if response and 'response' in response:
devices_list = response['response'].get('devices', [])
connected_devices_count = len(devices_list)
# If connected devices exceed new limit, remove excess (last connected)
if connected_devices_count > new_device_limit:
devices_to_remove = connected_devices_count - new_device_limit
logger.info(
'Removing excess devices for user had new limit',
devices_to_remove=devices_to_remove,
user_id=user.id,
connected_devices_count=connected_devices_count,
new_device_limit=new_device_limit,
)
# Sort by date (oldest first) and remove the last ones
sorted_devices = sorted(
devices_list,
key=lambda d: d.get('updatedAt') or d.get('createdAt') or '',
)
devices_to_delete = sorted_devices[-devices_to_remove:]
for device in devices_to_delete:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {'userUuid': user.remnawave_uuid, 'hwid': device_hwid}
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
devices_removed_count += 1
logger.info('Removed device for user', device_hwid=device_hwid, user_id=user.id)
except Exception as del_error:
logger.error('Error removing device', device_hwid=device_hwid, del_error=del_error)
except Exception as e:
logger.error('Error checking/removing devices', error=e)
old_device_limit = current_device_limit
# Update subscription
subscription.device_limit = new_device_limit
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Update RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error('Error updating RemnaWave user', error=e)
logger.info(
f'User {user.id} reduced device limit from {old_device_limit} to {new_device_limit}'
+ (f' (removed {devices_removed_count} devices)' if devices_removed_count > 0 else '')
)
return {
'success': True,
'message': 'Device limit reduced successfully'
+ (f' ({devices_removed_count} devices removed)' if devices_removed_count > 0 else ''),
'old_device_limit': old_device_limit,
'new_device_limit': new_device_limit,
'devices_removed': devices_removed_count,
}
# ============ Tariff Switch ============
@router.post('/tariff/switch/preview')
async def preview_tariff_switch(
request: TariffPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Preview tariff switch - shows cost calculation."""
if not settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariffs mode is not enabled',
)
await db.refresh(user, ['subscription'])
if not user.subscription or not user.subscription.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No active subscription with tariff',
)
# Use actual_status for correct status check (handles time-based expiration)
actual_status = user.subscription.actual_status
if actual_status == 'expired':
# For expired subscriptions, user should purchase a new tariff, not switch
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
'code': 'subscription_expired',
'message': 'Subscription is expired. Please purchase a new tariff instead of switching.',
'use_purchase_flow': True,
},
)
if actual_status not in ('active', 'trial'):
# For disabled/pending subscriptions, block switching with generic error
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
'code': 'subscription_not_active',
'message': f'Subscription is not active (status: {actual_status}). Cannot switch tariff.',
},
)
current_tariff = await get_tariff_by_id(db, user.subscription.tariff_id)
new_tariff = await get_tariff_by_id(db, request.tariff_id)
if not new_tariff or not new_tariff.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found or inactive',
)
if user.subscription.tariff_id == request.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Already on this tariff',
)
# Check tariff availability for user's promo group
# Use get_primary_promo_group() for correct promo group resolution
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(user, 'promo_group', None)
promo_group_id = promo_group.id if promo_group else None
if not new_tariff.is_available_for_promo_group(promo_group_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Tariff not available for your promo group',
)
# Calculate remaining days
remaining_days = 0
if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC):
delta = user.subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days)
# Calculate switch cost (PricingEngine handles all cases: periodic↔periodic, daily→periodic, periodic→daily)
switch_result = pricing_engine.calculate_tariff_switch_cost(
current_tariff,
new_tariff,
remaining_days,
user=user,
)
upgrade_cost = switch_result.upgrade_cost
is_upgrade = switch_result.is_upgrade
base_upgrade_cost = switch_result.raw_cost
discount_value = switch_result.discount_value
period_discount_percent = switch_result.effective_discount_pct
balance = user.balance_kopeks or 0
has_enough = balance >= upgrade_cost
missing = max(0, upgrade_cost - balance) if not has_enough else 0
response = {
'can_switch': has_enough,
'current_tariff_id': current_tariff.id if current_tariff else None,
'current_tariff_name': current_tariff.name if current_tariff else None,
'new_tariff_id': new_tariff.id,
'new_tariff_name': new_tariff.name,
'remaining_days': remaining_days,
'upgrade_cost_kopeks': upgrade_cost,
'upgrade_cost_label': settings.format_price(upgrade_cost) if upgrade_cost > 0 else 'Бесплатно',
'balance_kopeks': balance,
'balance_label': settings.format_price(balance),
'has_enough_balance': has_enough,
'missing_amount_kopeks': missing,
'missing_amount_label': settings.format_price(missing) if missing > 0 else '',
'is_upgrade': is_upgrade,
}
# Add discount info if applicable
if period_discount_percent > 0 and discount_value > 0:
response['discount_percent'] = period_discount_percent
response['discount_kopeks'] = discount_value
response['base_upgrade_cost_kopeks'] = base_upgrade_cost
return response
@router.post('/tariff/switch')
async def switch_tariff(
request: TariffPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Switch to a different tariff without changing end date."""
if not settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariffs mode is not enabled',
)
await db.refresh(user, ['subscription'])
if not user.subscription or not user.subscription.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No active subscription with tariff',
)
# Lock subscription row to prevent concurrent tariff switches
locked_result = await db.execute(
select(Subscription)
.where(Subscription.id == user.subscription.id)
.with_for_update()
.execution_options(populate_existing=True)
)
subscription = locked_result.scalar_one()
user.subscription = subscription
# Use actual_status for correct status check (handles time-based expiration)
actual_status = user.subscription.actual_status
if actual_status == 'expired':
# For expired subscriptions, user should purchase a new tariff, not switch
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
'code': 'subscription_expired',
'message': 'Subscription is expired. Please purchase a new tariff instead of switching.',
'use_purchase_flow': True,
},
)
if actual_status not in ('active', 'trial'):
# For disabled/pending subscriptions, block switching with generic error
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
'code': 'subscription_not_active',
'message': f'Subscription is not active (status: {actual_status}). Cannot switch tariff.',
},
)
current_tariff = await get_tariff_by_id(db, user.subscription.tariff_id)
new_tariff = await get_tariff_by_id(db, request.tariff_id)
if not new_tariff or not new_tariff.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found or inactive',
)
if user.subscription.tariff_id == request.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Already on this tariff',
)
# Check tariff availability
# Use get_primary_promo_group() for correct promo group resolution
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(user, 'promo_group', None)
promo_group_id = promo_group.id if promo_group else None
if not new_tariff.is_available_for_promo_group(promo_group_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Tariff not available',
)
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Calculate remaining days
remaining_days = 0
if subscription.end_date and subscription.end_date > datetime.now(UTC):
delta = subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days)
# Calculate cost (PricingEngine handles all cases: periodic↔periodic, daily→periodic, periodic→daily)
switch_result = pricing_engine.calculate_tariff_switch_cost(
current_tariff,
new_tariff,
remaining_days,
user=user,
)
upgrade_cost = switch_result.upgrade_cost
base_upgrade_cost = switch_result.raw_cost
discount_value = switch_result.discount_value
period_discount_percent = switch_result.effective_discount_pct
new_period_days = switch_result.new_period_days
# Validate daily price for switching TO daily
new_is_daily = getattr(new_tariff, 'is_daily', False)
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
switching_to_daily = not current_is_daily and new_is_daily
switching_from_daily = current_is_daily and not new_is_daily
if switching_to_daily and (getattr(new_tariff, 'daily_price_kopeks', 0) or 0) <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Daily tariff has invalid price',
)
# Charge if upgrade
if upgrade_cost > 0:
if user.balance_kopeks < upgrade_cost:
missing = upgrade_cost - user.balance_kopeks
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'message': f'Insufficient funds. Missing {settings.format_price(missing)}',
'missing_amount': missing,
},
)
if switching_to_daily:
description = f"Переход на суточный тариф '{new_tariff.name}'"
elif switching_from_daily:
description = f"Переход с суточного на тариф '{new_tariff.name}' ({new_period_days} дней)"
else:
description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)"
# Add discount info to description if applicable
if period_discount_percent > 0 and discount_value > 0:
description += f' (скидка {period_discount_percent}%)'
success = await subtract_user_balance(
db,
user,
upgrade_cost,
description,
consume_promo_offer=switch_result.offer_discount_pct > 0,
mark_as_paid_subscription=True,
commit=False,
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to charge balance',
)
# Create transaction (commit=False to keep FOR UPDATE lock held)
switch_transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=upgrade_cost,
description=description,
payment_method=PaymentMethod.BALANCE,
commit=False,
)
else:
# Free switch (downgrade) — record in history
description = f"Переход на тариф '{new_tariff.name}'"
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=0,
description=description,
commit=False,
)
# Update subscription
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
# Preserve extra purchased devices above the old tariff's base limit
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
# (subtract_user_balance re-selects User with populate_existing=True which expires relationships)
await db.refresh(user, ['subscription'])
subscription = user.subscription
subscription.tariff_id = new_tariff.id
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
subscription.device_limit = calc_device_limit_on_tariff_switch(
current_device_limit=subscription.device_limit,
old_tariff_device_limit=current_tariff.device_limit if current_tariff else None,
new_tariff_device_limit=new_tariff.device_limit,
max_device_limit=new_tariff.max_device_limit,
)
subscription.connected_squads = new_tariff.allowed_squads or []
# Reset purchased traffic and delete TrafficPurchase records on tariff switch
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
if switching_to_daily:
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.is_daily_paused = False
elif switching_from_daily:
subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
subscription.is_daily_paused = False
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Emit deferred side-effects after atomic commit
if upgrade_cost > 0 and switch_transaction:
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
switch_transaction,
amount_kopeks=upgrade_cost,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
payment_method=PaymentMethod.BALANCE,
)
# Sync with RemnaWave (optionally reset traffic based on admin setting)
should_reset_traffic = settings.RESET_TRAFFIC_ON_TARIFF_SWITCH
# Refresh subscription after commit (all objects are expired)
await db.refresh(subscription)
try:
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
)
except Exception as e:
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
# Reset all devices on tariff switch
devices_reset = False
if user.remnawave_uuid:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
await api.reset_user_devices(user.remnawave_uuid)
devices_reset = True
logger.info('Reset all devices for user on tariff switch', user_id=user.id)
except Exception as e:
logger.error('Failed to reset devices on tariff switch', error=e)
await db.refresh(user)
await db.refresh(subscription)
# Отправляем уведомление админам о смене тарифа
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
db=db,
user=user,
subscription=subscription,
transaction=switch_transaction if upgrade_cost > 0 else None,
period_days=remaining_days if remaining_days > 0 else new_period_days,
was_trial_conversion=False,
amount_kopeks=upgrade_cost,
purchase_type='tariff_switch',
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for tariff switch', error=e)
response = {
'success': True,
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
+ (' (devices reset)' if devices_reset else ''),
'subscription': _subscription_to_response(subscription, user=user),
'old_tariff_name': old_tariff_name,
'new_tariff_id': new_tariff.id,
'new_tariff_name': new_tariff.name,
'charged_kopeks': upgrade_cost,
'balance_kopeks': user.balance_kopeks,
'balance_label': settings.format_price(user.balance_kopeks),
}
# Add discount info if applicable
if period_discount_percent > 0 and discount_value > 0:
response['discount_percent'] = period_discount_percent
response['discount_kopeks'] = discount_value
response['base_charged_kopeks'] = base_upgrade_cost
return response
# ============ Daily Subscription Pause ============
@router.post('/pause')
async def toggle_subscription_pause(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Toggle pause/resume for daily subscription."""
await db.refresh(user, ['subscription'])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
tariff_id = getattr(user.subscription, 'tariff_id', None)
if not tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Subscription has no tariff',
)
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not getattr(tariff, 'is_daily', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Pause is only available for daily tariffs',
)
# Determine current state
from app.database.models import SubscriptionStatus
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
was_disabled = user.subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
)
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
# even if is_daily_paused is False (it's set by the system, not the user)
if was_disabled and not is_currently_paused:
new_paused_state = False # Force resume path
else:
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume)
from app.services.pricing_engine import PricingEngine
promo_group = PricingEngine.resolve_promo_group(user)
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
daily_price = (
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
# If resuming, check balance and charge
if not new_paused_state:
if daily_price > 0 and user.balance_kopeks < daily_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_balance',
'message': 'Insufficient balance to resume daily subscription',
'required': daily_price,
'balance': user.balance_kopeks,
},
)
# Charge daily fee FIRST, then restore ACTIVE status
if was_disabled:
if daily_price > 0:
from app.database.crud.user import subtract_user_balance
deducted = await subtract_user_balance(
db,
user,
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
)
if not deducted:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_balance',
'message': 'Balance deduction failed',
'required': daily_price,
'balance': user.balance_kopeks,
},
)
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
try:
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as exc:
logger.warning('Failed to create resume transaction', error=exc)
# Balance deducted successfully — now activate
user.subscription.status = SubscriptionStatus.ACTIVE.value
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
await db.commit()
await db.refresh(user.subscription)
await db.refresh(user)
# Sync with RemnaWave only when resuming from DISABLED state
if not new_paused_state and was_disabled:
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
user.subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as e:
logger.error('Error syncing RemnaWave user on resume', error=e)
if new_paused_state:
message = 'Daily subscription paused'
else:
message = 'Daily subscription resumed'
return {
'success': True,
'message': message,
'is_paused': new_paused_state,
'balance_kopeks': user.balance_kopeks,
'balance_label': settings.format_price(user.balance_kopeks),
}
# ============ Traffic Switch (Change Traffic Package) ============
@router.put('/traffic')
async def switch_traffic_package(
request: TrafficPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Switch to a different traffic package (change limit)."""
from app.utils.pricing_utils import calculate_prorated_price
await db.refresh(user, ['subscription'])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
if user.subscription.is_trial:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Traffic management is only available for paid subscriptions',
)
current_traffic = user.subscription.traffic_limit_gb or 0
new_traffic = request.gb
if current_traffic == new_traffic:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Already on this traffic package',
)
# Get available packages
packages = settings.get_traffic_packages()
current_pkg = next((p for p in packages if p['gb'] == current_traffic and p.get('enabled', True)), None)
new_pkg = next((p for p in packages if p['gb'] == new_traffic and p.get('enabled', True)), None)
if not new_pkg:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid traffic package',
)
# Calculate price difference (only charge for upgrade)
current_price = current_pkg['price'] if current_pkg else 0
new_price = new_pkg['price']
if new_price > current_price:
# Upgrade - charge difference
price_diff = new_price - current_price
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply promo discount via PricingEngine
price_diff, _discount_val, traffic_discount_percent = pricing_engine.calculate_traffic_discount(
price_diff,
user,
)
# Prorated calculation
final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date)
if user.balance_kopeks < final_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB',
)
# Charge balance
description = f'Traffic upgrade from {current_traffic}GB to {new_traffic}GB'
success = await subtract_user_balance(db, user, final_price, description)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to charge balance',
)
# Create transaction
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_price,
description=description,
)
charged = final_price
else:
# Downgrade - no charge, no refund
charged = 0
# Update subscription — delete TrafficPurchase records before resetting purchased_traffic_gb
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id))
user.subscription.traffic_limit_gb = new_traffic
user.subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch
user.subscription.traffic_reset_at = None # Reset traffic reset date
user.subscription.updated_at = datetime.now(UTC)
await db.commit()
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(db, user.subscription)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error('Failed to sync traffic switch with RemnaWave', error=e)
await db.refresh(user)
await db.refresh(user.subscription)
return {
'success': True,
'message': f'Traffic changed from {current_traffic}GB to {new_traffic}GB',
'old_traffic_gb': current_traffic,
'new_traffic_gb': new_traffic,
'charged_kopeks': charged,
'balance_kopeks': user.balance_kopeks,
'balance_label': settings.format_price(user.balance_kopeks),
}
# ============ Traffic Refresh ============
# Rate limit: 1 request per 60 seconds per user
TRAFFIC_REFRESH_RATE_LIMIT = 1
TRAFFIC_REFRESH_RATE_WINDOW = 60 # seconds
TRAFFIC_CACHE_TTL = 60 # Cache traffic data for 60 seconds
@router.post('/refresh-traffic')
async def refresh_traffic(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Refresh traffic usage from RemnaWave panel.
Rate limited to 1 request per 60 seconds.
"""
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No active subscription',
)
# Используем user.id для rate limit и кеша (работает и для email-пользователей)
user_cache_id = user.id
# Check rate limit
is_limited = await RateLimitCache.is_rate_limited(
user_cache_id,
'traffic_refresh',
TRAFFIC_REFRESH_RATE_LIMIT,
TRAFFIC_REFRESH_RATE_WINDOW,
)
if is_limited:
# Check if we have cached data
traffic_cache_key = cache_key('traffic', user_cache_id)
cached_data = await cache.get(traffic_cache_key)
if cached_data:
return {
'success': True,
'cached': True,
'rate_limited': True,
'retry_after_seconds': TRAFFIC_REFRESH_RATE_WINDOW,
**cached_data,
}
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f'Rate limited. Try again in {TRAFFIC_REFRESH_RATE_WINDOW} seconds.',
headers={'Retry-After': str(TRAFFIC_REFRESH_RATE_WINDOW)},
)
# Fetch traffic from RemnaWave
try:
remnawave_service = RemnaWaveService()
# Для email-пользователей (без telegram_id) используем UUID
if user.telegram_id:
traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id)
elif user.remnawave_uuid:
traffic_stats = await remnawave_service.get_user_traffic_stats_by_uuid(user.remnawave_uuid)
else:
traffic_stats = None
if not traffic_stats:
# Return current database values if RemnaWave unavailable
traffic_data = {
'traffic_used_bytes': int((user.subscription.traffic_used_gb or 0) * (1024**3)),
'traffic_used_gb': round(user.subscription.traffic_used_gb or 0, 2),
'traffic_limit_bytes': int((user.subscription.traffic_limit_gb or 0) * (1024**3)),
'traffic_limit_gb': user.subscription.traffic_limit_gb or 0,
'traffic_used_percent': round(
((user.subscription.traffic_used_gb or 0) / (user.subscription.traffic_limit_gb or 1)) * 100
if user.subscription.traffic_limit_gb
else 0,
1,
),
'is_unlimited': (user.subscription.traffic_limit_gb or 0) == 0,
}
return {
'success': True,
'cached': False,
'source': 'database',
**traffic_data,
}
# Update subscription with fresh data
used_gb = traffic_stats.get('used_traffic_gb', 0)
if abs((user.subscription.traffic_used_gb or 0) - used_gb) > 0.01:
user.subscription.traffic_used_gb = used_gb
user.subscription.updated_at = datetime.now(UTC)
await db.commit()
# Calculate percentage
limit_gb = user.subscription.traffic_limit_gb or 0
if limit_gb > 0:
percent = min(100, (used_gb / limit_gb) * 100)
else:
percent = 0
traffic_data = {
'traffic_used_bytes': traffic_stats.get('used_traffic_bytes', 0),
'traffic_used_gb': round(used_gb, 2),
'traffic_limit_bytes': traffic_stats.get('traffic_limit_bytes', 0),
'traffic_limit_gb': limit_gb,
'traffic_used_percent': round(percent, 1),
'is_unlimited': limit_gb == 0,
'lifetime_used_bytes': traffic_stats.get('lifetime_used_traffic_bytes', 0),
'lifetime_used_gb': round(traffic_stats.get('lifetime_used_traffic_gb', 0), 2),
}
# Cache the result
traffic_cache_key = cache_key('traffic', user_cache_id)
await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL)
return {
'success': True,
'cached': False,
'source': 'remnawave',
**traffic_data,
}
except Exception as e:
logger.error('Error refreshing traffic for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to refresh traffic data',
)