Files
remnawave-bedolaga-telegram…/app/cabinet/routes/subscription_modules/purchase.py
T
c0mrade d071269b8c fix: comprehensive multi-subscription audit fixes across routes, handlers, and services
- Fix UUID resolution in monitoring and webhook services for multi-tariff mode
- Update cabinet routes to properly resolve per-subscription UUIDs
- Fix account merge service for multi-tariff subscription transfers
- Update admin handlers (users, promo_offers, servers) for multi-subscription
- Fix traffic, devices, servers, daily subscription modules
- Update payment handlers (stars, yookassa) and purchase services
- Fix broadcast, promocode, and subscription auto-purchase services
- Add multi-subscription support to keyboards and localization
- Fix CRUD operations for subscription queries
- Resolve code quality issues (ruff linting)
2026-03-23 18:40:23 +03:00

1164 lines
50 KiB
Python

"""Purchase-related endpoints.
GET /subscription/purchase-options
POST /subscription/purchase-preview
POST /subscription/purchase
POST /subscription/purchase-tariff
GET /subscription/trial
POST /subscription/trial
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
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, 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.subscription_purchase_service import (
MiniAppSubscriptionPurchaseService,
PurchaseBalanceError,
PurchaseValidationError,
)
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.utils.pricing_utils import format_period_description
from ...dependencies import get_cabinet_db, get_current_cabinet_user
from ...schemas.subscription import (
PurchasePreviewRequest,
SubscriptionResponse,
TariffPurchaseRequest,
TrialInfoResponse,
)
from .helpers import _subscription_to_response
logger = structlog.get_logger(__name__)
router = APIRouter()
# ============ 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: dict[str, Any] = {
'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: dict[str, Any] = {
'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),
subscription_id: int | None = None,
) -> 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)
if settings.is_multi_tariff_enabled():
if subscription_id:
from app.database.crud.subscription import get_subscription_by_id_for_user
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
else:
from app.database.crud.subscription import get_active_subscriptions_by_user_id
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
subscription = active_subs[0] if active_subs else None
else:
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, subscription_id=subscription_id)
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)
# Refresh expired objects after db.commit() in _record_subscription_event
await db.refresh(subscription)
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
if settings.is_multi_tariff_enabled():
from app.database.crud.subscription import get_subscription_by_user_and_tariff
existing_subscription = await get_subscription_by_user_and_tariff(db, user.id, tariff.id)
else:
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',
'subscription_id': existing_subscription.id if existing_subscription else None,
}
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',
'subscription_id': existing_subscription.id if existing_subscription else None,
}
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 subscription.remnawave_uuid:
# Existing subscription with Remnawave user — update it
await service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа (cabinet)',
sync_squads=True,
)
else:
# New subscription — create new Remnawave user
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)
await db.refresh(subscription)
response: dict[str, Any] = {
'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',
)
# ============ Trial ============
@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, ['subscriptions'])
# Проверяем, отключён ли триал для этого типа пользователя
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, ['subscriptions'])
# Проверяем, отключён ли триал для этого типа пользователя
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)