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