721 lines
28 KiB
Python
721 lines
28 KiB
Python
"""
|
||
Сервисы для работы с платежными системами.
|
||
"""
|
||
from django.conf import settings
|
||
from django.utils import timezone
|
||
import requests
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class PaymentService:
|
||
"""Сервис для работы с платежами."""
|
||
|
||
def create_payment(self, payment, return_url=None):
|
||
"""
|
||
Создать платеж в платежной системе.
|
||
|
||
Args:
|
||
payment: объект Payment
|
||
return_url: URL для возврата после оплаты
|
||
|
||
Returns:
|
||
dict: данные платежа включая payment_url
|
||
"""
|
||
if payment.payment_method == 'yookassa':
|
||
return self._create_yookassa_payment(payment, return_url)
|
||
elif payment.payment_method == 'stripe':
|
||
return self._create_stripe_payment(payment, return_url)
|
||
else:
|
||
return {'payment_url': None}
|
||
|
||
def _create_yookassa_payment(self, payment, return_url):
|
||
"""Создать платеж в ЮKassa."""
|
||
try:
|
||
from yookassa import Configuration, Payment as YooPayment
|
||
|
||
Configuration.account_id = settings.YOOKASSA_SHOP_ID
|
||
Configuration.secret_key = settings.YOOKASSA_SECRET_KEY
|
||
|
||
# Создаем платеж
|
||
yoo_payment = YooPayment.create({
|
||
"amount": {
|
||
"value": str(payment.amount),
|
||
"currency": payment.currency
|
||
},
|
||
"confirmation": {
|
||
"type": "redirect",
|
||
"return_url": return_url or settings.FRONTEND_URL
|
||
},
|
||
"capture": True,
|
||
"description": payment.description,
|
||
"metadata": {
|
||
"payment_id": str(payment.uuid)
|
||
}
|
||
}, payment.uuid)
|
||
|
||
# Сохраняем внешний ID
|
||
payment.external_id = yoo_payment.id
|
||
payment.status = 'processing'
|
||
payment.provider_response = yoo_payment.json()
|
||
payment.save()
|
||
|
||
# Логируем историю
|
||
from .models import PaymentHistory
|
||
PaymentHistory.objects.create(
|
||
payment=payment,
|
||
status='processing',
|
||
message='Платеж создан в ЮKassa',
|
||
data=yoo_payment.json()
|
||
)
|
||
|
||
return {
|
||
'payment_url': yoo_payment.confirmation.confirmation_url,
|
||
'external_id': yoo_payment.id
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error creating YooKassa payment: {e}")
|
||
payment.mark_as_failed(str(e))
|
||
return {'payment_url': None, 'error': str(e)}
|
||
|
||
def _create_stripe_payment(self, payment, return_url):
|
||
"""Создать платеж в Stripe."""
|
||
try:
|
||
import stripe
|
||
|
||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||
|
||
# Создаем сессию оплаты
|
||
session = stripe.checkout.Session.create(
|
||
payment_method_types=['card'],
|
||
line_items=[{
|
||
'price_data': {
|
||
'currency': payment.currency.lower(),
|
||
'unit_amount': int(payment.amount * 100), # в копейках
|
||
'product_data': {
|
||
'name': payment.subscription.plan.name,
|
||
'description': payment.description,
|
||
},
|
||
},
|
||
'quantity': 1,
|
||
}],
|
||
mode='payment',
|
||
success_url=return_url or settings.FRONTEND_URL,
|
||
cancel_url=return_url or settings.FRONTEND_URL,
|
||
metadata={
|
||
'payment_id': str(payment.uuid)
|
||
}
|
||
)
|
||
|
||
# Сохраняем внешний ID
|
||
payment.external_id = session.id
|
||
payment.status = 'processing'
|
||
payment.provider_response = {'session_id': session.id}
|
||
payment.save()
|
||
|
||
# Логируем историю
|
||
from .models import PaymentHistory
|
||
PaymentHistory.objects.create(
|
||
payment=payment,
|
||
status='processing',
|
||
message='Платеж создан в Stripe',
|
||
data={'session_id': session.id}
|
||
)
|
||
|
||
return {
|
||
'payment_url': session.url,
|
||
'external_id': session.id
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error creating Stripe payment: {e}")
|
||
payment.mark_as_failed(str(e))
|
||
return {'payment_url': None, 'error': str(e)}
|
||
|
||
def process_yookassa_webhook(self, data):
|
||
"""
|
||
Обработать webhook от ЮKassa.
|
||
|
||
Args:
|
||
data: данные webhook
|
||
|
||
Returns:
|
||
dict: результат обработки
|
||
"""
|
||
try:
|
||
from .models import Payment, PaymentHistory
|
||
|
||
event_type = data.get('event')
|
||
payment_data = data.get('object', {})
|
||
|
||
# Находим платеж
|
||
payment_uuid = payment_data.get('metadata', {}).get('payment_id')
|
||
if not payment_uuid:
|
||
return {'success': False, 'error': 'payment_id not found'}
|
||
|
||
try:
|
||
payment = Payment.objects.get(uuid=payment_uuid)
|
||
except Payment.DoesNotExist:
|
||
return {'success': False, 'error': 'Payment not found'}
|
||
|
||
# Обрабатываем событие
|
||
if event_type == 'payment.succeeded':
|
||
payment.mark_as_succeeded()
|
||
|
||
PaymentHistory.objects.create(
|
||
payment=payment,
|
||
status='succeeded',
|
||
message='Платеж успешно выполнен',
|
||
data=data
|
||
)
|
||
|
||
elif event_type == 'payment.canceled':
|
||
payment.mark_as_failed('Платеж отменен')
|
||
|
||
PaymentHistory.objects.create(
|
||
payment=payment,
|
||
status='cancelled',
|
||
message='Платеж отменен',
|
||
data=data
|
||
)
|
||
|
||
return {'success': True}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error processing YooKassa webhook: {e}")
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
def process_stripe_webhook(self, data):
|
||
"""
|
||
Обработать webhook от Stripe.
|
||
|
||
Args:
|
||
data: данные webhook
|
||
|
||
Returns:
|
||
dict: результат обработки
|
||
"""
|
||
try:
|
||
from .models import Payment, PaymentHistory
|
||
|
||
event_type = data.get('type')
|
||
session_data = data.get('data', {}).get('object', {})
|
||
|
||
# Находим платеж
|
||
payment_uuid = session_data.get('metadata', {}).get('payment_id')
|
||
if not payment_uuid:
|
||
return {'success': False, 'error': 'payment_id not found'}
|
||
|
||
try:
|
||
payment = Payment.objects.get(uuid=payment_uuid)
|
||
except Payment.DoesNotExist:
|
||
return {'success': False, 'error': 'Payment not found'}
|
||
|
||
# Обрабатываем событие
|
||
if event_type == 'checkout.session.completed':
|
||
payment.mark_as_succeeded()
|
||
|
||
PaymentHistory.objects.create(
|
||
payment=payment,
|
||
status='succeeded',
|
||
message='Платеж успешно выполнен',
|
||
data=data
|
||
)
|
||
|
||
elif event_type == 'checkout.session.expired':
|
||
payment.mark_as_failed('Сессия истекла')
|
||
|
||
PaymentHistory.objects.create(
|
||
payment=payment,
|
||
status='failed',
|
||
message='Сессия истекла',
|
||
data=data
|
||
)
|
||
|
||
return {'success': True}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error processing Stripe webhook: {e}")
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
|
||
class PromoCodeService:
|
||
"""Сервис для работы с промокодами."""
|
||
|
||
@staticmethod
|
||
def validate_promo_code(code, user=None):
|
||
"""
|
||
Валидация промокода.
|
||
|
||
Args:
|
||
code: код промокода
|
||
user: пользователь (опционально)
|
||
|
||
Returns:
|
||
dict: {
|
||
'valid': bool,
|
||
'promo_code': PromoCode или None,
|
||
'error': str или None
|
||
}
|
||
"""
|
||
from .models import PromoCode
|
||
|
||
try:
|
||
promo_code = PromoCode.objects.get(code=code.upper(), is_active=True)
|
||
except PromoCode.DoesNotExist:
|
||
return {
|
||
'valid': False,
|
||
'promo_code': None,
|
||
'error': 'Промокод не найден'
|
||
}
|
||
|
||
is_valid, error = promo_code.is_valid(user)
|
||
|
||
if not is_valid:
|
||
return {
|
||
'valid': False,
|
||
'promo_code': promo_code,
|
||
'error': error
|
||
}
|
||
|
||
return {
|
||
'valid': True,
|
||
'promo_code': promo_code,
|
||
'error': None
|
||
}
|
||
|
||
@staticmethod
|
||
def apply_promo_code(promo_code, amount):
|
||
"""
|
||
Применить промокод к сумме.
|
||
|
||
Args:
|
||
promo_code: объект PromoCode
|
||
amount: исходная сумма
|
||
|
||
Returns:
|
||
dict: {
|
||
'original_amount': Decimal,
|
||
'discount_amount': Decimal,
|
||
'final_amount': Decimal
|
||
}
|
||
"""
|
||
discount_amount = promo_code.calculate_discount(amount)
|
||
final_amount = max(amount - discount_amount, 0)
|
||
|
||
return {
|
||
'original_amount': amount,
|
||
'discount_amount': discount_amount,
|
||
'final_amount': final_amount
|
||
}
|
||
|
||
@staticmethod
|
||
def increment_usage(promo_code):
|
||
"""Увеличить счетчик использований промокода."""
|
||
promo_code.uses_count += 1
|
||
promo_code.save(update_fields=['uses_count'])
|
||
|
||
|
||
class SubscriptionService:
|
||
"""Сервис для работы с подписками."""
|
||
|
||
@staticmethod
|
||
def get_active_subscription(user):
|
||
"""
|
||
Получить активную подписку пользователя.
|
||
|
||
Args:
|
||
user: пользователь
|
||
|
||
Returns:
|
||
Subscription или None (только если подписка действительно активна)
|
||
"""
|
||
from .models import Subscription
|
||
|
||
# Получаем все подписки пользователя со статусом trial или active
|
||
subscriptions = Subscription.objects.filter(
|
||
user=user,
|
||
status__in=['trial', 'active']
|
||
).order_by('-end_date')
|
||
|
||
# Проверяем каждую подписку, пока не найдем действительно активную
|
||
for subscription in subscriptions:
|
||
# Проверяем, что подписка действительно активна (не истекла)
|
||
if subscription.is_active():
|
||
return subscription
|
||
|
||
return None
|
||
|
||
@staticmethod
|
||
def get_subscription_or_expired(user):
|
||
"""
|
||
Получить подписку пользователя (активную или истекшую).
|
||
Используется для отображения информации о подписке, даже если она истекла.
|
||
|
||
Args:
|
||
user: пользователь
|
||
|
||
Returns:
|
||
Subscription или None
|
||
"""
|
||
from .models import Subscription
|
||
|
||
return Subscription.objects.filter(
|
||
user=user,
|
||
status__in=['trial', 'active', 'expired']
|
||
).order_by('-end_date').first()
|
||
|
||
@staticmethod
|
||
def calculate_subscription_price(plan, student_count=0, duration_days=30, promo_code=None, remaining_days=None, is_add_students=False, current_student_count=0):
|
||
"""
|
||
Рассчитать цену подписки.
|
||
|
||
Args:
|
||
plan: SubscriptionPlan
|
||
student_count: количество учеников (для типа per_student)
|
||
duration_days: длительность в днях (30, 90, 180, 365)
|
||
promo_code: промокод (опционально)
|
||
remaining_days: оставшиеся дни текущей подписки (для продления)
|
||
is_add_students: режим добавления учеников в текущий период
|
||
current_student_count: текущее количество учеников в подписке (для расчета доплаты)
|
||
|
||
Returns:
|
||
dict: {
|
||
'original_amount': Decimal,
|
||
'discount_amount': Decimal,
|
||
'final_amount': Decimal,
|
||
'end_date': str,
|
||
'end_date_display': str,
|
||
'extra_students_payment': доплата за новых учеников
|
||
}
|
||
"""
|
||
return plan.calculate_price(
|
||
student_count=student_count,
|
||
duration_days=duration_days,
|
||
promo_code=promo_code,
|
||
remaining_days=remaining_days,
|
||
is_add_students=is_add_students,
|
||
current_student_count=current_student_count
|
||
)
|
||
|
||
@staticmethod
|
||
def calculate_extra_students_payment(subscription, new_student_count):
|
||
"""
|
||
Рассчитать доплату за дополнительных учеников.
|
||
|
||
Args:
|
||
subscription: Subscription объект
|
||
new_student_count: новое общее количество учеников
|
||
|
||
Returns:
|
||
dict: {
|
||
'extra_students': количество дополнительных учеников,
|
||
'price_per_student': цена за одного дополнительного ученика,
|
||
'days_remaining': оставшиеся дни подписки,
|
||
'total_days': общее количество дней подписки,
|
||
'payment_amount': сумма доплаты (пропорционально оставшимся дням),
|
||
'next_month_amount': сумма за следующий месяц за всех учеников
|
||
}
|
||
"""
|
||
from django.utils import timezone
|
||
from decimal import Decimal
|
||
|
||
if subscription.plan.subscription_type != 'per_student':
|
||
return {
|
||
'extra_students': 0,
|
||
'price_per_student': Decimal('0'),
|
||
'days_remaining': 0,
|
||
'total_days': subscription.duration_days,
|
||
'payment_amount': Decimal('0'),
|
||
'next_month_amount': Decimal('0')
|
||
}
|
||
|
||
# Текущее оплаченное количество
|
||
paid_student_count = subscription.student_count
|
||
|
||
# Количество дополнительных учеников
|
||
extra_students = max(0, new_student_count - paid_student_count)
|
||
|
||
if extra_students == 0:
|
||
return {
|
||
'extra_students': 0,
|
||
'price_per_student': Decimal('0'),
|
||
'days_remaining': 0,
|
||
'total_days': subscription.duration_days,
|
||
'payment_amount': Decimal('0'),
|
||
'next_month_amount': Decimal('0')
|
||
}
|
||
|
||
# Рассчитываем цену за новое количество учеников
|
||
# Используем calculate_price для получения правильной цены с учетом прогрессивных скидок
|
||
price_data_new = subscription.plan.calculate_price(
|
||
student_count=new_student_count,
|
||
duration_days=30 # Рассчитываем за месяц
|
||
)
|
||
|
||
# Рассчитываем цену за старое количество учеников
|
||
price_data_old = subscription.plan.calculate_price(
|
||
student_count=paid_student_count,
|
||
duration_days=30
|
||
)
|
||
|
||
# Цена за дополнительных учеников в месяц
|
||
price_per_month_for_extra = price_data_new['final_amount'] - price_data_old['final_amount']
|
||
|
||
# Цена за одного дополнительного ученика в месяц
|
||
price_per_student_per_month = price_per_month_for_extra / Decimal(str(extra_students))
|
||
|
||
# Оставшиеся дни подписки
|
||
now = timezone.now()
|
||
if now >= subscription.end_date:
|
||
days_remaining = 0
|
||
else:
|
||
delta = subscription.end_date - now
|
||
days_remaining = max(0, delta.days)
|
||
|
||
# Пропорциональная доплата за оставшиеся дни
|
||
total_days = subscription.duration_days
|
||
if total_days > 0 and days_remaining > 0:
|
||
# Рассчитываем пропорционально: (цена за месяц / 30 дней) * оставшиеся дни
|
||
payment_amount = (price_per_student_per_month / Decimal('30')) * Decimal(str(days_remaining)) * Decimal(str(extra_students))
|
||
else:
|
||
payment_amount = Decimal('0')
|
||
|
||
# Сумма за следующий месяц за всех учеников
|
||
next_month_amount = price_data_new['final_amount']
|
||
|
||
return {
|
||
'extra_students': extra_students,
|
||
'price_per_student': price_per_student_per_month,
|
||
'days_remaining': days_remaining,
|
||
'total_days': total_days,
|
||
'payment_amount': payment_amount,
|
||
'next_month_amount': next_month_amount,
|
||
'current_student_count': paid_student_count,
|
||
'new_student_count': new_student_count
|
||
}
|
||
|
||
@staticmethod
|
||
def create_subscription(user, plan, student_count=0, duration_days=30,
|
||
start_date=None, promo_code=None):
|
||
"""
|
||
Создать подписку.
|
||
|
||
Args:
|
||
user: пользователь
|
||
plan: SubscriptionPlan
|
||
student_count: количество учеников (для типа per_student)
|
||
duration_days: длительность в днях (30, 90, 180, 365)
|
||
start_date: дата начала (если не указана - текущая дата)
|
||
promo_code: промокод (опционально)
|
||
|
||
Returns:
|
||
Subscription
|
||
"""
|
||
from .models import Subscription
|
||
from django.utils import timezone
|
||
from datetime import timedelta
|
||
|
||
# Рассчитываем цену
|
||
price_data = SubscriptionService.calculate_subscription_price(
|
||
plan=plan,
|
||
student_count=student_count,
|
||
duration_days=duration_days,
|
||
promo_code=promo_code
|
||
)
|
||
|
||
# Определяем даты
|
||
if start_date:
|
||
start = start_date
|
||
else:
|
||
start = timezone.now()
|
||
|
||
end = start + timedelta(days=duration_days)
|
||
|
||
# Создаем подписку
|
||
subscription = Subscription.objects.create(
|
||
user=user,
|
||
plan=plan,
|
||
student_count=student_count,
|
||
duration_days=duration_days,
|
||
start_date=start,
|
||
end_date=end,
|
||
promo_code=promo_code,
|
||
original_amount=price_data['original_amount'],
|
||
discount_amount=price_data['discount_amount'],
|
||
final_amount=price_data['final_amount'],
|
||
status='trial' if plan.trial_days > 0 else 'active'
|
||
)
|
||
|
||
# Увеличиваем счетчик использований промокода
|
||
if promo_code:
|
||
PromoCodeService.increment_usage(promo_code)
|
||
|
||
return subscription
|
||
|
||
@staticmethod
|
||
def check_feature_access(user, feature_name):
|
||
"""
|
||
Проверить доступ к функции.
|
||
|
||
Args:
|
||
user: пользователь
|
||
feature_name: название функции
|
||
|
||
Returns:
|
||
bool
|
||
"""
|
||
subscription = SubscriptionService.get_active_subscription(user)
|
||
if not subscription:
|
||
return False
|
||
return subscription.has_feature(feature_name)
|
||
|
||
@staticmethod
|
||
def check_limit(user, limit_type):
|
||
"""
|
||
Проверить лимит.
|
||
|
||
Args:
|
||
user: пользователь
|
||
limit_type: тип лимита
|
||
|
||
Returns:
|
||
bool
|
||
"""
|
||
subscription = SubscriptionService.get_active_subscription(user)
|
||
if not subscription:
|
||
return False
|
||
return subscription.check_limit(limit_type)
|
||
|
||
@staticmethod
|
||
def log_usage(subscription, usage_type, amount, description=''):
|
||
"""
|
||
Залогировать использование.
|
||
|
||
Args:
|
||
subscription: подписка
|
||
usage_type: тип использования
|
||
amount: количество
|
||
description: описание
|
||
"""
|
||
from .models import SubscriptionUsageLog
|
||
|
||
# Создаем лог
|
||
SubscriptionUsageLog.objects.create(
|
||
subscription=subscription,
|
||
usage_type=usage_type,
|
||
amount=amount,
|
||
description=description
|
||
)
|
||
|
||
# Обновляем счетчики
|
||
if usage_type == 'lesson':
|
||
subscription.lessons_used += amount
|
||
elif usage_type == 'storage':
|
||
subscription.storage_used_mb += amount
|
||
elif usage_type == 'video_minutes':
|
||
subscription.video_minutes_used += amount
|
||
|
||
subscription.save()
|
||
|
||
@staticmethod
|
||
def change_plan(subscription, new_plan, student_count=None, duration_days=None, promo_code=None):
|
||
"""
|
||
Сменить тарифный план подписки.
|
||
|
||
Args:
|
||
subscription: текущая подписка
|
||
new_plan: новый тарифный план
|
||
student_count: количество учеников (для типа per_student, если не указано - берется из текущей подписки)
|
||
duration_days: длительность в днях (если не указана - используется из текущей подписки)
|
||
promo_code: промокод (опционально)
|
||
|
||
Returns:
|
||
Subscription: обновленная подписка
|
||
"""
|
||
from django.utils import timezone
|
||
from datetime import timedelta
|
||
from apps.users.models import Client
|
||
|
||
# Определяем количество учеников
|
||
if new_plan.subscription_type == 'per_student':
|
||
if student_count is None:
|
||
# Если переходим с ежемесячной на индивидуальную, нужно указать количество учеников
|
||
if subscription.plan.subscription_type == 'monthly':
|
||
# Берем текущее количество клиентов пользователя
|
||
current_clients_count = Client.objects.filter(mentors=subscription.user).count()
|
||
if current_clients_count == 0:
|
||
raise ValueError('Необходимо указать количество учеников при переходе на тариф "За ученика"')
|
||
student_count = current_clients_count
|
||
else:
|
||
# Переходим с индивидуальной на индивидуальную - берем из текущей подписки
|
||
student_count = subscription.student_count
|
||
else:
|
||
# Переходим на ежемесячную подписку - количество учеников не требуется
|
||
student_count = 0
|
||
|
||
# Определяем длительность
|
||
if duration_days is None:
|
||
duration_days = subscription.duration_days
|
||
|
||
# Проверяем доступность периода для нового плана
|
||
if not new_plan.is_duration_available(duration_days):
|
||
available = new_plan.get_available_durations()
|
||
raise ValueError(f'Период {duration_days} дней недоступен для этого тарифа. Доступные периоды: {", ".join(map(str, available))}')
|
||
|
||
# Проверяем доступность тарифа
|
||
can_use, error_message = new_plan.can_be_used(subscription.user)
|
||
if not can_use:
|
||
raise ValueError(error_message)
|
||
|
||
# Рассчитываем цену для нового плана
|
||
price_data = SubscriptionService.calculate_subscription_price(
|
||
plan=new_plan,
|
||
student_count=student_count,
|
||
duration_days=duration_days,
|
||
promo_code=promo_code,
|
||
remaining_days=subscription.days_until_expiration() if subscription.is_active() else 0
|
||
)
|
||
|
||
# Обновляем подписку
|
||
old_plan = subscription.plan
|
||
subscription.plan = new_plan
|
||
subscription.student_count = student_count
|
||
subscription.duration_days = duration_days
|
||
subscription.original_amount = price_data['original_amount']
|
||
subscription.discount_amount = price_data['discount_amount']
|
||
subscription.final_amount = price_data['final_amount']
|
||
subscription.promo_code = promo_code
|
||
|
||
# Если подписка активна, продлеваем её на новую длительность
|
||
if subscription.is_active():
|
||
remaining_days = subscription.days_until_expiration()
|
||
if remaining_days > 0:
|
||
# Добавляем новую длительность к оставшимся дням
|
||
subscription.end_date = subscription.end_date + timedelta(days=duration_days)
|
||
else:
|
||
# Подписка истекла, начинаем с текущей даты
|
||
subscription.start_date = timezone.now()
|
||
subscription.end_date = timezone.now() + timedelta(days=duration_days)
|
||
else:
|
||
# Подписка неактивна, начинаем с текущей даты
|
||
subscription.start_date = timezone.now()
|
||
subscription.end_date = timezone.now() + timedelta(days=duration_days)
|
||
subscription.status = 'active'
|
||
|
||
subscription.save()
|
||
|
||
# Увеличиваем счетчик использований тарифа, если это акция
|
||
if new_plan.promo_type == 'limited_uses':
|
||
new_plan.current_uses += 1
|
||
new_plan.save(update_fields=['current_uses'])
|
||
|
||
# Увеличиваем счетчик использований промокода
|
||
if promo_code:
|
||
PromoCodeService.increment_usage(promo_code)
|
||
|
||
return subscription
|
||
|