uchill/backend/apps/subscriptions/views.py

1223 lines
51 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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
)