Files
remnawave-bedolaga-telegram…/app/cabinet/routes/subscription.py
T
2026-01-17 01:50:34 +03:00

2936 lines
110 KiB
Python

"""Subscription management routes for cabinet."""
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User, Subscription, ServerSquad, Tariff, TransactionType
from app.database.crud.subscription import (
create_trial_subscription,
get_subscription_by_user_id,
create_paid_subscription,
extend_subscription,
)
from app.database.crud.tariff import get_tariffs_for_user, get_tariff_by_id
from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.crud.user import subtract_user_balance
from app.database.crud.transaction import create_transaction
from sqlalchemy import select
from app.config import settings, PERIOD_PRICES
from app.utils.pricing_utils import format_period_description
from app.services.subscription_service import SubscriptionService
from app.services.system_settings_service import bot_configuration_service
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_purchase_service import (
MiniAppSubscriptionPurchaseService,
PurchaseValidationError,
PurchaseBalanceError,
)
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.subscription import (
SubscriptionResponse,
ServerInfo,
TrafficPurchaseInfo,
RenewalOptionResponse,
RenewalRequest,
TrafficPackageResponse,
TrafficPurchaseRequest,
DevicePurchaseRequest,
AutopayUpdateRequest,
TrialInfoResponse,
PurchaseSelectionRequest,
PurchasePreviewRequest,
TariffPurchaseRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/subscription", tags=["Cabinet Subscription"])
def _subscription_to_response(
subscription: Subscription,
servers: Optional[List[ServerInfo]] = None,
tariff_name: Optional[str] = None,
traffic_purchases: Optional[List[Dict[str, Any]]] = None,
) -> SubscriptionResponse:
"""Convert Subscription model to response."""
now = datetime.utcnow()
# 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")
# 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 and tariff_name from tariff (separate from is_daily check)
if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None)
if not tariff_name: # Only set if not passed as parameter
tariff_name = getattr(subscription.tariff, 'name', None)
# 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_daily_charge_at = last_charge + timedelta(days=1)
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 1,
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,
is_active=is_active,
is_expired=is_expired,
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,
)
@router.get("", response_model=SubscriptionResponse)
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:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No subscription found",
)
# 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.utcnow()
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)
})
return _subscription_to_response(fresh_user.subscription, servers, tariff_name, traffic_purchases_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."""
options = []
# В режиме тарифов берём цены из тарифа пользователя
tariff_prices = None
tariff_periods = None
if settings.is_tariffs_mode():
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
tariff_periods = sorted(tariff_prices.keys())
# Используем периоды тарифа или стандартные
if tariff_periods:
periods = tariff_periods
else:
periods = settings.get_available_renewal_periods()
for period in periods:
# Получаем цену из тарифа или из PERIOD_PRICES
if tariff_prices and period in tariff_prices:
price_kopeks = tariff_prices[period]
else:
price_kopeks = PERIOD_PRICES.get(period, 0)
if price_kopeks <= 0:
continue
# Apply user's discount if any
discount_percent = 0
if hasattr(user, "get_promo_discount"):
discount_percent = user.get_promo_discount("period", period)
if discount_percent > 0:
original_price = price_kopeks
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
else:
original_price = None
options.append(RenewalOptionResponse(
period_days=period,
price_kopeks=price_kopeks,
price_rubles=price_kopeks / 100,
discount_percent=discount_percent,
original_price_kopeks=original_price,
))
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)."""
await db.refresh(user, ["subscription"])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No subscription found",
)
# В режиме тарифов берём цену из тарифа пользователя
price_kopeks = 0
if settings.is_tariffs_mode() and user.subscription.tariff_id:
tariff = await get_tariff_by_id(db, user.subscription.tariff_id)
if tariff and tariff.period_prices:
price_kopeks = tariff.period_prices.get(str(request.period_days), 0)
# Fallback на PERIOD_PRICES
if price_kopeks <= 0:
price_kopeks = PERIOD_PRICES.get(request.period_days, 0)
if price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid renewal period",
)
# Apply discount
discount_percent = 0
if hasattr(user, "get_promo_discount"):
discount_percent = user.get_promo_discount("period", request.period_days)
if discount_percent > 0:
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
# Check balance
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, have {user.balance_kopeks / 100:.2f} RUB",
)
# Deduct balance and extend subscription
user.balance_kopeks -= price_kopeks
# Extend from end_date or now if expired
now = datetime.utcnow()
if user.subscription.end_date and user.subscription.end_date > now:
from datetime import timedelta
user.subscription.end_date = user.subscription.end_date + timedelta(days=request.period_days)
else:
from datetime import timedelta
user.subscription.end_date = now + timedelta(days=request.period_days)
user.subscription.start_date = now
user.subscription.status = "active"
user.subscription.is_trial = False
await db.commit()
return {
"message": "Subscription renewed successfully",
"new_end_date": user.subscription.end_date.isoformat(),
"amount_paid_kopeks": price_kopeks,
}
@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.user import get_user_by_id
from app.database.crud.tariff import get_tariff_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():
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_packages()
result = []
for pkg in packages:
if not pkg.get("enabled", True):
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."""
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]
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_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"]
# Применяем скидку промогруппы
traffic_discount_percent = 0
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None)
if promo_group:
apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True)
if apply_to_addons:
traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0)))
if traffic_discount_percent > 0:
base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100)
# Пропорциональный расчёт цены
final_price, months_charged = calculate_prorated_price(
base_price_kopeks,
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, have {user.balance_kopeks / 100:.2f} RUB",
)
# Формируем описание
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",
)
# Добавляем трафик
await add_subscription_traffic(db, subscription, request.gb)
# Обновляем purchased_traffic_gb
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
subscription.purchased_traffic_gb = current_purchased + request.gb
# Устанавливаем дату сброса трафика (только при первой докупке)
# При повторной докупке дата НЕ продлевается
if not subscription.traffic_reset_at:
from datetime import timedelta
subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30)
logger.info(f"Set traffic_reset_at for subscription {subscription.id}: {subscription.traffic_reset_at}")
await db.commit()
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error(f"Failed to sync traffic with RemnaWave: {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)
return {
"success": True,
"message": "Traffic purchased successfully",
"gb_added": request.gb,
"new_traffic_limit_gb": subscription.traffic_limit_gb,
"amount_paid_kopeks": final_price,
"discount_percent": traffic_discount_percent,
"new_balance_kopeks": user.balance_kopeks,
}
@router.post("/devices")
async def purchase_devices(
request: DevicePurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Purchase additional device slots."""
await db.refresh(user, ["subscription"])
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No subscription found",
)
price_per_device = settings.PRICE_PER_DEVICE
total_price = price_per_device * request.devices
# Check balance
if user.balance_kopeks < total_price:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Insufficient balance",
)
# Check max devices limit
current_devices = user.subscription.device_limit or 1
new_devices = current_devices + request.devices
max_devices = settings.MAX_DEVICES_LIMIT
if new_devices > max_devices:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Maximum device limit is {max_devices}",
)
# Deduct balance and add devices
user.balance_kopeks -= total_price
user.subscription.device_limit = new_devices
await db.commit()
return {
"message": "Devices added successfully",
"devices_added": request.devices,
"new_device_limit": new_devices,
"amount_paid_kopeks": total_price,
}
@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",
)
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"])
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
# Check if user already has an active subscription
if user.subscription:
now = datetime.utcnow()
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"])
# Check if user already has an active subscription
if user.subscription:
now = datetime.utcnow()
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:
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",
)
user.balance_kopeks -= price_kopeks
logger.info(f"User {user.id} paid {price_kopeks} kopeks for trial activation")
# 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
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id:
try:
from app.database.crud.tariff import get_tariff_by_id
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
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(f"Using trial tariff {trial_tariff.name} (ID: {trial_tariff.id}) with squads: {trial_squads}")
except Exception as e:
logger.error(f"Error getting trial tariff: {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 if trial_squads else None,
tariff_id=tariff_id_for_trial,
)
logger.info(f"Trial subscription activated for user {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(f"Failed to create RemnaWave user for trial: {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(f"Failed to send trial activation notification: {e}")
return _subscription_to_response(subscription)
# ============ Full Purchase Flow (like MiniApp) ============
purchase_service = MiniAppSubscriptionPurchaseService()
async def _build_tariff_response(
db: AsyncSession,
tariff: Tariff,
current_tariff_id: Optional[int] = None,
language: str = "ru",
user: Optional[User] = 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
promo_group = user.get_primary_promo_group() if user and hasattr(user, 'get_primary_promo_group') else None
promo_group_name = promo_group.name if promo_group else None
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
period_days = int(period_str)
# Apply promo group discount for this period
original_price = int(price_kopeks)
discount_percent = 0
discount_amount = 0
final_price = original_price
if promo_group:
discount_percent = promo_group.get_discount_percent("period", period_days)
if discount_percent > 0:
discount_amount = original_price * discount_percent // 100
final_price = original_price - discount_amount
months = max(1, period_days // 30)
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),
}
# 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
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
original_daily_price = daily_price
daily_discount_percent = 0
if promo_group and daily_price > 0:
# For daily tariffs, use period discount with period_days=1
daily_discount_percent = promo_group.get_discount_percent("period", 1)
if daily_discount_percent > 0:
discount_amount = daily_price * daily_discount_percent // 100
daily_price = daily_price - discount_amount
# 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:
discount_amount = price_per_day * custom_days_discount_percent // 100
price_per_day = price_per_day - discount_amount
# Apply discount to device price if applicable
device_price = tariff.device_price_kopeks or 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")
if device_discount_percent > 0:
discount_amount = device_price * device_discount_percent // 100
device_price = device_price - discount_amount
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": tariff.device_limit,
"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,
}
# 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:
sales_mode = settings.get_sales_mode()
# Tariffs mode - return list of tariffs
if settings.is_tariffs_mode():
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"
tariff_responses = []
for tariff in tariffs:
tariff_data = await _build_tariff_response(db, tariff, current_tariff_id, language, user)
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),
}
# 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(f"Failed to build purchase options for user {user.id}: {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."""
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(f"Failed to calculate purchase preview for user {user.id}: {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)."""
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)
result = await purchase_service.submit_purchase(db, context, pricing)
subscription = result["subscription"]
return {
"success": True,
"message": result["message"],
"subscription": _subscription_to_response(subscription),
"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:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=str(e),
)
except Exception as e:
logger.error(f"Failed to submit purchase for user {user.id}: {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)."""
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",
)
# 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)
discount_percent = 0
original_price = 0
if is_daily_tariff:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Daily tariff has invalid price",
)
original_price = daily_price
# Apply promo group discount for daily tariff
if promo_group:
discount_percent = promo_group.get_discount_percent("period", 1)
if discount_percent > 0:
discount_amount = daily_price * discount_percent // 100
daily_price = daily_price - discount_amount
# For daily tariffs, charge first day and set period to 1 day
price_kopeks = daily_price
period_days = 1
else:
period_days = request.period_days
# Get price for period (support custom days)
price_kopeks = tariff.get_price_for_period(period_days)
if price_kopeks is None:
# Check for custom days
if tariff.can_purchase_custom_days():
price_kopeks = tariff.get_price_for_custom_days(period_days)
if price_kopeks is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Period must be between {tariff.min_days} and {tariff.max_days} days",
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period for this tariff",
)
original_price = price_kopeks
# Apply promo group discount for period
if promo_group and price_kopeks > 0:
discount_percent = promo_group.get_discount_percent("period", period_days)
if discount_percent > 0:
discount_amount = price_kopeks * discount_percent // 100
price_kopeks = price_kopeks - discount_amount
# Calculate traffic limit and price
traffic_limit_gb = tariff.traffic_limit_gb
traffic_price_kopeks = 0
if request.traffic_gb is not None and tariff.can_purchase_custom_traffic():
# Custom traffic requested
traffic_price_kopeks = tariff.get_price_for_custom_traffic(request.traffic_gb)
if traffic_price_kopeks is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Traffic must be between {tariff.min_traffic_gb} and {tariff.max_traffic_gb} GB",
)
# Apply traffic discount if promo group has it
if promo_group and traffic_price_kopeks > 0:
traffic_discount_percent = promo_group.get_discount_percent("traffic", period_days)
if traffic_discount_percent > 0:
traffic_discount = traffic_price_kopeks * traffic_discount_percent // 100
traffic_price_kopeks = traffic_price_kopeks - traffic_discount
traffic_limit_gb = request.traffic_gb
price_kopeks += traffic_price_kopeks
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "insufficient_funds",
"message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}",
"missing_amount": missing,
},
)
subscription = await get_subscription_by_user_id(db, user.id)
# 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}%)"
success = await subtract_user_balance(db, user, price_kopeks, description)
if not success:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to charge balance",
)
# Create transaction
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_kopeks,
description=description,
)
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=tariff.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.utcnow()
subscription.is_daily_paused = False
await db.commit()
await db.refresh(subscription)
# Sync with RemnaWave
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
if not is_daily_tariff:
try:
from app.services.user_cart_service import user_cart_service
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(f"Tariff cart saved for auto-renewal (cabinet) user {user.telegram_id}")
except Exception as e:
logger.error(f"Error saving tariff cart (cabinet): {e}")
await db.refresh(user)
response = {
"success": True,
"message": f"Тариф '{tariff.name}' успешно активирован",
"subscription": _subscription_to_response(subscription),
"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_kopeks
response["discount_label"] = settings.format_price(original_price - price_kopeks)
if promo_group:
response["promo_group_name"] = promo_group.name
return response
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to purchase tariff for user {user.id}: {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."""
try:
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
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)
if not tariff or not tariff.device_price_kopeks:
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 tariff.max_device_limit and new_device_count > tariff.max_device_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Максимальное количество устройств для вашего тарифа: {tariff.max_device_limit}",
)
# Calculate prorated price based on remaining days
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
end_date = subscription.end_date
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
days_left = max(1, (end_date - now).days)
total_days = 30 # Base period for device price calculation
# Price = device_price * devices * (days_left / 30)
price_kopeks = int(tariff.device_price_kopeks * request.devices * days_left / total_days)
price_kopeks = max(100, price_kopeks) # Minimum 1 ruble
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"error": "Insufficient balance",
"required_kopeks": price_kopeks,
"current_kopeks": user.balance_kopeks,
"missing_kopeks": missing,
},
)
# Deduct balance
from app.database.crud.user import subtract_user_balance
await subtract_user_balance(
db=db,
user=user,
amount_kopeks=price_kopeks,
description=f"Покупка {request.devices} доп. устройств",
)
# Increase device limit
subscription.device_limit += request.devices
await db.commit()
await db.refresh(subscription)
# Sync with RemnaWave
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
await db.refresh(user)
logger.info(
f"User {user.telegram_id} purchased {request.devices} devices for {price_kopeks} kopeks"
)
return {
"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),
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to purchase devices for user {user.id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Не удалось обработать покупку устройств",
)
@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)
if not tariff or not tariff.device_price_kopeks:
return {
"available": False,
"reason": "Докупка устройств недоступна для вашего тарифа",
}
# Check max device limit
current_devices = subscription.device_limit or 1
max_device_limit = tariff.max_device_limit
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
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
end_date = subscription.end_date
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
days_left = max(1, (end_date - now).days)
total_days = 30
price_per_device_kopeks = int(tariff.device_price_kopeks * days_left / total_days)
price_per_device_kopeks = max(100, price_per_device_kopeks)
total_price_kopeks = price_per_device_kopeks * devices
return {
"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": tariff.device_price_kopeks,
}
# ============ App Config for Connection ============
def _load_app_config_from_file() -> Dict[str, Any]:
"""Load app-config.json file."""
try:
config_path = settings.get_app_config_path()
with open(config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception as e:
logger.error(f"Failed to load app-config.json: {e}")
return {}
def _get_remnawave_config_uuid() -> Optional[str]:
"""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 _is_subscription_link_template(url: str) -> bool:
"""Check if URL is a RemnaWave subscription link template."""
if not url:
return False
# RemnaWave uses templates like {{HAPP_CRYPT4_LINK}}, {{V2RAY_LINK}}, etc.
if url.startswith("{{") and url.endswith("}}"):
return True
# Also check for button type "subscriptionLink" indicator
return False
def _convert_remnawave_block_to_step(block: Dict[str, Any], url_scheme: str = "") -> Dict[str, Any]:
"""Convert RemnaWave block format to cabinet step format."""
step = {
"description": block.get("description", {}),
}
if block.get("title"):
step["title"] = block["title"]
if block.get("buttons"):
buttons = []
for btn in block["buttons"]:
btn_url = btn.get("url", "") or btn.get("link", "")
btn_type = btn.get("type", "")
# Replace subscription link templates with {{deepLink}} placeholder
# RemnaWave uses templates like {{HAPP_CRYPT4_LINK}} or type="subscriptionLink"
if _is_subscription_link_template(btn_url) or btn_type == "subscriptionLink":
btn_url = "{{deepLink}}"
# Also check for urlScheme-based URLs
elif url_scheme and btn_url and (
btn_url.startswith(url_scheme) or
btn_url.endswith("://") or
btn_url.endswith("://add/") or
("://" in btn_url and not btn_url.startswith("http"))
):
btn_url = "{{deepLink}}"
buttons.append({
"buttonLink": btn_url,
"buttonText": btn.get("text", {}),
})
step["buttons"] = buttons
return step
# Known app URL schemes (fallback if RemnaWave doesn't provide urlScheme)
KNOWN_APP_URL_SCHEMES = {
# iOS
"happ": "happ://add/",
"streisand": "streisand://import/",
"shadowrocket": "sub://",
"shadow rocket": "sub://",
"karing": "karing://install-config?url=",
"foxray": "foxray://yiguo.dev/sub/add/?url=",
"fox ray": "foxray://yiguo.dev/sub/add/?url=",
"v2box": "v2box://install-sub?url=",
"sing-box": "sing-box://import-remote-profile?url=",
"singbox": "sing-box://import-remote-profile?url=",
"quantumult x": "quantumult-x://add-resource?remote-resource=",
"quantumultx": "quantumult-x://add-resource?remote-resource=",
"quantumult": "quantumult-x://add-resource?remote-resource=",
"surge": "surge3://install-config?url=",
"loon": "loon://import?sub=",
"stash": "stash://install-config?url=",
# Android
"v2rayn": "v2rayng://install-sub?url=",
"v2rayng": "v2rayng://install-sub?url=",
"v2ray ng": "v2rayng://install-sub?url=",
"nekoray": "sn://subscription?url=",
"nekobox": "sn://subscription?url=",
"neko ray": "sn://subscription?url=",
"neko box": "sn://subscription?url=",
"surfboard": "surfboard://install-config?url=",
# PC (Windows/macOS/Linux)
"clash": "clash://install-config?url=",
"clash meta": "clash://install-config?url=",
"clash verge": "clash://install-config?url=",
"clash verge rev": "clash://install-config?url=",
"clashx": "clashx://install-config?url=",
"clashx meta": "clash://install-config?url=",
"clashx pro": "clash://install-config?url=",
"flclash": "clash://install-config?url=",
"flclashx": "clash://install-config?url=",
"koala clash": "clash://install-config?url=",
"koalaclash": "clash://install-config?url=",
"hiddify": "hiddify://install-config/?url=",
"hiddify next": "hiddify://install-config/?url=",
"mihomo party": "clash://install-config?url=",
"mihomo": "clash://install-config?url=",
}
def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]:
"""Convert RemnaWave app format to cabinet app format."""
blocks = app.get("blocks", [])
url_scheme = app.get("urlScheme", "")
# If urlScheme is missing, try to determine from app name
if not url_scheme:
app_name = app.get("name", "").lower().strip()
url_scheme = KNOWN_APP_URL_SCHEMES.get(app_name, "")
# Map blocks to steps based on position
installation_step = _convert_remnawave_block_to_step(blocks[0], url_scheme) if len(blocks) > 0 else {"description": {}}
subscription_step = _convert_remnawave_block_to_step(blocks[1], url_scheme) if len(blocks) > 1 else {"description": {}}
connect_step = _convert_remnawave_block_to_step(blocks[2], url_scheme) if len(blocks) > 2 else {"description": {}}
# Ensure subscription step has a deepLink button if urlScheme exists
if url_scheme:
has_deeplink_button = False
if "buttons" in subscription_step:
for btn in subscription_step["buttons"]:
if btn.get("buttonLink") == "{{deepLink}}":
has_deeplink_button = True
break
if not has_deeplink_button:
# Add deepLink button at the beginning
deeplink_button = {
"buttonLink": "{{deepLink}}",
"buttonText": {
"en": "Open app",
"ru": "Открыть приложение",
"zh": "打开应用",
"fa": "باز کردن برنامه",
},
}
if "buttons" not in subscription_step:
subscription_step["buttons"] = []
subscription_step["buttons"].insert(0, deeplink_button)
return {
"id": app.get("name", "").lower().replace(" ", "-"),
"name": app.get("name", ""),
"isFeatured": app.get("featured", False),
"urlScheme": url_scheme, # Use resolved url_scheme (with fallback from app name)
"isNeedBase64Encoding": app.get("isNeedBase64Encoding", False),
"installationStep": installation_step,
"addSubscriptionStep": subscription_step,
"connectAndUseStep": connect_step,
}
def _convert_remnawave_config_to_cabinet(config: Dict[str, Any]) -> Dict[str, Any]:
"""Convert RemnaWave config format to cabinet format."""
platforms = {}
remnawave_platforms = config.get("platforms", {})
for platform_key, platform_data in remnawave_platforms.items():
if not isinstance(platform_data, dict):
continue
apps = platform_data.get("apps", [])
if not isinstance(apps, list):
continue
cabinet_apps = []
for app in apps:
if isinstance(app, dict):
cabinet_apps.append(_convert_remnawave_app_to_cabinet(app))
if cabinet_apps:
platforms[platform_key] = cabinet_apps
# Convert branding
branding = {}
if config.get("brandingSettings"):
branding = {
"name": config["brandingSettings"].get("name", ""),
"logoUrl": config["brandingSettings"].get("logoUrl", ""),
"supportUrl": config["brandingSettings"].get("supportUrl", ""),
}
return {
"config": {
"additionalLocales": ["zh", "fa"],
"branding": branding,
},
"platforms": platforms,
}
async def _load_app_config_async() -> Dict[str, Any]:
"""Load app config from RemnaWave (if configured) or local file."""
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(f"Loaded app config from RemnaWave: {remnawave_uuid}")
# Debug: log raw RemnaWave config structure
import json
logger.debug(f"RemnaWave raw config: {json.dumps(config.config, ensure_ascii=False, indent=2)[:2000]}")
converted = _convert_remnawave_config_to_cabinet(config.config)
logger.debug(f"Converted config platforms: {list(converted.get('platforms', {}).keys())}")
# Log first app from each platform
for platform, apps in converted.get('platforms', {}).items():
if apps:
first_app = apps[0]
logger.debug(f"Platform {platform} first app: name={first_app.get('name')}, urlScheme={first_app.get('urlScheme')}")
return converted
except Exception as e:
logger.warning(f"Failed to load RemnaWave config, falling back to file: {e}")
# Fallback to local file
return _load_app_config_from_file()
def _load_app_config() -> Dict[str, Any]:
"""Load app-config.json file (sync version for compatibility)."""
return _load_app_config_from_file()
def _create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]:
"""Create deep link for app with subscription URL."""
if not subscription_url or not isinstance(app, dict):
logger.debug(f"_create_deep_link: no subscription_url or invalid app")
return None
scheme = str(app.get("urlScheme", "")).strip()
if not scheme:
# Try fallback from app name
app_name = app.get("name", "").lower().strip()
scheme = KNOWN_APP_URL_SCHEMES.get(app_name, "")
if scheme:
logger.info(f"_create_deep_link: used fallback urlScheme for '{app_name}': {scheme}")
if not scheme:
logger.warning(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'")
return None
payload = subscription_url
if app.get("isNeedBase64Encoding"):
try:
payload = base64.b64encode(subscription_url.encode("utf-8")).decode("utf-8")
except Exception as e:
logger.warning(f"Failed to encode subscription URL to base64: {e}")
payload = subscription_url
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
await db.refresh(user, ["subscription"])
promo_group_id = user.promo_group_id
# Exclude trial-only servers from available servers for purchase
available_servers = await get_available_server_squads(
db, promo_group_id=promo_group_id, exclude_trial_only=True
)
connected_squads = []
if user.subscription:
connected_squads = user.subscription.connected_squads or []
countries = []
for server in available_servers:
countries.append({
"uuid": server.squad_uuid,
"name": server.display_name,
"country_code": server.country_code,
"price_kopeks": server.price_kopeks,
"price_rubles": server.price_kopeks / 100,
"is_available": server.is_available and not server.is_full,
"is_connected": server.squad_uuid in connected_squads,
})
return {
"countries": countries,
"connected_count": len(connected_squads),
"has_subscription": user.subscription is not None,
}
@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 get_available_server_squads, get_server_ids_by_uuids, add_user_to_servers
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 calculate_prorated_price, apply_percentage_discount
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
# Exclude trial-only servers from available servers for purchase
available_servers = await get_available_server_squads(
db, promo_group_id=promo_group_id, exclude_trial_only=True
)
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,
}
# Calculate cost for added servers
total_cost = 0
added_names = []
removed_names = []
servers_discount_percent = 0
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group:
servers_discount_percent = promo_group.get_discount_percent("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_months = 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)
await add_user_to_servers(db, added_server_ids)
# Update connected squads
user.subscription.connected_squads = selected_countries
user.subscription.updated_at = datetime.utcnow()
await db.commit()
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f"Failed to sync countries with RemnaWave: {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 (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
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),
}
@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
if user.subscription:
subscription_url = user.subscription.subscription_url
# Load config from RemnaWave (if configured) or local file
config = await _load_app_config_async()
platforms_raw = config.get("platforms", {})
if not isinstance(platforms_raw, dict):
platforms_raw = {}
# Build response with deep links
platforms = {}
for platform_key, apps in platforms_raw.items():
if not isinstance(apps, list):
continue
platform_apps = []
for app in apps:
if not isinstance(app, dict):
continue
app_data = {
"id": app.get("id"),
"name": app.get("name"),
"isFeatured": app.get("isFeatured", False),
"installationStep": app.get("installationStep"),
"addSubscriptionStep": app.get("addSubscriptionStep"),
"connectAndUseStep": app.get("connectAndUseStep"),
"additionalBeforeAddSubscriptionStep": app.get("additionalBeforeAddSubscriptionStep"),
"additionalAfterAddSubscriptionStep": app.get("additionalAfterAddSubscriptionStep"),
}
# Add deep link if subscription exists
if subscription_url:
app_data["deepLink"] = _create_deep_link(app, subscription_url)
platform_apps.append(app_data)
if platform_apps:
platforms[platform_key] = platform_apps
# Platform display names for UI
platform_names = {
"ios": {"ru": "iPhone/iPad", "en": "iPhone/iPad"},
"android": {"ru": "Android", "en": "Android"},
"macos": {"ru": "macOS", "en": "macOS"},
"windows": {"ru": "Windows", "en": "Windows"},
"linux": {"ru": "Linux", "en": "Linux"},
"androidTV": {"ru": "Android TV", "en": "Android TV"},
"appleTV": {"ru": "Apple TV", "en": "Apple TV"},
}
return {
"platforms": platforms,
"platformNames": platform_names,
"hasSubscription": bool(subscription_url),
"subscriptionUrl": subscription_url,
"branding": config.get("config", {}).get("branding", {}),
}
# ============ 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 1,
}
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 1,
}
except Exception as e:
logger.error(f"Error fetching devices: {e}")
return {
"devices": [],
"total": 0,
"device_limit": user.subscription.device_limit or 1,
}
@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(f"Error deleting device: {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(f"Error deleting device {device_hwid}: {device_error}")
return {
"success": True,
"message": f"Deleted {deleted_count} devices",
"deleted_count": deleted_count,
}
except Exception as e:
logger.error(f"Error deleting all devices: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete devices",
)
# ============ 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",
)
if user.subscription.status not in ("active", "trial"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Subscription is not active",
)
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
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.utcnow():
delta = user.subscription.end_date - datetime.utcnow()
remaining_days = max(0, delta.days)
# Calculate switch cost
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
new_is_daily = getattr(new_tariff, 'is_daily', False)
switching_to_daily = not current_is_daily and new_is_daily
switching_from_daily = current_is_daily and not new_is_daily
def get_monthly_price(tariff) -> int:
"""Get 30-day price from tariff, or calculate from closest period."""
if not tariff or not tariff.period_prices:
return 0
# Try to get 30-day price directly
if '30' in tariff.period_prices:
return tariff.period_prices['30']
# Find closest period and calculate monthly equivalent
min_period = None
min_price = 0
for period_str, price in tariff.period_prices.items():
period_days = int(period_str)
if min_period is None or period_days < min_period:
min_period = period_days
min_price = price
if min_period and min_period > 0:
return int(min_price * 30 / min_period)
return 0
if switching_to_daily:
# Switching TO daily - pay first day price
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
upgrade_cost = daily_price
is_upgrade = daily_price > 0
elif switching_from_daily:
# Switching FROM daily TO periodic - full payment for new tariff
min_period_price = 0
if new_tariff.period_prices:
min_period_price = min(new_tariff.period_prices.values())
upgrade_cost = min_period_price
is_upgrade = min_period_price > 0
else:
# Calculate proportional cost difference using monthly prices
current_monthly = get_monthly_price(current_tariff)
new_monthly = get_monthly_price(new_tariff)
price_diff = new_monthly - current_monthly
if price_diff > 0:
# Upgrade - pay proportional difference
upgrade_cost = int(price_diff * remaining_days / 30)
is_upgrade = True
else:
# Downgrade or same - free
upgrade_cost = 0
is_upgrade = False
balance = user.balance_kopeks or 0
has_enough = balance >= upgrade_cost
missing = max(0, upgrade_cost - balance) if not has_enough else 0
return {
"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,
}
@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."""
from datetime import timedelta
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",
)
if user.subscription.status not in ("active", "trial"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Subscription is not active",
)
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
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",
)
# Calculate remaining days
remaining_days = 0
if user.subscription.end_date and user.subscription.end_date > datetime.utcnow():
delta = user.subscription.end_date - datetime.utcnow()
remaining_days = max(0, delta.days)
# Calculate cost
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
new_is_daily = getattr(new_tariff, 'is_daily', False)
switching_from_daily = current_is_daily and not new_is_daily
switching_to_daily = not current_is_daily and new_is_daily
if switching_to_daily:
# Switching TO daily tariff - charge first day price
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
if daily_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Daily tariff has invalid price",
)
upgrade_cost = daily_price
new_period_days = 1 # Daily tariff starts with 1 day
elif switching_from_daily:
# Switch FROM daily to regular tariff - pay for minimum period
min_period_days = 30
min_period_price = 0
if new_tariff.period_prices:
min_period_days = min(int(k) for k in new_tariff.period_prices.keys())
min_period_price = new_tariff.period_prices.get(str(min_period_days), 0)
upgrade_cost = min_period_price
new_period_days = min_period_days
else:
# Regular tariff switch - calculate proportional cost difference using monthly prices
def get_monthly_price(tariff) -> int:
if not tariff or not tariff.period_prices:
return 0
if '30' in tariff.period_prices:
return tariff.period_prices['30']
min_period = None
min_price = 0
for period_str, price in tariff.period_prices.items():
period_days = int(period_str)
if min_period is None or period_days < min_period:
min_period = period_days
min_price = price
if min_period and min_period > 0:
return int(min_price * 30 / min_period)
return 0
current_monthly = get_monthly_price(current_tariff)
new_monthly = get_monthly_price(new_tariff)
price_diff = new_monthly - current_monthly
if price_diff > 0:
upgrade_cost = int(price_diff * remaining_days / 30)
else:
upgrade_cost = 0
new_period_days = 0
# 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} дней)"
success = await subtract_user_balance(db, user, upgrade_cost, 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=upgrade_cost,
description=description,
)
# Update subscription
old_tariff_name = current_tariff.name if current_tariff else "Unknown"
user.subscription.tariff_id = new_tariff.id
user.subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
user.subscription.device_limit = new_tariff.device_limit
user.subscription.connected_squads = new_tariff.allowed_squads or []
# Reset purchased traffic and delete TrafficPurchase records on tariff switch
from app.database.models import TrafficPurchase
from sqlalchemy import delete as sql_delete
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id))
user.subscription.purchased_traffic_gb = 0
user.subscription.traffic_reset_at = None
if switching_to_daily:
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
user.subscription.end_date = datetime.utcnow() + timedelta(days=1)
user.subscription.last_daily_charge_at = datetime.utcnow()
user.subscription.is_daily_paused = False
elif switching_from_daily:
user.subscription.end_date = datetime.utcnow() + timedelta(days=new_period_days)
user.subscription.is_daily_paused = False
user.subscription.updated_at = datetime.utcnow()
await db.commit()
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f"Failed to sync tariff switch with RemnaWave: {e}")
await db.refresh(user)
await db.refresh(user.subscription)
return {
"success": True,
"message": f"Switched from '{old_tariff_name}' to '{new_tariff.name}'",
"subscription": _subscription_to_response(user.subscription),
"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),
}
# ============ 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."""
from datetime import timedelta
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",
)
# Toggle pause state
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
# If resuming, check balance
if not new_paused_state:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
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,
},
)
# Restore ACTIVE status if was DISABLED
from app.database.models import SubscriptionStatus
if user.subscription.status == SubscriptionStatus.DISABLED.value:
user.subscription.status = SubscriptionStatus.ACTIVE.value
user.subscription.last_daily_charge_at = datetime.utcnow()
user.subscription.end_date = datetime.utcnow() + timedelta(days=1)
await db.commit()
await db.refresh(user.subscription)
await db.refresh(user)
# Sync with RemnaWave when resuming
if not new_paused_state:
try:
subscription_service = SubscriptionService()
if user.remnawave_uuid:
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
except Exception as e:
logger.error(f"Error syncing with RemnaWave on resume: {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, apply_percentage_discount
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
# Apply promo discount
traffic_discount_percent = 0
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None)
if promo_group:
apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True)
if apply_to_addons:
traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0)))
if traffic_discount_percent > 0:
price_diff = int(price_diff * (100 - traffic_discount_percent) / 100)
# Prorated calculation
final_price, months_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
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.utcnow()
await db.commit()
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f"Failed to sync traffic switch with RemnaWave: {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),
}