""" Сериализаторы для подписок и платежей. """ from rest_framework import serializers from django.utils import timezone from datetime import timedelta from .models import SubscriptionPlan, Subscription, Payment, PaymentHistory, SubscriptionUsageLog, PromoCode, BulkDiscount from apps.users.mixins import TimezoneAwareSerializerMixin class SubscriptionPlanSerializer(serializers.ModelSerializer): """Сериализатор тарифного плана.""" features = serializers.SerializerMethodField() bulk_discounts = serializers.SerializerMethodField() duration_discounts = serializers.SerializerMethodField() available_durations = serializers.SerializerMethodField() promo_info = serializers.SerializerMethodField() price = serializers.DecimalField(max_digits=10, decimal_places=2, coerce_to_string=False) price_per_student = serializers.DecimalField(max_digits=10, decimal_places=2, coerce_to_string=False, allow_null=True) class Meta: model = SubscriptionPlan fields = [ 'id', 'name', 'slug', 'description', 'price', 'price_per_student', 'currency', 'billing_period', 'subscription_type', 'trial_days', 'max_clients', 'max_lessons_per_month', 'max_storage_mb', 'max_video_minutes_per_month', 'target_role', 'features', 'bulk_discounts', 'duration_discounts', 'available_durations', 'promo_info', 'is_active', 'is_featured', 'sort_order', 'subscribers_count' ] read_only_fields = ['subscribers_count'] def to_representation(self, instance): """Преобразование в представление для API.""" data = super().to_representation(instance) # Преобразуем Decimal в float для JSON if data.get('price') is not None: data['price'] = float(data['price']) if data.get('price_per_student') is not None: data['price_per_student'] = float(data['price_per_student']) return data def get_bulk_discounts(self, obj): """Получить прогрессирующие скидки для плана.""" if obj.subscription_type == 'per_student': from decimal import Decimal discounts = obj.bulk_discounts.all().order_by('min_students') result = [] base_price = obj.price_per_student or obj.price for discount in discounts: # Рассчитываем цену за одного ученика в этом диапазоне # total_price - это цена за минимальное количество учеников в диапазоне # Например: для диапазона 5-9, total_price = 420 означает 420 руб за 5 учеников # Значит цена за одного = 420/5 = 84 руб students_for_calc = discount.min_students price_per_student = discount.total_price / Decimal(str(students_for_calc)) result.append({ 'min_students': discount.min_students, 'max_students': discount.max_students, 'total_price': float(discount.total_price), 'price_per_student': float(price_per_student), 'base_price': float(base_price) # Базовая цена для сравнения }) return result return [] def get_duration_discounts(self, obj): """ Получить скидки за длительность для плана (определяют доступные периоды оплаты). ВАЖНО: Периоды, для которых указаны скидки, автоматически становятся доступными для оплаты. Скидки применяются только к ежемесячным тарифам (subscription_type='monthly'). Для тарифов "За ученика" скидки не применяются, но периоды определяют доступные варианты оплаты. """ discounts = obj.duration_discounts.all().order_by('duration_days') result = [] for discount in discounts: result.append({ 'duration_days': discount.duration_days, 'discount_percent': float(discount.discount_percent) if obj.subscription_type == 'monthly' else 0, 'months': discount.duration_days / 30, 'is_available': True, # Период доступен для оплаты 'applies_discount': obj.subscription_type == 'monthly', # Скидка применяется только для monthly }) return result def get_features(self, obj): """Список доступных функций.""" return { 'video_calls': obj.allow_video_calls, 'screen_sharing': obj.allow_screen_sharing, 'whiteboard': obj.allow_whiteboard, 'homework': obj.allow_homework, 'materials': obj.allow_materials, 'analytics': obj.allow_analytics, 'telegram_bot': obj.allow_telegram_bot, 'api_access': obj.allow_api_access, } def get_available_durations(self, obj): """Получить доступные периоды оплаты.""" return obj.get_available_durations() def get_promo_info(self, obj): """Информация об акции.""" return { 'promo_type': obj.promo_type, 'promo_type_display': obj.get_promo_type_display(), 'max_uses': obj.max_uses, 'current_uses': obj.current_uses, 'remaining_uses': obj.max_uses - obj.current_uses if obj.max_uses else None, 'is_limited': obj.promo_type == 'limited_uses' and obj.max_uses is not None, 'is_first_time_only': obj.promo_type == 'first_time', } class SubscriptionSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer): """Сериализатор подписки.""" plan = SubscriptionPlanSerializer(read_only=True) is_active_now = serializers.SerializerMethodField() days_left = serializers.SerializerMethodField() usage = serializers.SerializerMethodField() class Meta: model = Subscription fields = [ 'id', 'user', 'plan', 'status', 'start_date', 'end_date', 'trial_end_date', 'cancelled_at', 'auto_renew', 'student_count', 'is_active_now', 'days_left', 'usage', 'created_at' ] read_only_fields = ['user', 'created_at'] timezone_aware_fields = ['start_date', 'end_date', 'trial_end_date', 'cancelled_at', 'created_at'] def get_is_active_now(self, obj): """Активна ли подписка сейчас.""" return obj.is_active() def get_days_left(self, obj): """Дней до истечения.""" return obj.days_until_expiration() def get_usage(self, obj): """Статистика использования.""" plan = obj.plan usage = { 'lessons': { 'used': obj.lessons_used, 'limit': plan.max_lessons_per_month, 'unlimited': plan.max_lessons_per_month is None }, 'storage': { 'used_mb': obj.storage_used_mb, 'limit_mb': plan.max_storage_mb, 'percentage': round((obj.storage_used_mb / plan.max_storage_mb) * 100, 2) if plan.max_storage_mb > 0 else 0 }, 'video_minutes': { 'used': obj.video_minutes_used, 'limit': plan.max_video_minutes_per_month, 'unlimited': plan.max_video_minutes_per_month is None } } return usage class SubscriptionCreateSerializer(serializers.ModelSerializer): """Сериализатор создания подписки.""" plan_id = serializers.IntegerField() student_count = serializers.IntegerField(default=0, min_value=0) duration_days = serializers.IntegerField(default=30, min_value=1) promo_code = serializers.CharField(required=False, allow_blank=True, allow_null=True) start_date = serializers.DateTimeField(required=False, allow_null=True) class Meta: model = Subscription fields = ['plan_id', 'student_count', 'duration_days', 'promo_code', 'start_date', 'auto_renew'] def validate_plan_id(self, value): """Валидация плана.""" try: plan = SubscriptionPlan.objects.get(id=value, is_active=True) except SubscriptionPlan.DoesNotExist: raise serializers.ValidationError('Тарифный план не найден') return value def validate_duration_days(self, value): """Валидация длительности.""" allowed_durations = [30, 90, 180, 365] if value not in allowed_durations: raise serializers.ValidationError( f'Длительность должна быть одним из значений: {", ".join(map(str, allowed_durations))}' ) # Проверяем доступность периода для плана plan_id = self.initial_data.get('plan_id') if plan_id: try: plan = SubscriptionPlan.objects.get(id=plan_id) if not plan.is_duration_available(value): available = plan.get_available_durations() raise serializers.ValidationError( f'Период {value} дней недоступен для этого тарифа. Доступные периоды: {", ".join(map(str, available))}' ) except SubscriptionPlan.DoesNotExist: pass return value def validate_student_count(self, value): """Валидация количества учеников.""" if value < 0: raise serializers.ValidationError('Количество учеников не может быть отрицательным') return value def validate(self, attrs): """Валидация данных.""" plan_id = attrs.get('plan_id') student_count = attrs.get('student_count', 0) try: plan = SubscriptionPlan.objects.get(id=plan_id) except SubscriptionPlan.DoesNotExist: raise serializers.ValidationError({'plan_id': 'Тарифный план не найден'}) # Проверяем доступность тарифа (акция) user = self.context.get('request').user if self.context.get('request') else None can_use, error_message = plan.can_be_used(user) if not can_use: raise serializers.ValidationError({'plan_id': error_message}) # Для типа "за ученика" количество учеников обязательно if plan.subscription_type == 'per_student' and student_count == 0: raise serializers.ValidationError({ 'student_count': 'Для подписки "За ученика" необходимо указать количество учеников' }) # Для ежемесячной подписки количество учеников должно быть 0 if plan.subscription_type == 'monthly' and student_count > 0: raise serializers.ValidationError({ 'student_count': 'Для ежемесячной подписки количество учеников не требуется' }) return attrs def create(self, validated_data): """Создание подписки.""" from .services import SubscriptionService, PromoCodeService plan_id = validated_data.pop('plan_id') student_count = validated_data.pop('student_count', 0) duration_days = validated_data.pop('duration_days', 30) promo_code_str = validated_data.pop('promo_code', None) start_date = validated_data.pop('start_date', None) user = self.context['request'].user plan = SubscriptionPlan.objects.get(id=plan_id) # Валидация и применение промокода promo_code = None if promo_code_str: promo_result = PromoCodeService.validate_promo_code(promo_code_str, user) if not promo_result['valid']: raise serializers.ValidationError({ 'promo_code': promo_result['error'] }) promo_code = promo_result['promo_code'] # Создаем подписку через сервис subscription = SubscriptionService.create_subscription( user=user, plan=plan, student_count=student_count, duration_days=duration_days, start_date=start_date, promo_code=promo_code ) return subscription class PaymentSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer): """Сериализатор платежа.""" subscription = SubscriptionSerializer(read_only=True) class Meta: model = Payment fields = [ 'id', 'uuid', 'user', 'subscription', 'amount', 'currency', 'status', 'payment_method', 'external_id', 'description', 'created_at', 'paid_at', 'failed_at', 'refunded_at' ] read_only_fields = ['user', 'uuid', 'created_at'] timezone_aware_fields = ['created_at', 'paid_at', 'failed_at', 'refunded_at'] class PaymentCreateSerializer(serializers.Serializer): """Сериализатор создания платежа.""" subscription_id = serializers.IntegerField() payment_method = serializers.ChoiceField(choices=Payment.PAYMENT_METHOD_CHOICES) return_url = serializers.URLField(required=False) def validate_subscription_id(self, value): """Валидация подписки.""" try: subscription = Subscription.objects.get(id=value) # Проверяем что подписка принадлежит пользователю if subscription.user != self.context['request'].user: raise serializers.ValidationError('Подписка не найдена') except Subscription.DoesNotExist: raise serializers.ValidationError('Подписка не найдена') return value def create(self, validated_data): """Создание платежа.""" user = self.context['request'].user subscription_id = validated_data['subscription_id'] payment_method = validated_data['payment_method'] subscription = Subscription.objects.get(id=subscription_id) # Создаем платеж payment = Payment.objects.create( user=user, subscription=subscription, amount=subscription.plan.price, currency=subscription.plan.currency, payment_method=payment_method, description=f"Оплата подписки {subscription.plan.name}" ) return payment class PaymentHistorySerializer(serializers.ModelSerializer): """Сериализатор истории платежа.""" class Meta: model = PaymentHistory fields = ['id', 'status', 'message', 'data', 'created_at'] read_only_fields = ['created_at'] class SubscriptionUsageLogSerializer(serializers.ModelSerializer): """Сериализатор лога использования.""" class Meta: model = SubscriptionUsageLog fields = ['id', 'usage_type', 'amount', 'description', 'created_at'] read_only_fields = ['created_at']