""" API views для платежей с интеграцией ЮKassa. """ import logging 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.conf import settings from django.utils import timezone from .models import Payment, Subscription from .yookassa_service import yookassa_service logger = logging.getLogger(__name__) class PaymentViewSet(viewsets.ViewSet): """ ViewSet для управления платежами через ЮKassa. """ permission_classes = [IsAuthenticated] @action(detail=False, methods=['post']) def create_payment(self, request): """ Создать платеж для подписки. POST /api/subscriptions/payments/create_payment/ Body: { "plan_id": 1, "student_count": 5, // для типа "за ученика" "duration_days": 30, // 30, 90, 180, 365 "promo_code": "WELCOME10", // опционально "return_url": "http://localhost:3000/payment/success" } """ from .models import SubscriptionPlan from .services import SubscriptionService, PromoCodeService from datetime import timedelta plan_id = request.data.get('plan_id') return_url = request.data.get('return_url') or f"{settings.FRONTEND_URL}/payment/success" student_count = request.data.get('student_count', 0) duration_days = request.data.get('duration_days', 30) promo_code_str = request.data.get('promo_code') is_renewal = request.data.get('is_renewal', False) is_add_students = request.data.get('is_add_students', False) subscription_id = request.data.get('subscription_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) # Валидация для типа "за ученика" if plan.subscription_type == 'per_student': if not student_count or student_count <= 0: return Response( {'error': 'Для подписки "за ученика" необходимо указать количество учеников'}, status=status.HTTP_400_BAD_REQUEST ) # Валидация промокода если указан 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': promo_result['error']}, status=status.HTTP_400_BAD_REQUEST ) promo_code = promo_result['promo_code'] # Если это продление, получаем существующую подписку existing_subscription = None remaining_days = None current_student_count = 0 actual_students_count = 0 if is_renewal and subscription_id: try: existing_subscription = Subscription.objects.get( id=subscription_id, user=request.user, plan=plan ) # Рассчитываем оставшиеся дни if existing_subscription.end_date: now = timezone.now() if existing_subscription.end_date > now: delta = existing_subscription.end_date - now remaining_days = max(0, delta.days) # Получаем текущее количество учеников из подписки current_student_count = existing_subscription.student_count or 0 # Получаем фактическое количество учеников в системе from apps.users.models import Client actual_students_count = Client.objects.filter(mentors=request.user).count() # Если подписка истекла и пользователь не указал количество учеников, # используем фактическое количество (но не меньше текущего оплаченного) if plan.subscription_type == 'per_student' and not student_count: # Если подписка истекла, используем фактическое количество студентов if not existing_subscription.is_active(): student_count = max(actual_students_count, current_student_count) else: # Если подписка активна, используем текущее оплаченное количество student_count = current_student_count if current_student_count > 0 else actual_students_count # Если пользователь указал количество меньше фактического, корректируем if plan.subscription_type == 'per_student' and student_count < actual_students_count: student_count = actual_students_count except Subscription.DoesNotExist: pass # Рассчитываем цену с учетом оставшихся дней и текущего количества учеников price_data = SubscriptionService.calculate_subscription_price( plan=plan, student_count=student_count, duration_days=duration_days, promo_code=promo_code, remaining_days=remaining_days, current_student_count=current_student_count ) final_amount = price_data['final_amount'] # Если это добавление учеников в текущий период if is_add_students and existing_subscription: # Обновляем только количество учеников, не продлеваем подписку if plan.subscription_type == 'per_student': existing_subscription.student_count = student_count existing_subscription.unpaid_students_count = 0 existing_subscription.pending_payment_amount = 0 # Обновляем суммы платежа existing_subscription.original_amount = price_data['original_amount'] existing_subscription.discount_amount = price_data['discount_amount'] existing_subscription.final_amount = price_data['final_amount'] if promo_code: existing_subscription.promo_code = promo_code existing_subscription.save() subscription = existing_subscription # Если это продление, обновляем существующую подписку elif is_renewal and existing_subscription: # Обновляем количество учеников если изменилось if plan.subscription_type == 'per_student': existing_subscription.student_count = student_count existing_subscription.unpaid_students_count = 0 existing_subscription.pending_payment_amount = 0 # Продлеваем подписку: добавляем дни к текущей end_date if existing_subscription.end_date: # Если end_date в будущем, добавляем к нему if existing_subscription.end_date > timezone.now(): existing_subscription.end_date = existing_subscription.end_date + timedelta(days=duration_days) else: # Если подписка уже истекла, начинаем с текущей даты existing_subscription.start_date = timezone.now() existing_subscription.end_date = timezone.now() + timedelta(days=duration_days) else: existing_subscription.end_date = timezone.now() + timedelta(days=duration_days) existing_subscription.duration_days = duration_days existing_subscription.status = 'active' existing_subscription.original_amount = price_data['original_amount'] existing_subscription.discount_amount = price_data['discount_amount'] existing_subscription.final_amount = price_data['final_amount'] if promo_code: existing_subscription.promo_code = promo_code existing_subscription.save() subscription = existing_subscription else: # Создаем новую подписку subscription = SubscriptionService.create_subscription( user=request.user, plan=plan, student_count=student_count, duration_days=duration_days, start_date=timezone.now(), promo_code=promo_code ) # Создаем платеж в ЮKassa yookassa_payment = yookassa_service.create_payment( amount=float(final_amount), description=f"Оплата подписки: {plan.name}" + (f" ({student_count} учеников)" if plan.subscription_type == 'per_student' else ''), return_url=return_url, metadata={ 'subscription_id': subscription.id, 'user_id': request.user.id, 'plan_id': plan.id, 'student_count': student_count if plan.subscription_type == 'per_student' else None, 'duration_days': duration_days, } ) # Сохраняем платеж в БД payment = Payment.objects.create( user=request.user, subscription=subscription, amount=final_amount, currency='RUB', status='pending', payment_method='yookassa', external_id=yookassa_payment['id'], description=f"Оплата подписки: {plan.name}" + (f" ({student_count} учеников)" if plan.subscription_type == 'per_student' else ''), provider_response={ 'confirmation_url': yookassa_payment['confirmation_url'], 'yookassa_status': yookassa_payment['status'], 'metadata': yookassa_payment['metadata'], }, ) logger.info(f"Payment created: {payment.id} for user {request.user.id}, amount: {final_amount}") return Response({ 'success': True, 'payment_id': payment.id, 'external_id': payment.external_id, 'confirmation_url': yookassa_payment['confirmation_url'], 'amount': float(payment.amount), }, 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}", exc_info=True) return Response( {'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @action(detail=True, methods=['get']) def check_status(self, request, pk=None): """ Проверить статус платежа. GET /api/subscriptions/payments/{id}/check_status/ """ try: payment = Payment.objects.get(id=pk, user=request.user) 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': payment.created_at, 'paid_at': payment.paid_at, }) except Exception as e: logger.error(f"Error checking payment status: {e}") return Response( {'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @action(detail=False, methods=['post'], permission_classes=[AllowAny]) def webhook(self, request): """ Webhook для получения уведомлений от ЮKassa. POST /api/subscriptions/payments/webhook/ """ 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.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=['get']) def history(self, request): """ История платежей пользователя. GET /api/subscriptions/payments/history/ """ payments = Payment.objects.filter(user=request.user).select_related( 'subscription', 'subscription__plan' ).order_by('-created_at') 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 else None, 'created_at': p.created_at, 'paid_at': p.paid_at, } for p in payments ] })