""" API views для подписок и платежей. """ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny from django.db import models from django.utils import timezone from django.core.cache import cache from .models import SubscriptionPlan, Subscription, Payment, PaymentHistory from .serializers import ( SubscriptionPlanSerializer, SubscriptionSerializer, SubscriptionCreateSerializer, PaymentSerializer, PaymentCreateSerializer, PaymentHistorySerializer ) from .permissions import IsSubscriptionOwner from apps.users.utils import format_datetime_for_user from .services import PaymentService, SubscriptionService, PromoCodeService class SubscriptionPlanViewSet(viewsets.ReadOnlyModelViewSet): """ ViewSet для тарифных планов. list: Список тарифов retrieve: Детали тарифа """ permission_classes = [AllowAny] serializer_class = SubscriptionPlanSerializer queryset = SubscriptionPlan.objects.filter(is_active=True) lookup_field = 'slug' def get_queryset(self): """Получение тарифов с кэшированием и фильтрацией по роли.""" # Получаем роль пользователя из запроса user = self.request.user if hasattr(self.request, 'user') else None user_role = user.role if user and hasattr(user, 'role') and not user.is_anonymous else None # Формируем ключ кеша с учетом роли cache_key = f'subscription_plans_active_{user_role or "anonymous"}' queryset = cache.get(cache_key) # Всегда пересчитываем queryset, чтобы учесть изменения в target_role # (кеш может быть устаревшим после изменения планов в админке) queryset = SubscriptionPlan.objects.filter( is_active=True ) # Фильтруем по роли пользователя if user_role: # Показываем планы для всех или для конкретной роли queryset = queryset.filter( models.Q(target_role='all') | models.Q(target_role=user_role) ) else: # Для неавторизованных пользователей показываем только планы для всех queryset = queryset.filter(target_role='all') queryset = queryset.order_by('sort_order', 'price').prefetch_related( 'bulk_discounts', 'duration_discounts' ) # Оптимизация: для списка используем only() для ограничения полей if self.action == 'list': queryset = queryset.only( 'id', 'name', 'slug', 'description', 'price', 'price_per_student', 'currency', 'billing_period', 'subscription_type', 'trial_days', 'max_clients', 'max_lessons_per_month', 'max_storage_mb', 'max_video_minutes_per_month', 'target_role', 'is_active', 'is_featured', 'sort_order' ) # Обновляем кеш (но не полагаемся на него полностью) cache.set(cache_key, queryset, 300) # Кеш на 5 минут вместо 1 часа return queryset @action(detail=False, methods=['get']) def featured(self, request): """ Рекомендуемые тарифы. GET /api/subscriptions/plans/featured/ """ plans = self.get_queryset().filter(is_featured=True) serializer = self.get_serializer(plans, many=True) return Response(serializer.data) @action(detail=False, methods=['get']) def compare(self, request): """ Сравнение тарифов. GET /api/subscriptions/plans/compare/?plans=1,2,3 """ plan_ids = request.query_params.get('plans', '').split(',') plan_ids = [int(id.strip()) for id in plan_ids if id.strip().isdigit()] if not plan_ids: return Response( {'error': 'Укажите ID тарифов для сравнения (plans=1,2,3)'}, status=status.HTTP_400_BAD_REQUEST ) plans = self.get_queryset().filter(id__in=plan_ids) # Оптимизация: используем exists() вместо count() для проверки if plans.count() != len(plan_ids): return Response( {'error': 'Некоторые тарифы не найдены'}, status=status.HTTP_404_NOT_FOUND ) # Формируем данные для сравнения comparison_data = [] # Получаем все уникальные функции из всех тарифов all_features = set() for plan in plans: features = plan.get_features_list() all_features.update(features.keys()) # Для каждого тарифа формируем данные for plan in plans: features = plan.get_features_list() plan_data = { 'id': plan.id, 'name': plan.name, 'slug': plan.slug, 'description': plan.description, 'price': float(plan.price), 'price_per_student': float(plan.price_per_student) if plan.price_per_student else None, 'currency': plan.currency, 'billing_period': plan.billing_period, 'subscription_type': plan.subscription_type, 'trial_days': plan.trial_days, 'max_clients': plan.max_clients, 'max_lessons_per_month': plan.max_lessons_per_month, 'max_storage_mb': plan.max_storage_mb, 'max_video_minutes_per_month': plan.max_video_minutes_per_month, 'is_featured': plan.is_featured, 'features': {} } # Добавляем все функции (если есть в тарифе - значение, если нет - None) for feature_key in all_features: plan_data['features'][feature_key] = features.get(feature_key, None) comparison_data.append(plan_data) return Response({ 'plans': comparison_data, 'features_list': sorted(list(all_features)) }) @action(detail=False, methods=['get']) def by_type(self, request): """ Тарифы по типу подписки. GET /api/subscriptions/plans/by_type/?type=per_student GET /api/subscriptions/plans/by_type/?type=monthly """ subscription_type = request.query_params.get('type') if subscription_type not in ['per_student', 'monthly']: return Response( {'error': 'Неверный тип подписки. Используйте: per_student или monthly'}, status=status.HTTP_400_BAD_REQUEST ) plans = self.get_queryset().filter(subscription_type=subscription_type) serializer = self.get_serializer(plans, many=True) return Response(serializer.data) class SubscriptionViewSet(viewsets.ModelViewSet): """ ViewSet для подписок. list: Мои подписки create: Создать подписку retrieve: Детали подписки update: Обновить подписку cancel: Отменить подписку renew: Продлить подписку check_feature: Проверить доступ к функции check_limit: Проверить лимит """ permission_classes = [IsAuthenticated, IsSubscriptionOwner] def get_queryset(self): """Получение подписок пользователя.""" queryset = Subscription.objects.filter( user=self.request.user ).select_related('plan').order_by('-created_at') # Оптимизация: для списка используем only() для ограничения полей if self.action == 'list': queryset = queryset.only( 'id', 'user_id', 'plan_id', 'status', 'start_date', 'end_date', 'trial_end_date', 'student_count', 'duration_days', 'original_amount', 'discount_amount', 'final_amount', 'created_at', 'updated_at', 'cancelled_at' ) return queryset def get_serializer_class(self): """Выбор сериализатора.""" if self.action == 'create': return SubscriptionCreateSerializer return SubscriptionSerializer def create(self, request, *args, **kwargs): """Создание подписки.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) subscription = serializer.save() response_serializer = SubscriptionSerializer(subscription) return Response( response_serializer.data, status=status.HTTP_201_CREATED ) @action(detail=False, methods=['get']) def active(self, request): """ Активная подписка. При отсутствии — 200 и null (без 404), чтобы фронт не получал ошибку. GET /api/subscriptions/subscriptions/active/ """ subscription = Subscription.objects.filter( user=request.user, status__in=['trial', 'active'] ).select_related('plan', 'user').order_by('-end_date').first() if not subscription or not subscription.is_active(): return Response(None) serializer = SubscriptionSerializer(subscription, context={'request': request}) return Response(serializer.data) @action(detail=True, methods=['post']) def cancel(self, request, pk=None): """ Отменить подписку. POST /api/subscriptions/subscriptions/{id}/cancel/ """ subscription = self.get_object() if subscription.status in ['cancelled', 'expired']: return Response( {'error': 'Подписка уже отменена или истекла'}, status=status.HTTP_400_BAD_REQUEST ) subscription.cancel() serializer = SubscriptionSerializer(subscription, context={'request': request}) return Response(serializer.data) @action(detail=True, methods=['post']) def renew(self, request, pk=None): """ Продлить подписку. POST /api/subscriptions/subscriptions/{id}/renew/ """ subscription = self.get_object() if not subscription.is_active(): return Response( {'error': 'Подписка неактивна'}, status=status.HTTP_400_BAD_REQUEST ) subscription.renew() serializer = SubscriptionSerializer(subscription, context={'request': request}) return Response(serializer.data) @action(detail=False, methods=['post']) def activate_free(self, request): """ Активировать бесплатный тариф (цена плана = 0). Платёжный запрос не создаётся. POST /api/subscriptions/subscriptions/activate_free/ Body: { "plan_id": 1, "duration_days": 30, // опционально "student_count": 1 // опционально, для тарифа "за ученика" } """ from datetime import timedelta from decimal import Decimal plan_id = request.data.get('plan_id') if not plan_id: return Response( {'error': 'Требуется plan_id'}, status=status.HTTP_400_BAD_REQUEST ) try: plan = SubscriptionPlan.objects.get(id=plan_id, is_active=True) except SubscriptionPlan.DoesNotExist: return Response( {'error': 'Тариф не найден'}, status=status.HTTP_404_NOT_FOUND ) # Только для тарифов с нулевой ценой (monthly: price=0, per_student: price_per_student=0) base_price = Decimal(str(plan.price if plan.price is not None else 0)) price_per_student = Decimal(str(plan.price_per_student if getattr(plan, 'price_per_student', None) is not None else 0)) st = getattr(plan, 'subscription_type', None) or 'monthly' is_free = (st == 'per_student' and price_per_student == Decimal('0')) or (st != 'per_student' and base_price == Decimal('0')) if not is_free: return Response( {'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'}, status=status.HTTP_400_BAD_REQUEST ) requested_days = request.data.get('duration_days') duration_days = int(requested_days) if requested_days else plan.get_duration_days() student_count = int(request.data.get('student_count', 1)) if st == 'per_student' else 0 if st == 'per_student' and student_count <= 0: student_count = 1 try: subscription = SubscriptionService.create_subscription( user=request.user, plan=plan, student_count=student_count, duration_days=duration_days, start_date=timezone.now(), promo_code=None ) except Exception as e: import logging logging.getLogger(__name__).exception("activate_free create_subscription failed") return Response( {'error': str(e)}, status=status.HTTP_400_BAD_REQUEST ) subscription.status = 'active' subscription.save(update_fields=['status']) serializer = SubscriptionSerializer(subscription, context={'request': request}) return Response({ 'success': True, 'message': 'Подписка активирована', 'subscription': serializer.data, }, status=status.HTTP_201_CREATED) @action(detail=False, methods=['post']) def check_feature(self, request): """ Проверить доступ к функции. POST /api/subscriptions/subscriptions/check_feature/ Body: { "feature": "video_calls" } """ feature = request.data.get('feature') if not feature: return Response( {'error': 'Укажите feature'}, status=status.HTTP_400_BAD_REQUEST ) # Получаем активную подписку с оптимизацией subscription = Subscription.objects.filter( user=request.user, status__in=['trial', 'active'] ).select_related('plan').order_by('-end_date').first() if not subscription: return Response({ 'has_access': False, 'reason': 'Нет активной подписки' }) has_access = subscription.has_feature(feature) return Response({ 'has_access': has_access, 'subscription_id': subscription.id, 'plan': subscription.plan.name }) @action(detail=False, methods=['post']) def check_limit(self, request): """ Проверить лимит. POST /api/subscriptions/subscriptions/check_limit/ Body: { "limit_type": "lessons" } """ limit_type = request.data.get('limit_type') if not limit_type: return Response( {'error': 'Укажите limit_type'}, status=status.HTTP_400_BAD_REQUEST ) # Получаем активную подписку с оптимизацией subscription = Subscription.objects.filter( user=request.user, status__in=['trial', 'active'] ).select_related('plan').order_by('-end_date').first() if not subscription: return Response({ 'within_limit': False, 'reason': 'Нет активной подписки' }) within_limit = subscription.check_limit(limit_type) return Response({ 'within_limit': within_limit, 'subscription_id': subscription.id, 'plan': subscription.plan.name }) @action(detail=True, methods=['post']) def change_plan(self, request, pk=None): """ Сменить тарифный план подписки. POST /api/subscriptions/subscriptions/{id}/change_plan/ Body: { "plan_id": 1, "student_count": 5, # опционально, для типа per_student "duration_days": 90, # опционально "promo_code": "PROMO123" # опционально } """ subscription = self.get_object() plan_id = request.data.get('plan_id') if not plan_id: return Response( {'error': 'Требуется plan_id'}, status=status.HTTP_400_BAD_REQUEST ) try: new_plan = SubscriptionPlan.objects.get(id=plan_id, is_active=True) except SubscriptionPlan.DoesNotExist: return Response( {'error': 'Тарифный план не найден'}, status=status.HTTP_404_NOT_FOUND ) student_count = request.data.get('student_count') duration_days = request.data.get('duration_days') promo_code_str = request.data.get('promo_code') # Валидация и применение промокода promo_code = None if promo_code_str: promo_result = PromoCodeService.validate_promo_code(promo_code_str, request.user) if not promo_result['valid']: return Response( {'error': f'Промокод невалиден: {promo_result["error"]}'}, status=status.HTTP_400_BAD_REQUEST ) promo_code = promo_result['promo_code'] try: # Меняем тариф updated_subscription = SubscriptionService.change_plan( subscription=subscription, new_plan=new_plan, student_count=student_count, duration_days=duration_days, promo_code=promo_code ) serializer = SubscriptionSerializer(updated_subscription) return Response(serializer.data) except ValueError as e: return Response( {'error': str(e)}, status=status.HTTP_400_BAD_REQUEST ) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Error changing plan: {e}") return Response( {'error': 'Ошибка при смене тарифа'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) class PaymentViewSet(viewsets.ModelViewSet): """ ViewSet для платежей. list: Мои платежи create: Создать платеж retrieve: Детали платежа history: История изменений """ permission_classes = [IsAuthenticated] def get_queryset(self): """Получение платежей пользователя.""" queryset = Payment.objects.filter( user=self.request.user ).select_related('subscription', 'subscription__plan').order_by('-created_at') # Оптимизация: для списка используем only() для ограничения полей if self.action == 'list': queryset = queryset.only( 'id', 'user_id', 'subscription_id', 'amount', 'currency', 'status', 'payment_method', 'description', 'external_id', 'created_at', 'paid_at' ) return queryset def get_serializer_class(self): """Выбор сериализатора.""" if self.action == 'create': return PaymentCreateSerializer return PaymentSerializer def create(self, request, *args, **kwargs): """Создание платежа.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) payment = serializer.save() # Инициализация платежа через платежную систему payment_service = PaymentService() payment_data = payment_service.create_payment( payment=payment, return_url=request.data.get('return_url') ) response_serializer = PaymentSerializer(payment) data = response_serializer.data data['payment_url'] = payment_data.get('payment_url') return Response(data, status=status.HTTP_201_CREATED) @action(detail=True, methods=['get']) def changes(self, request, pk=None): """ История изменений платежа. GET /api/subscriptions/payments/{id}/changes/ """ payment = self.get_object() history = payment.history.all() serializer = PaymentHistorySerializer(history, many=True) return Response(serializer.data) @action(detail=False, methods=['get']) def history(self, request): """ История платежей пользователя. GET /api/subscriptions/payments/history/ """ # Оптимизация: используем select_related для subscription и plan payments = self.get_queryset().select_related('subscription', 'subscription__plan') return Response({ 'payments': [ { 'id': p.id, 'amount': float(p.amount), 'currency': p.currency, 'status': p.status, 'payment_method': p.payment_method, 'description': p.description, 'plan_name': p.subscription.plan.name if p.subscription and p.subscription.plan else None, 'created_at': format_datetime_for_user(p.created_at, request.user.timezone) if p.created_at else None, 'paid_at': format_datetime_for_user(p.paid_at, request.user.timezone) if p.paid_at else None, } for p in payments ] }) @action(detail=False, methods=['get']) def successful(self, request): """ Успешные платежи. GET /api/subscriptions/payments/successful/ """ payments = self.get_queryset().filter(status='succeeded') serializer = PaymentSerializer(payments, many=True) return Response(serializer.data) @action(detail=False, methods=['get']) def pending(self, request): """ Ожидающие платежи. GET /api/subscriptions/payments/pending/ """ payments = self.get_queryset().filter(status='pending') serializer = PaymentSerializer(payments, many=True) return Response(serializer.data) @action(detail=False, methods=['post']) def create_extra_payment(self, request): """ Создать дополнительный платеж (например, за дополнительных учеников). POST /api/subscriptions/payments/create_extra_payment/ Body: { "amount": 500.00, "extra_students": 2, "description": "Доплата за 2 дополнительных учеников" } """ from .yookassa_service import yookassa_service from django.conf import settings from decimal import Decimal import logging import uuid logger = logging.getLogger(__name__) amount = request.data.get('amount') extra_students = request.data.get('extra_students', 0) description = request.data.get('description', 'Дополнительный платеж') return_url = request.data.get('return_url') or f"{settings.FRONTEND_URL}/profile/payments" if not amount: return Response( {'error': 'Требуется amount'}, status=status.HTTP_400_BAD_REQUEST ) try: amount = Decimal(str(amount)) if amount <= 0: return Response( {'error': 'Сумма должна быть больше 0'}, status=status.HTTP_400_BAD_REQUEST ) # Создаем платеж в БД payment = Payment.objects.create( user=request.user, amount=amount, currency='RUB', status='pending', provider='yookassa', payment_type='extra', description=description, metadata={ 'extra_students': extra_students, 'type': 'extra_payment' } ) # Создаем платеж в YooKassa yookassa_payment = yookassa_service.create_payment( amount=float(amount), currency='RUB', description=description, return_url=return_url, metadata={ 'payment_id': str(payment.id), 'user_id': str(request.user.id), 'extra_students': extra_students, 'type': 'extra_payment' } ) if yookassa_payment: # Обновляем платеж payment.external_id = yookassa_payment.id payment.provider_response = { 'yookassa_payment': yookassa_payment.id, 'status': yookassa_payment.status, 'created_at': str(yookassa_payment.created_at) } payment.save() # Получаем URL для оплаты confirmation_url = yookassa_payment.confirmation.confirmation_url logger.info(f"Extra payment created: {payment.id}, YooKassa ID: {yookassa_payment.id}") return Response({ 'success': True, 'payment_id': payment.id, 'confirmation_url': confirmation_url, 'amount': float(amount), 'currency': 'RUB' }, status=status.HTTP_201_CREATED) else: payment.status = 'failed' payment.save() return Response( {'error': 'Не удалось создать платеж в YooKassa'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) except Exception as e: logger.error(f"Error creating extra payment: {e}", exc_info=True) return Response( {'error': f'Ошибка при создании платежа: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @action(detail=False, methods=['post']) def create_payment(self, request): """ Создать платеж для подписки через ЮKassa с поддержкой промокодов и бонусов. POST /api/subscriptions/payments/create_payment/ Body: { "plan_id": 1, "student_count": 5, # опционально, для тарифов "за ученика" "duration_days": 90, # опционально, период оплаты (30, 90, 180, 365) "subscription_id": 1, # опционально, для смены тарифа или продления "promo_code": "PROMO123", # опционально "use_bonus": 50.00, # опционально, сумма бонусов для использования "return_url": "http://localhost:3000/payment/success" } """ from .yookassa_service import yookassa_service from django.conf import settings from decimal import Decimal import logging logger = logging.getLogger(__name__) plan_id = request.data.get('plan_id') student_count = request.data.get('student_count') duration_days = request.data.get('duration_days') subscription_id = request.data.get('subscription_id') promo_code = request.data.get('promo_code') use_bonus = request.data.get('use_bonus', 0) return_url = request.data.get('return_url') or f"{settings.FRONTEND_URL}/payment/success" if not plan_id: return Response( {'error': 'Требуется plan_id'}, status=status.HTTP_400_BAD_REQUEST ) try: # Получаем план подписки с оптимизацией plan = SubscriptionPlan.objects.select_related().get(id=plan_id, is_active=True) # Проверяем доступность тарифа (акция) can_use, error_message = plan.can_be_used(request.user) if not can_use: return Response( {'error': error_message}, status=status.HTTP_400_BAD_REQUEST ) # Определяем длительность if duration_days: duration_days = int(duration_days) # Проверяем доступность периода if not plan.is_duration_available(duration_days): available = plan.get_available_durations() return Response( {'error': f'Период {duration_days} дней недоступен для этого тарифа. Доступные периоды: {", ".join(map(str, available))}'}, status=status.HTTP_400_BAD_REQUEST ) else: # По умолчанию используем duration_days из тарифного плана duration_days = plan.get_duration_days() # Определяем количество учеников для тарифов "за ученика" if plan.subscription_type == 'per_student': if student_count is None or student_count <= 0: return Response( {'error': 'Для тарифа "За ученика" необходимо указать количество учеников'}, status=status.HTTP_400_BAD_REQUEST ) student_count = int(student_count) else: student_count = 0 # Валидация промокода если указан promo_obj = None if promo_code: from apps.referrals.models import PromoCode, PromoCodeUsage try: promo_obj = PromoCode.objects.get(code=promo_code.upper()) # Проверяем валидность is_valid, message = promo_obj.is_valid() if not is_valid: return Response( {'error': f'Промокод невалиден: {message}'}, status=status.HTTP_400_BAD_REQUEST ) # Проверяем применимость if not promo_obj.can_apply_to_plan(plan): return Response( {'error': 'Промокод не применим к этому плану'}, status=status.HTTP_400_BAD_REQUEST ) except PromoCode.DoesNotExist: return Response( {'error': 'Промокод не найден'}, status=status.HTTP_404_NOT_FOUND ) # Рассчитываем цену через сервис (с учетом промокода) price_data = SubscriptionService.calculate_subscription_price( plan=plan, student_count=student_count, duration_days=duration_days, promo_code=promo_obj ) original_price = price_data['original_amount'] final_price = price_data['final_amount'] discount_info = {} # Добавляем информацию о скидке за длительность if 'duration_discount' in price_data: discount_info['duration'] = price_data['duration_discount'] # Добавляем информацию о промокоде if promo_obj: discount_info['promo'] = { 'code': promo_obj.code, 'discount': float(price_data['discount_amount']) } # Применяем бонусы если указаны bonus_used = Decimal('0') bonus_account = None if use_bonus and Decimal(str(use_bonus)) > 0: from apps.referrals.models import BonusAccount, UserReferralProfile try: bonus_account = request.user.bonus_account referral_profile = request.user.referral_profile use_bonus = Decimal(str(use_bonus)) # Проверяем баланс if use_bonus > bonus_account.balance: return Response( {'error': 'Недостаточно бонусов на счете'}, status=status.HTTP_400_BAD_REQUEST ) # Проверяем лимит использования бонусов max_bonus_percent = referral_profile.get_max_bonus_payment_percent() max_bonus_amount = original_price * (Decimal(str(max_bonus_percent)) / 100) if use_bonus > max_bonus_amount: return Response( {'error': f'Максимум {max_bonus_percent}% ({float(max_bonus_amount)} ₽) можно оплатить бонусами'}, status=status.HTTP_400_BAD_REQUEST ) # Проверяем что не превышаем итоговую цену if use_bonus > final_price: use_bonus = final_price bonus_used = use_bonus final_price -= bonus_used discount_info['bonus'] = { 'used': float(bonus_used), 'remaining': float(bonus_account.balance - bonus_used) } except (BonusAccount.DoesNotExist, UserReferralProfile.DoesNotExist): return Response( {'error': 'Бонусный счет не найден'}, status=status.HTTP_404_NOT_FOUND ) # Проверяем, нужно ли сменить тариф subscription = None if subscription_id: try: subscription = Subscription.objects.get(id=subscription_id, user=request.user) # Если план отличается, меняем тариф if subscription.plan.id != plan.id: subscription = SubscriptionService.change_plan( subscription=subscription, new_plan=plan, student_count=student_count if plan.subscription_type == 'per_student' else None, duration_days=duration_days, promo_code=promo_obj if promo_obj else None ) # После смены тарифа цена уже рассчитана, используем её original_price = float(subscription.original_amount) final_price = float(subscription.final_amount) except Subscription.DoesNotExist: return Response( {'error': 'Подписка не найдена'}, status=status.HTTP_404_NOT_FOUND ) # Если итоговая цена = 0, активируем подписку без оплаты if final_price <= 0: if not subscription: subscription = SubscriptionService.create_subscription( user=request.user, plan=plan, student_count=student_count, duration_days=duration_days, promo_code=promo_obj if promo_obj else None ) # Списываем бонусы if bonus_used > 0 and bonus_account: bonus_account.spend_bonus( bonus_used, reason=f'Оплата подписки {plan.name}' ) # Отмечаем использование промокода if promo_obj: from apps.referrals.models import PromoCodeUsage promo_obj.use() PromoCodeUsage.objects.create( user=request.user, promo_code=promo_obj, original_amount=original_price, discount_amount=discount_info.get('promo', {}).get('discount', 0), final_amount=0 ) return Response({ 'success': True, 'free_activation': True, 'message': 'Подписка активирована бесплатно', 'original_price': float(original_price), 'discount_info': discount_info, 'final_price': 0 }) # Создаем или обновляем подписку if not subscription: subscription = SubscriptionService.create_subscription( user=request.user, plan=plan, student_count=student_count, duration_days=duration_days, promo_code=promo_obj if promo_obj else None ) else: # Обновляем подписку с новой длительностью и количеством учеников subscription.duration_days = duration_days if plan.subscription_type == 'per_student': subscription.student_count = student_count subscription.original_amount = original_price subscription.discount_amount = price_data.get('discount_amount', 0) subscription.final_amount = final_price subscription.save() # Создаем платеж в ЮKassa yookassa_payment = yookassa_service.create_payment( amount=final_price, description=f"Оплата подписки: {plan.name} ({duration_days} дней)" + (f", {student_count} учеников" if student_count > 0 else ""), return_url=return_url, metadata={ 'subscription_id': subscription.id, 'user_id': request.user.id, 'plan_id': plan.id, 'duration_days': duration_days, 'student_count': student_count if plan.subscription_type == 'per_student' else None, 'promo_code': promo_code if promo_code else None, 'bonus_used': float(bonus_used) if bonus_used > 0 else None, } ) # Сохраняем платеж в БД payment = Payment.objects.create( user=request.user, subscription=subscription, amount=final_price, currency='RUB', status='pending', payment_method='yookassa', external_id=yookassa_payment['id'], description=f"Оплата подписки: {plan.name}", provider_response={ 'confirmation_url': yookassa_payment['confirmation_url'], 'yookassa_status': yookassa_payment['status'], 'metadata': yookassa_payment['metadata'], 'original_price': float(original_price), 'discount_info': discount_info, }, ) # Списываем бонусы (будут возвращены если платеж отменится) if bonus_used > 0: bonus_account.spend_bonus( bonus_used, reason=f'Оплата подписки {plan.name} (платеж #{payment.id})' ) # Отмечаем использование промокода if promo_obj: from apps.referrals.models import PromoCodeUsage promo_obj.use() PromoCodeUsage.objects.create( user=request.user, promo_code=promo_obj, payment=payment, original_amount=original_price, discount_amount=discount_info.get('promo', {}).get('discount', 0), final_amount=final_price ) logger.info(f"Payment created: {payment.id} for user {request.user.id}, price: {final_price}") return Response({ 'success': True, 'payment_id': payment.id, 'external_id': payment.external_id, 'confirmation_url': yookassa_payment['confirmation_url'], 'original_price': float(original_price), 'discount_info': discount_info, 'final_price': float(final_price), }, status=status.HTTP_201_CREATED) except SubscriptionPlan.DoesNotExist: return Response( {'error': 'План подписки не найден'}, status=status.HTTP_404_NOT_FOUND ) except Exception as e: logger.error(f"Error creating payment: {e}") return Response( {'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @action(detail=True, methods=['get']) def check_status(self, request, pk=None): """ Проверить статус платежа в ЮKassa. GET /api/subscriptions/payments/{id}/check_status/ """ from .yookassa_service import yookassa_service import logging logger = logging.getLogger(__name__) try: # Оптимизация: используем select_related для subscription и plan payment = Payment.objects.select_related('subscription', 'subscription__plan').get(pk=pk) except Payment.DoesNotExist: return Response( {'error': 'Платеж не найден'}, status=status.HTTP_404_NOT_FOUND ) try: # Получаем актуальный статус из ЮKassa yookassa_payment = yookassa_service.get_payment(payment.external_id) # Обновляем статус в БД old_status = payment.status yookassa_status = yookassa_payment['status'] if yookassa_status == 'succeeded' and old_status != 'succeeded': payment.status = 'succeeded' payment.paid_at = timezone.now() payment.provider_response['payment_method'] = yookassa_payment.get('payment_method') payment.save() # Активируем подписку if payment.subscription: payment.subscription.status = 'active' payment.subscription.start_date = timezone.now() payment.subscription.save() elif yookassa_status == 'canceled': payment.status = 'cancelled' payment.save() return Response({ 'id': payment.id, 'status': payment.status, 'external_id': payment.external_id, 'amount': float(payment.amount), 'created_at': format_datetime_for_user(payment.created_at, request.user.timezone) if payment.created_at else None, 'paid_at': format_datetime_for_user(payment.paid_at, request.user.timezone) if payment.paid_at else None, }) except Exception as e: logger.error(f"Error checking payment status: {e}") return Response( {'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) class WebhookViewSet(viewsets.ViewSet): """ ViewSet для webhook от платежных систем. yookassa: Webhook от ЮKassa stripe: Webhook от Stripe """ permission_classes = [AllowAny] @action(detail=False, methods=['post']) def yookassa(self, request): """ Webhook от ЮKassa. POST /api/subscriptions/webhooks/yookassa/ """ import logging logger = logging.getLogger(__name__) try: # Получаем данные из запроса data = request.data event_type = data.get('event') payment_object = data.get('object', {}) payment_id = payment_object.get('id') if not payment_id: return Response( {'error': 'Payment ID not found'}, status=status.HTTP_400_BAD_REQUEST ) # Находим платеж в БД с оптимизацией try: payment = Payment.objects.select_related('subscription', 'subscription__plan', 'user').get(external_id=payment_id) except Payment.DoesNotExist: logger.warning(f"Payment {payment_id} not found in database") return Response({'status': 'ok'}, status=status.HTTP_200_OK) # Обрабатываем событие if event_type == 'payment.succeeded': payment_method = payment_object.get('payment_method', {}).get('type') payment.status = 'succeeded' payment.paid_at = timezone.now() payment.provider_response['payment_method'] = payment_method payment.provider_response['webhook_data'] = data payment.save() # Активируем подписку if payment.subscription: payment.subscription.status = 'active' payment.subscription.start_date = timezone.now() payment.subscription.save() logger.info(f"Payment {payment_id} succeeded") elif event_type == 'payment.canceled': cancellation_details = payment_object.get('cancellation_details', {}) reason = cancellation_details.get('reason', '') payment.status = 'cancelled' payment.provider_response['cancellation_reason'] = reason payment.provider_response['webhook_data'] = data payment.save() logger.info(f"Payment {payment_id} canceled: {reason}") elif event_type == 'payment.waiting_for_capture': payment.status = 'processing' payment.save() return Response({'status': 'ok'}, status=status.HTTP_200_OK) except Exception as e: logger.error(f"Error processing webhook: {e}") return Response( {'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @action(detail=False, methods=['post']) def stripe(self, request): """ Webhook от Stripe. POST /api/subscriptions/webhooks/stripe/ """ payment_service = PaymentService() result = payment_service.process_stripe_webhook(request.data) if result['success']: return Response({'status': 'ok'}) else: return Response( {'error': result.get('error')}, status=status.HTTP_400_BAD_REQUEST )