395 lines
19 KiB
Python
395 lines
19 KiB
Python
"""
|
||
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
|
||
]
|
||
})
|
||
|
||
|