""" Модели для подписок и платежей. """ from django.db import models from django.core.validators import MinValueValidator from django.utils import timezone from datetime import timedelta from decimal import Decimal import uuid class BulkDiscount(models.Model): """ Модель прогрессирующих скидок для подписки "за ученика". Пример: - 1-4 ученика: 100 руб за каждого - 5-9 учеников: 420 руб за 5 (84 руб за каждого) - 10+ учеников: 850 руб за 10 (85 руб за каждого) """ plan = models.ForeignKey( 'SubscriptionPlan', on_delete=models.CASCADE, related_name='bulk_discounts', verbose_name='Тарифный план' ) min_students = models.IntegerField( validators=[MinValueValidator(1)], verbose_name='Минимальное количество учеников', help_text='От какого количества учеников действует эта цена' ) max_students = models.IntegerField( null=True, blank=True, validators=[MinValueValidator(1)], verbose_name='Максимальное количество учеников', help_text='До какого количества учеников действует эта цена. Если не указано - без ограничений' ) price_per_student = models.DecimalField( max_digits=10, decimal_places=2, validators=[MinValueValidator(0)], verbose_name='Цена за ученика', help_text='Цена за одного ученика в этом диапазоне' ) total_price = models.DecimalField( max_digits=10, decimal_places=2, validators=[MinValueValidator(0)], verbose_name='Итоговая цена', help_text='Автоматически рассчитывается как: цена за ученика × минимальное количество учеников', editable=False ) # Временные метки created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания' ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления' ) class Meta: db_table = 'bulk_discounts' verbose_name = 'Прогрессирующая скидка' verbose_name_plural = 'Прогрессирующие скидки' ordering = ['plan', 'min_students'] indexes = [ models.Index(fields=['plan', 'min_students']), ] constraints = [ models.CheckConstraint( check=models.Q(max_students__isnull=True) | models.Q(max_students__gte=models.F('min_students')), name='max_students_gte_min_students' ) ] def __str__(self): if self.max_students: return f"{self.plan.name}: {self.min_students}-{self.max_students} учеников = {self.price_per_student} руб/ученик" else: return f"{self.plan.name}: {self.min_students}+ учеников = {self.price_per_student} руб/ученик" def save(self, *args, **kwargs): """Автоматически рассчитываем total_price из price_per_student × min_students.""" if self.price_per_student and self.min_students: self.total_price = self.price_per_student * Decimal(str(self.min_students)) super().save(*args, **kwargs) def matches(self, student_count): """ Проверить, подходит ли эта скидка для указанного количества учеников. Args: student_count: количество учеников Returns: bool """ if student_count < self.min_students: return False if self.max_students is None: return True return student_count <= self.max_students class DurationDiscount(models.Model): """ Модель скидок за длительность подписки. ВАЖНО: Периоды, для которых указаны скидки, автоматически становятся доступными для оплаты. Если скидок нет, доступны все стандартные периоды (30, 90, 180, 365 дней). Пример: - 3 месяца (90 дней): 7% скидка - 6 месяцев (180 дней): 12% скидка - 12 месяцев (365 дней): 18% скидка Применяется к ежемесячным тарифам (subscription_type='monthly'). Для тарифов "За ученика" (per_student) скидки не применяются, но периоды определяют доступные варианты оплаты. """ plan = models.ForeignKey( 'SubscriptionPlan', on_delete=models.CASCADE, related_name='duration_discounts', verbose_name='Тарифный план' ) duration_days = models.IntegerField( validators=[MinValueValidator(1)], verbose_name='Длительность в днях', help_text='Количество дней подписки (30, 90, 180, 365)' ) discount_percent = models.DecimalField( max_digits=5, decimal_places=2, validators=[MinValueValidator(0)], verbose_name='Процент скидки', help_text='Процент скидки за эту длительность (например, 7.00 для 7%)' ) # Временные метки created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания' ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления' ) class Meta: db_table = 'duration_discounts' verbose_name = 'Скидка за длительность' verbose_name_plural = 'Скидки за длительность' ordering = ['plan', 'duration_days'] unique_together = [['plan', 'duration_days']] indexes = [ models.Index(fields=['plan', 'duration_days']), ] def __str__(self): months = self.duration_days / 30 return f"{self.plan.name}: {months:.0f} мес ({self.duration_days} дней) - {self.discount_percent}%" def matches(self, days): """Проверить, подходит ли эта скидка для указанной длительности.""" return self.duration_days == days class SubscriptionPlan(models.Model): """ Модель тарифного плана. """ BILLING_PERIOD_CHOICES = [ ('monthly', 'Ежемесячно'), ('quarterly', 'Ежеквартально'), ('yearly', 'Ежегодно'), ('lifetime', 'Навсегда'), ] SUBSCRIPTION_TYPE_CHOICES = [ ('per_student', 'За ученика'), ('monthly', 'Ежемесячная'), ] # Основная информация name = models.CharField( max_length=100, unique=True, verbose_name='Название' ) slug = models.SlugField( max_length=100, unique=True, verbose_name='Слаг' ) description = models.TextField( blank=True, verbose_name='Описание' ) # Стоимость price = models.DecimalField( max_digits=10, decimal_places=2, validators=[MinValueValidator(0)], verbose_name='Цена' ) currency = models.CharField( max_length=3, default='RUB', verbose_name='Валюта' ) billing_period = models.CharField( max_length=20, choices=BILLING_PERIOD_CHOICES, default='monthly', verbose_name='Период оплаты' ) subscription_type = models.CharField( max_length=20, choices=SUBSCRIPTION_TYPE_CHOICES, default='monthly', verbose_name='Тип подписки' ) price_per_student = models.DecimalField( max_digits=10, decimal_places=2, null=True, blank=True, validators=[MinValueValidator(0)], verbose_name='Цена за ученика', help_text='Используется для типа "За ученика"' ) trial_days = models.IntegerField( default=0, validators=[MinValueValidator(0)], verbose_name='Пробный период (дней)' ) # Лимиты max_clients = models.IntegerField( null=True, blank=True, validators=[MinValueValidator(1)], verbose_name='Максимум клиентов' ) max_lessons_per_month = models.IntegerField( null=True, blank=True, validators=[MinValueValidator(1)], verbose_name='Максимум занятий в месяц' ) max_storage_mb = models.IntegerField( default=1024, # 1 GB validators=[MinValueValidator(1)], verbose_name='Максимум хранилища (МБ)' ) max_video_minutes_per_month = models.IntegerField( null=True, blank=True, validators=[MinValueValidator(1)], verbose_name='Максимум минут видео в месяц' ) # Функциональность allow_video_calls = models.BooleanField( default=True, verbose_name='Видеозвонки' ) allow_screen_sharing = models.BooleanField( default=True, verbose_name='Демонстрация экрана' ) allow_whiteboard = models.BooleanField( default=True, verbose_name='Интерактивная доска' ) allow_homework = models.BooleanField( default=True, verbose_name='Домашние задания' ) allow_materials = models.BooleanField( default=True, verbose_name='Материалы' ) allow_analytics = models.BooleanField( default=True, verbose_name='Аналитика' ) allow_telegram_bot = models.BooleanField( default=False, verbose_name='Telegram бот' ) allow_api_access = models.BooleanField( default=False, verbose_name='API доступ' ) # Тип акции PROMO_TYPE_CHOICES = [ ('none', 'Без акции'), ('first_time', 'Только для новых пользователей'), ('limited_uses', 'Ограниченное количество использований'), ] promo_type = models.CharField( max_length=20, choices=PROMO_TYPE_CHOICES, default='none', verbose_name='Тип акции', help_text='Тип акции для данного тарифа' ) max_uses = models.IntegerField( null=True, blank=True, validators=[MinValueValidator(1)], verbose_name='Максимальное количество использований', help_text='Максимальное количество раз, которое можно использовать этот тариф (для типа "Ограниченное количество использований")' ) current_uses = models.IntegerField( default=0, validators=[MinValueValidator(0)], verbose_name='Текущее количество использований', help_text='Сколько раз уже использован этот тариф' ) # Целевая аудитория TARGET_ROLE_CHOICES = [ ('all', 'Для всех'), ('mentor', 'Для менторов'), ('client', 'Для студентов'), ('parent', 'Для родителей'), ] target_role = models.CharField( max_length=20, choices=TARGET_ROLE_CHOICES, default='all', verbose_name='Для кого предназначена подписка', help_text='Роль пользователя, для которой предназначена эта подписка' ) # Настройки is_active = models.BooleanField( default=True, verbose_name='Активен', db_index=True ) is_featured = models.BooleanField( default=False, verbose_name='Рекомендуемый' ) sort_order = models.IntegerField( default=0, verbose_name='Порядок сортировки' ) # Статистика subscribers_count = models.IntegerField( default=0, verbose_name='Количество подписчиков' ) # Временные метки created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания' ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления' ) class Meta: db_table = 'subscription_plans' verbose_name = 'Тарифный план' verbose_name_plural = 'Тарифные планы' ordering = ['sort_order', 'price'] indexes = [ models.Index(fields=['is_active', 'sort_order']), models.Index(fields=['slug']), models.Index(fields=['subscription_type', 'is_active']), ] def __str__(self): return f"{self.name} - {self.price} {self.currency}/{self.get_billing_period_display()}" def get_duration_days(self, custom_days=None): """ Получить длительность подписки в днях. Args: custom_days: кастомная длительность в днях (30, 90, 180, 365) Returns: int: количество дней """ if custom_days: return custom_days if self.billing_period == 'monthly': return 30 elif self.billing_period == 'quarterly': return 90 elif self.billing_period == 'yearly': return 365 elif self.billing_period == 'lifetime': return 36500 # 100 лет return 30 def get_available_durations(self): """ Получить список доступных периодов оплаты из скидок за длительность. Returns: list: список доступных периодов в днях [30, 90, 180, 365] """ # Получаем периоды из скидок за длительность durations = list(self.duration_discounts.values_list('duration_days', flat=True)) # Если есть скидки, возвращаем их периоды if durations: return sorted(durations) # Если скидок нет, доступны все стандартные периоды return [30, 90, 180, 365] def is_duration_available(self, duration_days): """ Проверить, доступен ли указанный период оплаты. Args: duration_days: период в днях (30, 90, 180, 365) Returns: bool """ # Если есть скидки за длительность, проверяем по ним if self.duration_discounts.exists(): return self.duration_discounts.filter(duration_days=duration_days).exists() # Если скидок нет, доступны все стандартные периоды return duration_days in [30, 90, 180, 365] def can_be_used(self, user=None): """ Проверить, можно ли использовать этот тариф. Args: user: пользователь (для проверки типа акции "first_time") Returns: tuple: (can_use: bool, error_message: str) """ if self.promo_type == 'limited_uses': if self.max_uses is not None and self.current_uses >= self.max_uses: return False, f'Тариф можно использовать только {self.max_uses} раз(а). Лимит исчерпан.' if self.promo_type == 'first_time' and user: # Проверяем, есть ли у пользователя уже подписки # Используем строку для избежания циклического импорта from django.apps import apps Subscription = apps.get_model('subscriptions', 'Subscription') has_subscriptions = Subscription.objects.filter(user=user).exists() if has_subscriptions: return False, 'Этот тариф доступен только для новых пользователей.' return True, '' def calculate_price(self, student_count=0, duration_days=None, promo_code=None, remaining_days=None, is_add_students=False, current_student_count=0): """ Рассчитать цену подписки. Args: student_count: количество учеников (для типа per_student) duration_days: длительность в днях (30, 90, 180, 365) promo_code: промокод (опционально) remaining_days: оставшиеся дни текущей подписки (для продления) is_add_students: режим добавления учеников в текущий период current_student_count: текущее количество учеников в подписке (для расчета доплаты) Returns: dict: { 'original_amount': исходная сумма, 'discount_amount': сумма скидки, 'final_amount': итоговая сумма, 'end_date': дата окончания, 'end_date_display': дата окончания для отображения, 'extra_students_payment': доплата за новых учеников за оставшиеся дни } """ from decimal import Decimal # Определяем базовую цену if self.subscription_type == 'per_student': # Проверяем прогрессирующие скидки bulk_discount = None if student_count > 0: # Ищем подходящую прогрессирующую скидку bulk_discounts = self.bulk_discounts.all().order_by('-min_students') for discount in bulk_discounts: if discount.matches(student_count): bulk_discount = discount break if bulk_discount: # Используем прогрессирующую скидку # price_per_student - это цена за одного ученика в этом диапазоне # Применяем цену за единицу к количеству учеников monthly_price = bulk_discount.price_per_student * Decimal(str(student_count)) else: # Обычная цена за ученика price_per_student = self.price_per_student or self.price monthly_price = price_per_student * Decimal(str(student_count)) # Учитываем длительность подписки if duration_days: days_in_month = 30 months = Decimal(str(duration_days)) / Decimal(str(days_in_month)) original_amount = monthly_price * months else: # По умолчанию 1 месяц original_amount = monthly_price else: # Ежемесячная подписка # Если указана кастомная длительность, пересчитываем цену if duration_days: days_in_month = 30 months = Decimal(str(duration_days)) / Decimal(str(days_in_month)) original_amount = self.price * months else: original_amount = self.price # Применяем скидку за длительность для ежемесячных тарифов duration_discount_amount = Decimal('0') if self.subscription_type == 'monthly' and duration_days: # Ищем скидку за длительность duration_discount = self.duration_discounts.filter(duration_days=duration_days).first() if duration_discount: # Применяем процентную скидку discount_percent = duration_discount.discount_percent / Decimal('100') duration_discount_amount = original_amount * discount_percent # Применяем промокод если есть (скидка от суммы после скидки за длительность) promo_discount_amount = Decimal('0') price_after_duration_discount = original_amount - duration_discount_amount if promo_code: is_valid, error = promo_code.is_valid() if is_valid: promo_discount_amount = promo_code.calculate_discount(price_after_duration_discount) # Итоговая сумма скидки discount_amount = duration_discount_amount + promo_discount_amount final_amount = max(Decimal('0'), original_amount - discount_amount) # Если это продление с добавлением учеников, рассчитываем доплату за новых учеников за оставшиеся дни extra_students_payment = Decimal('0') if (remaining_days and remaining_days > 0 and self.subscription_type == 'per_student' and student_count > current_student_count and current_student_count > 0): # Количество новых учеников new_students = student_count - current_student_count # Рассчитываем цену за одного нового ученика в месяц # Используем ту же логику прогрессивных скидок, но для нового количества new_bulk_discount = None if student_count > 0: bulk_discounts = self.bulk_discounts.all().order_by('-min_students') for discount in bulk_discounts: if discount.matches(student_count): new_bulk_discount = discount break if new_bulk_discount: price_per_new_student_per_month = new_bulk_discount.price_per_student else: price_per_new_student_per_month = self.price_per_student or self.price # Рассчитываем доплату за новых учеников за оставшиеся дни days_in_month = 30 months_remaining = Decimal(str(remaining_days)) / Decimal(str(days_in_month)) extra_students_payment = price_per_new_student_per_month * Decimal(str(new_students)) * months_remaining # Добавляем доплату к итоговой сумме final_amount = final_amount + extra_students_payment result = { 'original_amount': original_amount, 'discount_amount': discount_amount, 'final_amount': final_amount } # Добавляем информацию о доплате за новых учеников if extra_students_payment > 0: result['extra_students_payment'] = float(extra_students_payment) result['extra_students_count'] = student_count - current_student_count result['remaining_days_payment'] = remaining_days # Добавляем информацию о скидке за длительность if duration_discount_amount > 0: result['duration_discount'] = { 'amount': float(duration_discount_amount), 'percent': float(duration_discount.discount_percent) if duration_discount else 0, 'duration_days': duration_days } # Добавляем дату окончания подписки if duration_days: # Если есть оставшиеся дни (продление), добавляем к текущей дате окончания if remaining_days and remaining_days > 0: # Для продления: берем текущую дату + оставшиеся дни + новые дни base_date = timezone.now() + timedelta(days=remaining_days) end_date = base_date + timedelta(days=duration_days) else: # Новая подписка: от текущей даты end_date = timezone.now() + timedelta(days=duration_days) result['end_date'] = end_date.strftime('%Y-%m-%d') result['end_date_display'] = end_date.strftime('%d.%m.%Y') result['total_duration_days'] = (remaining_days or 0) + duration_days else: # Если длительность не указана, используем по умолчанию if remaining_days and remaining_days > 0: base_date = timezone.now() + timedelta(days=remaining_days) end_date = base_date + timedelta(days=30) else: end_date = timezone.now() + timedelta(days=30) result['end_date'] = end_date.strftime('%Y-%m-%d') result['end_date_display'] = end_date.strftime('%d.%m.%Y') result['total_duration_days'] = (remaining_days or 0) + 30 return result class Subscription(models.Model): """ Модель подписки пользователя. """ STATUS_CHOICES = [ ('trial', 'Пробная'), ('active', 'Активна'), ('past_due', 'Просрочена'), ('cancelled', 'Отменена'), ('expired', 'Истекла'), ] # Основная информация user = models.ForeignKey( 'users.User', on_delete=models.CASCADE, related_name='subscriptions', verbose_name='Пользователь' ) plan = models.ForeignKey( SubscriptionPlan, on_delete=models.PROTECT, related_name='subscriptions', verbose_name='Тарифный план' ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default='trial', verbose_name='Статус', db_index=True ) # Промокод и скидка promo_code = models.ForeignKey( 'PromoCode', on_delete=models.SET_NULL, null=True, blank=True, related_name='subscriptions', verbose_name='Промокод' ) discount_amount = models.DecimalField( max_digits=10, decimal_places=2, default=0, validators=[MinValueValidator(0)], verbose_name='Сумма скидки' ) # Для подписки за ученика student_count = models.IntegerField( default=0, validators=[MinValueValidator(0)], verbose_name='Количество учеников', help_text='Используется для типа подписки "За ученика"' ) # Отслеживание неоплаченных учеников unpaid_students_count = models.IntegerField( default=0, validators=[MinValueValidator(0)], verbose_name='Количество неоплаченных учеников', help_text='Количество учеников, добавленных сверх оплаченного количества' ) pending_payment_amount = models.DecimalField( max_digits=10, decimal_places=2, default=0, validators=[MinValueValidator(0)], verbose_name='Сумма ожидающей доплаты', help_text='Сумма доплаты за неоплаченных учеников' ) # Даты (можно указывать вручную) start_date = models.DateTimeField( verbose_name='Дата начала' ) end_date = models.DateTimeField( verbose_name='Дата окончания' ) # Длительность в днях (30, 90, 180, 365) duration_days = models.IntegerField( default=30, validators=[MinValueValidator(1)], verbose_name='Длительность в днях', help_text='30, 90, 180, 365 дней' ) # Сумма с учетом скидки original_amount = models.DecimalField( max_digits=10, decimal_places=2, default=0, validators=[MinValueValidator(0)], verbose_name='Исходная сумма' ) discount_amount = models.DecimalField( max_digits=10, decimal_places=2, default=0, validators=[MinValueValidator(0)], verbose_name='Сумма скидки' ) final_amount = models.DecimalField( max_digits=10, decimal_places=2, default=0, validators=[MinValueValidator(0)], verbose_name='Итоговая сумма' ) trial_end_date = models.DateTimeField( null=True, blank=True, verbose_name='Дата окончания пробного периода' ) cancelled_at = models.DateTimeField( null=True, blank=True, verbose_name='Дата отмены' ) # Автопродление auto_renew = models.BooleanField( default=True, verbose_name='Автопродление' ) # Статистика использования lessons_used = models.IntegerField( default=0, verbose_name='Использовано занятий' ) storage_used_mb = models.IntegerField( default=0, verbose_name='Использовано хранилища (МБ)' ) video_minutes_used = models.IntegerField( default=0, verbose_name='Использовано минут видео' ) # Временные метки created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания' ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления' ) class Meta: db_table = 'subscriptions' verbose_name = 'Подписка' verbose_name_plural = 'Подписки' ordering = ['-created_at'] indexes = [ models.Index(fields=['user', 'status']), models.Index(fields=['end_date']), models.Index(fields=['status']), models.Index(fields=['user', 'end_date']), models.Index(fields=['plan', 'status']), models.Index(fields=['start_date', 'end_date']), ] def __str__(self): return f"{self.user.email} - {self.plan.name} ({self.get_status_display()})" def is_active(self): """Проверка активности подписки.""" now = timezone.now() return ( self.status in ['trial', 'active'] and self.start_date <= now <= self.end_date ) def is_trial(self): """Проверка пробного периода.""" if not self.trial_end_date: return False return timezone.now() <= self.trial_end_date and self.status == 'trial' def days_until_expiration(self): """Дни до истечения.""" if not self.is_active(): return 0 delta = self.end_date - timezone.now() return max(0, delta.days) def renew(self, duration_days=None): """ Продление подписки. Args: duration_days: длительность в днях (если не указана, используется из плана) """ if duration_days: days = duration_days elif self.custom_duration_days: days = self.custom_duration_days else: days = self.plan.get_duration_days() self.end_date = self.end_date + timedelta(days=days) self.status = 'active' self.save() def apply_extra_payment(self, new_student_count): """ Применить доплату за дополнительных учеников. Обновляет student_count и сбрасывает счетчики неоплаченных учеников. Args: new_student_count: новое общее количество учеников """ if self.plan.subscription_type != 'per_student': return # Обновляем количество оплаченных учеников self.student_count = new_student_count # Сбрасываем счетчики неоплаченных self.unpaid_students_count = 0 self.pending_payment_amount = Decimal('0') self.save(update_fields=['student_count', 'unpaid_students_count', 'pending_payment_amount']) def cancel(self): """Отмена подписки.""" self.status = 'cancelled' self.cancelled_at = timezone.now() self.auto_renew = False self.save() def check_expiration(self): """Проверка истечения срока.""" now = timezone.now() # Проверка окончания пробного периода if self.is_trial() and self.trial_end_date and now > self.trial_end_date: self.status = 'active' if self.auto_renew else 'expired' self.save() # Проверка истечения подписки if now > self.end_date: if self.auto_renew and self.status == 'active': self.status = 'past_due' # Ожидание оплаты else: self.status = 'expired' self.save() def has_feature(self, feature_name): """Проверка доступности функции.""" if not self.is_active(): return False feature_map = { 'video_calls': self.plan.allow_video_calls, 'screen_sharing': self.plan.allow_screen_sharing, 'whiteboard': self.plan.allow_whiteboard, 'homework': self.plan.allow_homework, 'materials': self.plan.allow_materials, 'analytics': self.plan.allow_analytics, 'telegram_bot': self.plan.allow_telegram_bot, 'api_access': self.plan.allow_api_access, } return feature_map.get(feature_name, False) def check_limit(self, limit_type): """Проверка лимитов.""" if not self.is_active(): return False if limit_type == 'lessons': max_lessons = self.plan.max_lessons_per_month if max_lessons is None: return True return self.lessons_used < max_lessons elif limit_type == 'storage': return self.storage_used_mb < self.plan.max_storage_mb elif limit_type == 'video_minutes': max_minutes = self.plan.max_video_minutes_per_month if max_minutes is None: return True return self.video_minutes_used < max_minutes elif limit_type == 'clients': # Для типа "За ученика" - лимит не применяется, но нужно платить за каждого if self.plan.subscription_type == 'per_student': return True # Можно добавлять учеников, но нужно будет доплатить # Для ежемесячной подписки - проверяем лимит max_clients = self.plan.max_clients if max_clients is None: return True from apps.users.models import Client current_clients_count = Client.objects.filter(mentors=self.user).count() return current_clients_count < max_clients return True def calculate_monthly_fee(self): """Рассчитать ежемесячную плату.""" if self.plan.subscription_type == 'per_student': if self.plan.price_per_student: from apps.users.models import Client current_clients_count = Client.objects.filter(mentors=self.user).count() return current_clients_count * self.plan.price_per_student return 0 else: return self.plan.price def update_monthly_fee(self): """Обновить ежемесячную плату на основе текущего количества учеников.""" if self.plan.subscription_type == 'per_student': from apps.users.models import Client self.monthly_fee = self.calculate_monthly_fee() self.students_count = Client.objects.filter(mentor=self.user).count() self.save(update_fields=['monthly_fee', 'students_count']) def reset_monthly_usage(self): """Сброс месячного использования.""" self.lessons_used = 0 self.video_minutes_used = 0 self.save() class Payment(models.Model): """ Модель платежа. """ STATUS_CHOICES = [ ('pending', 'Ожидает'), ('processing', 'Обрабатывается'), ('succeeded', 'Успешно'), ('failed', 'Ошибка'), ('cancelled', 'Отменен'), ('refunded', 'Возврат'), ] PAYMENT_METHOD_CHOICES = [ ('card', 'Карта'), ('yookassa', 'ЮKassa'), ('stripe', 'Stripe'), ('paypal', 'PayPal'), ('other', 'Другое'), ] # Основная информация uuid = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, verbose_name='UUID' ) user = models.ForeignKey( 'users.User', on_delete=models.CASCADE, related_name='payments', verbose_name='Пользователь' ) subscription = models.ForeignKey( Subscription, on_delete=models.CASCADE, related_name='payments', verbose_name='Подписка' ) # Сумма amount = models.DecimalField( max_digits=10, decimal_places=2, validators=[MinValueValidator(0)], verbose_name='Сумма' ) currency = models.CharField( max_length=3, default='RUB', verbose_name='Валюта' ) # Статус status = models.CharField( max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='Статус', db_index=True ) payment_method = models.CharField( max_length=20, choices=PAYMENT_METHOD_CHOICES, verbose_name='Метод оплаты' ) # Внешние данные external_id = models.CharField( max_length=255, blank=True, verbose_name='Внешний ID', db_index=True ) provider_response = models.JSONField( default=dict, blank=True, verbose_name='Ответ провайдера' ) # Описание description = models.TextField( blank=True, verbose_name='Описание' ) # Временные метки created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания', db_index=True ) paid_at = models.DateTimeField( null=True, blank=True, verbose_name='Дата оплаты' ) failed_at = models.DateTimeField( null=True, blank=True, verbose_name='Дата ошибки' ) refunded_at = models.DateTimeField( null=True, blank=True, verbose_name='Дата возврата' ) class Meta: db_table = 'payments' verbose_name = 'Платеж' verbose_name_plural = 'Платежи' ordering = ['-created_at'] indexes = [ models.Index(fields=['user', 'created_at']), models.Index(fields=['subscription']), models.Index(fields=['status']), models.Index(fields=['external_id']), ] def __str__(self): return f"{self.user.email} - {self.amount} {self.currency} ({self.get_status_display()})" def mark_as_succeeded(self): """Отметить платеж как успешный.""" self.status = 'succeeded' self.paid_at = timezone.now() self.save() # Обновляем подписку if self.subscription.status in ['trial', 'past_due']: self.subscription.status = 'active' self.subscription.save() def mark_as_failed(self, reason=''): """Отметить платеж как неудачный.""" self.status = 'failed' self.failed_at = timezone.now() if reason: self.description = reason self.save() def refund(self): """Возврат платежа.""" self.status = 'refunded' self.refunded_at = timezone.now() self.save() class PaymentHistory(models.Model): """ История изменений платежа. """ payment = models.ForeignKey( Payment, on_delete=models.CASCADE, related_name='history', verbose_name='Платеж' ) status = models.CharField( max_length=20, verbose_name='Статус' ) message = models.TextField( blank=True, verbose_name='Сообщение' ) data = models.JSONField( default=dict, blank=True, verbose_name='Данные' ) created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата', db_index=True ) class Meta: db_table = 'payment_history' verbose_name = 'История платежа' verbose_name_plural = 'История платежей' ordering = ['-created_at'] def __str__(self): return f"{self.payment.uuid} - {self.status}" class SubscriptionUsageLog(models.Model): """ Лог использования подписки. """ USAGE_TYPE_CHOICES = [ ('lesson', 'Занятие'), ('storage', 'Хранилище'), ('video_minutes', 'Минуты видео'), ] subscription = models.ForeignKey( Subscription, on_delete=models.CASCADE, related_name='usage_logs', verbose_name='Подписка' ) usage_type = models.CharField( max_length=20, choices=USAGE_TYPE_CHOICES, verbose_name='Тип использования' ) amount = models.IntegerField( verbose_name='Количество' ) description = models.CharField( max_length=255, blank=True, verbose_name='Описание' ) created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата', db_index=True ) class Meta: db_table = 'subscription_usage_logs' verbose_name = 'Лог использования' verbose_name_plural = 'Логи использования' ordering = ['-created_at'] indexes = [ models.Index(fields=['subscription', 'created_at']), models.Index(fields=['usage_type']), ] def __str__(self): return f"{self.subscription.user.email} - {self.get_usage_type_display()} ({self.amount})" class PromoCode(models.Model): """ Модель промокода для скидок на подписки. """ DISCOUNT_TYPE_CHOICES = [ ('percentage', 'Процент'), ('fixed', 'Фиксированная сумма'), ] # Основная информация code = models.CharField( max_length=50, unique=True, db_index=True, verbose_name='Код', help_text='Уникальный код промокода' ) description = models.TextField( blank=True, verbose_name='Описание' ) # Тип скидки discount_type = models.CharField( max_length=20, choices=DISCOUNT_TYPE_CHOICES, default='percentage', verbose_name='Тип скидки' ) # Размер скидки discount_value = models.DecimalField( max_digits=10, decimal_places=2, validators=[MinValueValidator(0)], verbose_name='Значение скидки', help_text='Процент или фиксированная сумма' ) # Ограничения max_discount_amount = models.DecimalField( max_digits=10, decimal_places=2, null=True, blank=True, validators=[MinValueValidator(0)], verbose_name='Максимальная сумма скидки', help_text='Для процентных скидок - ограничение максимальной суммы' ) # Срок действия valid_from = models.DateTimeField( null=True, blank=True, verbose_name='Действителен с', help_text='Если не указано, действует с момента создания' ) valid_until = models.DateTimeField( null=True, blank=True, verbose_name='Действителен до', help_text='Если не указано, действует бессрочно' ) # Лимиты использования max_uses_total = models.IntegerField( null=True, blank=True, validators=[MinValueValidator(1)], verbose_name='Максимум использований (общий)', help_text='Общее количество использований промокода' ) max_uses_per_user = models.IntegerField( default=1, validators=[MinValueValidator(1)], verbose_name='Максимум использований на пользователя', help_text='Сколько раз один пользователь может использовать промокод' ) # Статистика uses_count = models.IntegerField( default=0, verbose_name='Количество использований' ) # Ограничения is_active = models.BooleanField( default=True, verbose_name='Активен', db_index=True ) # Применяется только к определенным планам (без промежуточной модели) applicable_plans = models.ManyToManyField( SubscriptionPlan, blank=True, related_name='promo_codes', verbose_name='Применимые тарифы', help_text='Если не выбрано, применяется ко всем тарифам' ) # Временные метки created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания' ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления' ) class Meta: db_table = 'promo_codes' verbose_name = 'Промокод' verbose_name_plural = 'Промокоды' ordering = ['-created_at'] indexes = [ models.Index(fields=['code', 'is_active']), models.Index(fields=['valid_until']), ] def __str__(self): return f"{self.code} - {self.get_discount_display()}" def get_discount_display(self): """Отображение скидки.""" if self.discount_type == 'percentage': return f"{self.discount_value}%" return f"{self.discount_value} {self.currency if hasattr(self, 'currency') else 'RUB'}" def is_valid(self, user=None, plan=None): """ Проверка валидности промокода. Args: user: Пользователь (для проверки лимитов на пользователя) plan: Тарифный план (для проверки применимости) Returns: tuple: (is_valid: bool, error_message: str) """ # Проверка активности if not self.is_active: return False, "Промокод неактивен" # Проверка срока действия now = timezone.now() if self.valid_from and now < self.valid_from: return False, "Промокод еще не действует" if self.valid_until and now > self.valid_until: return False, "Промокод истек" # Проверка общего лимита использований if self.max_uses_total and self.uses_count >= self.max_uses_total: return False, "Промокод исчерпан" # Проверка лимита на пользователя if user: # Используем строку для избежания циклического импорта from django.apps import apps Subscription = apps.get_model('subscriptions', 'Subscription') user_uses = Subscription.objects.filter( user=user, promo_code=self ).count() if user_uses >= self.max_uses_per_user: return False, f"Вы уже использовали этот промокод максимальное количество раз ({self.max_uses_per_user})" # Проверка применимости к тарифу if plan and self.applicable_plans.exists(): if plan not in self.applicable_plans.all(): return False, "Промокод не применим к выбранному тарифу" return True, "" def calculate_discount(self, amount): """ Рассчитать сумму скидки. Args: amount: Исходная сумма Returns: tuple: (discount_amount: Decimal, final_amount: Decimal) """ from decimal import Decimal if self.discount_type == 'percentage': discount_amount = (amount * self.discount_value) / Decimal('100') # Применяем максимальную скидку, если указана if self.max_discount_amount: discount_amount = min(discount_amount, self.max_discount_amount) else: discount_amount = min(self.discount_value, amount) final_amount = max(Decimal('0'), amount - discount_amount) return discount_amount, final_amount def apply(self, user, subscription): """ Применить промокод к подписке. Args: user: Пользователь subscription: Подписка Returns: PromoCodeUsage: Объект использования промокода """ # Создаем запись об использовании from django.apps import apps PromoCodeUsage = apps.get_model('subscriptions', 'PromoCodeUsage') usage = PromoCodeUsage.objects.create( promo_code=self, user=user, subscription=subscription, discount_amount=subscription.discount_amount or 0, original_amount=subscription.original_amount or 0, final_amount=subscription.final_amount or 0 ) # Увеличиваем счетчик использований self.uses_count += 1 self.save(update_fields=['uses_count']) return usage class PromoCodeUsage(models.Model): """ История использования промокодов. """ promo_code = models.ForeignKey( PromoCode, on_delete=models.CASCADE, related_name='usages', verbose_name='Промокод' ) user = models.ForeignKey( 'users.User', on_delete=models.CASCADE, related_name='promo_code_usages', verbose_name='Пользователь' ) subscription = models.ForeignKey( Subscription, on_delete=models.CASCADE, related_name='promo_code_usages', verbose_name='Подписка' ) discount_amount = models.DecimalField( max_digits=10, decimal_places=2, verbose_name='Сумма скидки' ) original_amount = models.DecimalField( max_digits=10, decimal_places=2, verbose_name='Исходная сумма' ) final_amount = models.DecimalField( max_digits=10, decimal_places=2, verbose_name='Итоговая сумма' ) created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата использования', db_index=True ) class Meta: db_table = 'promo_code_usages' verbose_name = 'Использование промокода' verbose_name_plural = 'Использования промокодов' ordering = ['-created_at'] indexes = [ models.Index(fields=['user', 'created_at']), models.Index(fields=['promo_code', 'user']), ] def __str__(self): return f"{self.user.email} - {self.promo_code.code} ({self.discount_amount})"