uchill/backend/apps/subscriptions/payment_views.py

395 lines
19 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 для платежей с интеграцией Ю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
]
})