d071269b8c
- 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)
1164 lines
50 KiB
Python
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)
|