uchill/backend/apps/subscriptions/services.py

721 lines
28 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.

"""
Сервисы для работы с платежными системами.
"""
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