1223 lines
51 KiB
Python
1223 lines
51 KiB
Python
"""
|
||
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
|
||
)
|
||
duration_days = int(request.data.get('duration_days', 30))
|
||
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
|
||
available = plan.get_available_durations() if hasattr(plan, 'get_available_durations') else [30]
|
||
if not available:
|
||
available = [30]
|
||
if duration_days not in available:
|
||
duration_days = available[0]
|
||
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:
|
||
# По умолчанию используем первый доступный период или 30 дней
|
||
available_durations = plan.get_available_durations()
|
||
duration_days = available_durations[0] if available_durations else 30
|
||
|
||
# Определяем количество учеников для тарифов "за ученика"
|
||
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
|
||
)
|