from datetime import timedelta, date, datetime from decimal import Decimal import arrow import short_url from django.db.models.functions import Coalesce from paymentwall import Pingback from polymorphic.models import PolymorphicModel from polymorphic.managers import PolymorphicManager from django.db.models import Func, F, Q, Max from django.db import models from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField, JSONField from django.core.validators import RegexValidator, MaxValueValidator from django.utils.timezone import now from django.conf import settings from apps.content.models import Package from project.utils import weekdays_in_date_range from apps.course.models import Course from apps.config.models import Config from apps.school.models import SchoolSchedule from apps.notification.utils import send_email from project.utils import dates_overlap config = Config.load() User = get_user_model() CREDIT_CARD_RE = r'^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}' \ r'|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\d{11})$' class AuthorBalance(models.Model): IN = 0 OUT = 1 TYPE_CHOICES = ( (IN, 'In'), (OUT, 'Out'), ) PENDING = 0 ACCEPTED = 1 DECLINED = 2 STATUS_CHOICES = ( (PENDING, 'Pending'), (ACCEPTED, 'Accepted'), (DECLINED, 'Declined'), ) author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Автор', null=True, blank=True, related_name='balances') type = models.PositiveSmallIntegerField('Тип', choices=TYPE_CHOICES, default=0) amount = models.DecimalField('Итого', max_digits=8, decimal_places=2, default=0) commission = models.DecimalField('Комиссия', max_digits=8, decimal_places=2, default=0) status = models.PositiveSmallIntegerField('Статус', choices=STATUS_CHOICES, default=0) payment = models.OneToOneField('Payment', on_delete=models.CASCADE, null=True, blank=True, verbose_name='Платёж') cause = models.TextField('Причина отказа', null=True, blank=True) card = models.CharField(max_length=20, validators=[RegexValidator(regex=CREDIT_CARD_RE)], null=True, blank=True) declined_send_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) update_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = 'Баланс' verbose_name_plural = 'Балансы' def save(self, *args, **kwargs): if self.type == self.IN and not self.id: self.commission = self.calc_commission() if self.type == self.OUT: if self.status == self.DECLINED and not self.declined_send_at: send_email( 'Отказ вывода средств', self.author.email, 'notification/email/decline_withdrawal.html', author_balance=self, ) self.declined_send_at = now() super().save(*args, **kwargs) def calc_commission(self): return self.amount * config.SERVICE_COMMISSION / 100 class PaymentManger(PolymorphicManager): def all(self): return self.filter(status__isnull=False) def paid(self): return self.filter(status__in=Payment.PW_PAID_STATUSES) class Payment(PolymorphicModel): PW_PAID_STATUSES = [ Pingback.PINGBACK_TYPE_REGULAR, Pingback.PINGBACK_TYPE_GOODWILL, Pingback.PINGBACK_TYPE_RISK_REVIEWED_ACCEPTED, ] PW_STATUS_CHOICES = ( (Pingback.PINGBACK_TYPE_REGULAR, 'regular',), (Pingback.PINGBACK_TYPE_GOODWILL, 'goodwill',), (Pingback.PINGBACK_TYPE_NEGATIVE, 'negative',), (Pingback.PINGBACK_TYPE_RISK_UNDER_REVIEW, 'risk under review',), (Pingback.PINGBACK_TYPE_RISK_REVIEWED_ACCEPTED, 'risk reviewed accepted',), (Pingback.PINGBACK_TYPE_RISK_REVIEWED_DECLINED, 'risk reviewed declined',), (Pingback.PINGBACK_TYPE_RISK_AUTHORIZATION_VOIDED, 'risk authorization voided',), (Pingback.PINGBACK_TYPE_SUBSCRIPTION_CANCELLATION, 'subscription cancelation',), (Pingback.PINGBACK_TYPE_SUBSCRIPTION_EXPIRED, 'subscription expired',), (Pingback.PINGBACK_TYPE_SUBSCRIPTION_PAYMENT_FAILED, 'subscription payment failed',), ) user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Пользователь', related_name='payments') amount = models.DecimalField('Итого', max_digits=8, decimal_places=2, default=0, editable=False) status = models.PositiveSmallIntegerField('Статус платежа', choices=PW_STATUS_CHOICES, null=True) data = JSONField('Данные платежа от провайдера', default={}, editable=False) roistat_visit = models.PositiveIntegerField('Номер визита Roistat', null=True, editable=False) created_at = models.DateTimeField(auto_now_add=True) update_at = models.DateTimeField(auto_now=True) bonus = models.ForeignKey('payment.UserBonus', blank=True, null=True, on_delete=models.SET_NULL, related_name='purchase_payments') objects = PaymentManger() class Meta: verbose_name = 'Платеж' verbose_name_plural = 'Платежи' ordering = ('created_at',) @classmethod def adjust_date_bounds(cls, date_start=None, date_end=None, is_camp=False): school_start = date((date_start or date_end).year, 9, 1) school_end = date((date_end or date_start).year, 5, 31) camp_start = date((date_start or date_end).year, 6, 1) camp_end = date((date_end or date_start).year, 8, 31) if date_start: if is_camp: if date_start < camp_start: date_start = camp_start elif school_end < date_start < school_start: date_start = school_start if date_end: if is_camp: if date_end > camp_end: date_end = camp_end elif school_end < date_end < school_start: date_end = school_end if date_start and date_end: return [date_start, date_end] else: return date_start or date_end @classmethod def date_add(cls, date_start, days=0, months=0, is_camp=False): date_end = arrow.get(date_start + timedelta(days=days), settings.TIME_ZONE).shift( months=months).date() - timedelta(1) if months == 1: if is_camp or (date_start.month == 2 and date_start.day >= 28) or ( date_start.day == 31 and date_end.day <= 30) \ or (date_start.month == 1 and date_start.day >= 29 and date_end.day == 28): date_end = date_start.replace(day=1, month=date_start.month + 1) - timedelta(1) return date_end @classmethod def get_date_range(cls, date_start=None, days=0, months=0, is_camp=False): date_start = date_start or now().date() if isinstance(date_start, datetime): date_start = date_start.date() date_start = cls.adjust_date_bounds(date_start=date_start, is_camp=is_camp) if is_camp and date_start.month == 6 and date_start.day > 16: date_start = date_start.replace(month=7, day=1) date_end = cls.date_add(date_start, days, months, is_camp) if is_camp: date_end = cls.adjust_date_bounds(date_end, is_camp=is_camp) return [date_start, date_end] @classmethod def calc_amount(cls, package=None, payment=None, user=None, course=None, date_start=None, weekdays=None, is_camp=False): price = 0 discount = 0 referral_bonus = 0 referrer_bonus = 0 if isinstance(payment, CoursePayment): course = payment.course user = payment.user if isinstance(payment, SchoolPayment): user = payment.user date_start = payment.date_start package=payment.package if isinstance(payment, DrawingCampPayment): user = payment.user date_start = payment.date_start if issubclass(cls, DrawingCampPayment): is_camp = True date_start, date_end = Payment.get_date_range(date_start, months=package.duration if package else 1, is_camp=is_camp) if hasattr(user, 'referral') and not user.referral.payment: referral_bonus = user.referral.bonus referrer_bonus = user.referral.referrer_bonus if payment and payment.is_paid(): price = payment.amount elif isinstance(payment, GiftCertificatePayment): price = payment.gift_certificate.price elif course: paid_before = CoursePayment.objects.filter(user=user, course=course, status__in=Payment.PW_PAID_STATUSES).exists() price = course.price / 2 if paid_before else course.price elif is_camp: if date_start.day == 1: price = DrawingCampPayment.MONTH_PRICE else: weekdays_count = weekdays_in_date_range(date_start, date_end) weekdays_count = sum(weekdays_count[wd] for wd in DrawingCampPayment.WEEKDAYS) all_weekdays_count = weekdays_in_date_range(date_start.replace(day=1), date_end) all_weekdays_count = sum(all_weekdays_count[wd] for wd in DrawingCampPayment.WEEKDAYS) price = round(DrawingCampPayment.MONTH_PRICE / all_weekdays_count * weekdays_count) else: if user: school_payments = SchoolPayment.objects.filter( user=user, date_start__lte=date_start, date_end__gte=date_start, status__in=Payment.PW_PAID_STATUSES, ) prev_school_payment = school_payments.filter(add_days=False).last() if prev_school_payment: date_start, date_end = Payment.get_date_range(prev_school_payment.date_end + timedelta(1), months=package.duration, is_camp=False) school_schedules = SchoolSchedule.objects.filter(is_camp=False).exclude(title='') weekdays = list(school_schedules.values_list('weekday', flat=True)) # FIXME после мая 2019 убрать? # Если хотят купить школу в мае, то оплатить ее можно только до 31 мая, потом школа закроется if date_start.month == 5: weekdays_count = weekdays_in_date_range(date_start, date_end) weekdays_count = sum(weekdays_count[wd] for wd in weekdays) all_weekdays_count = weekdays_in_date_range(date_start.replace(day=1), date_end) all_weekdays_count = sum(all_weekdays_count[wd] for wd in weekdays) price = package.price // all_weekdays_count * weekdays_count else: price = package.price amount = price - discount referral_bonus = round(amount * referral_bonus / 100) referrer_bonus = round(amount * referrer_bonus / 100) return { 'price': price, 'amount': amount, 'referral_bonus': referral_bonus, 'referrer_bonus': referrer_bonus, 'discount': discount, 'date_start': date_start, 'date_end': date_end, 'weekdays': weekdays, } # TODO? change to property? def calc_commission(self): return self.amount * config.SERVICE_COMMISSION / 100 # TODO change to property def is_paid(self): return self.status in self.PW_PAID_STATUSES # TODO? delete ? change to property def is_deliverable(self): return self.status in [ Pingback.PINGBACK_TYPE_REGULAR, Pingback.PINGBACK_TYPE_GOODWILL, Pingback.PINGBACK_TYPE_RISK_REVIEWED_ACCEPTED, ] # TODO change to property def is_under_review(self): return self.status == Pingback.PINGBACK_TYPE_RISK_UNDER_REVIEW # TODO change to property def is_cancelable(self): return self.status in [ Pingback.PINGBACK_TYPE_NEGATIVE, Pingback.PINGBACK_TYPE_RISK_REVIEWED_DECLINED, ] def save(self, *args, **kwargs): amount_data = type(self).calc_amount(payment=self) if not self.is_paid(): if not self.bonus: self.amount = amount_data.get('amount') if isinstance(self, SchoolPayment): self.weekdays = amount_data.get('weekdays') super().save(*args, **kwargs) if self.is_paid(): if isinstance(self, CoursePayment): if not getattr(self, 'authorbalance', None): AuthorBalance.objects.create( author=self.course.author, amount=self.amount, payment=self, ) if isinstance(self, GiftCertificatePayment): ugs, created = UserGiftCertificate.objects.get_or_create(user=self.user, gift_certificate=self.gift_certificate, payment=self) if created: from apps.notification.tasks import send_gift_certificate send_gift_certificate(ugs.id) # Если это не первая покупка, - отправляем бонусы юзеру if self.user.paid_one_more and not self.user.bonuses.filter( is_service=True, action_name=UserBonus.ACTION_PAID_ONE_MORE).count(): UserBonus.objects.create(user=self.user, amount=UserBonus.AMOUNT_PAID_ONE_MORE, is_service=True, action_name=UserBonus.ACTION_PAID_ONE_MORE) # Если юзер реферал и нет платежа, где применялась скидка # (после первой покупки реферала начисляются бонусы ему и пригласившему рефереру) if hasattr(self.user, 'referral') and not self.user.referral.payment: # Платеж - как сигнал, что была покупка и бонусы отправлены self.user.referral.payment = self self.user.referral.save() # Отправляем кэшбэк self.user.referral.send_bonuses(amount_data.get('referral_bonus'), amount_data.get('referrer_bonus')) class CoursePayment(Payment): course = models.ForeignKey(Course, on_delete=models.CASCADE, verbose_name='Курс', related_name='payments') access_expire = models.DateField('Доступ к курсу до даты', null=True) class Meta: verbose_name = 'Платеж за курс' verbose_name_plural = 'Платежи за курсы' class SchoolPayment(Payment): weekdays = ArrayField(models.IntegerField(), size=7, verbose_name='Дни недели') add_days = models.BooleanField('Докупленные дни', default=False) package = models.ForeignKey(Package, null=True, blank=True, on_delete=models.SET_NULL) date_start = models.DateField('Дата начала подписки', null=True, blank=True) date_end = models.DateField('Дата окончания подписки', null=True, blank=True) class Meta: verbose_name = 'Платеж за школу' verbose_name_plural = 'Платежи за школу' def __str__(self): days = ', '.join([ dict(SchoolSchedule.WEEKDAY_CHOICES).get(weekday, '') for weekday in sorted(self.weekdays) ]) return days @property def date_end_humanize(self): return arrow.get(self.date_end, settings.TIME_ZONE).humanize(locale='ru') class DrawingCampPayment(Payment): MONTH_PRICE = Decimal(1990) WEEKDAYS = {1, 3, 5} date_start = models.DateField('Дата начала подписки', null=True, blank=True) date_end = models.DateField('Дата окончания подписки', null=True, blank=True) class Meta: verbose_name = 'Платеж за рисовальный лагерь' verbose_name_plural = 'Платежи за рисовальный лагерь' def __str__(self): return '%s - %s' % (self.date_start.strftime('%d/%m/%Y'), self.date_end.strftime('%d/%m/%Y')) @property def date_end_humanize(self): return arrow.get(self.date_end, settings.TIME_ZONE).humanize(locale='ru') class GiftCertificatePayment(Payment): gift_certificate = models.ForeignKey('GiftCertificate', on_delete=models.CASCADE, verbose_name='Подарочный сертификат', related_name='payments') class Meta: verbose_name = 'Платеж за подарочный сертификат' verbose_name_plural = 'Платежи за подарочные сертификаты' class UserBonus(models.Model): ACTION_FILL_PROFILE = 'fill_profile' ACTION_PAID_ONE_MORE = 'paid_one_more' ACTION_HAVE_REVIEW = 'have_review' ACTION_CHILD_BIRTHDAY = 'child_birthday' AMOUNT_FILL_PROFILE = 50 AMOUNT_PAID_ONE_MORE = 100 AMOUNT_HAVE_REVIEW = 300 AMOUNT_CHILD_BIRTHDAY = 200 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='bonuses') amount = models.DecimalField(max_digits=8, decimal_places=2, default=0) payment = models.ForeignKey(Payment, on_delete=models.SET_NULL, null=True, blank=True) referral = models.ForeignKey('user.Referral', on_delete=models.SET_NULL, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) is_service = models.BooleanField(default=False) action_name = models.CharField(max_length=20, blank=True, default='') notified_at = models.DateTimeField(blank=True, null=True) class Meta: ordering = ('created_at',) def __str__(self): return '%s %s' % (self.user_id, self.amount) class GiftCertificate(models.Model): price = models.DecimalField(max_digits=8, decimal_places=2, default=0) cover = models.CharField(max_length=255, blank=True, default='') class Meta: ordering = ('price',) class UserGiftCertificate(models.Model): gift_certificate = models.ForeignKey(GiftCertificate, on_delete=models.CASCADE,) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gift_certificates') payment = models.ForeignKey(Payment, on_delete=models.SET_NULL, null=True) bonuses_sent = models.ForeignKey(UserBonus, on_delete=models.CASCADE, blank=True, null=True) @property def code(self): return short_url.encode_url(self.id) if self.id else None class DiscountManager(models.Manager): def get_actual_discounts(self, user=None): now_dt = now() qs = self.filter(start__lte=now_dt, end__gte=now_dt, is_active=True, promo_id__isnull=True) if user: qs = qs.filter(Q(client=user) | Q(client__isnull=True)) else: qs = qs.filter(client__isnull=True) return qs def get_courses_discounts(self, user=None, course=None): actual_discounts = self.get_actual_discounts(user=user) all_courses_max_discount = actual_discounts.filter(product=Discount.PRODUCT_ALL_COURSES)\ .aggregate(max_discount=Max('value')) if course: actual_discounts = actual_discounts.filter(course=course) courses_max_discounts = actual_discounts.filter(product=Discount.PRODUCT_ONE_COURSE).values('course')\ .annotate(max_discount=Max('value')) return all_courses_max_discount, courses_max_discounts def get_packages_discounts(self, user=None, package=None): actual_discounts = self.get_actual_discounts(user=user) all_packages_max_discount = actual_discounts.filter(product=Discount.PRODUCT_ALL_PACKAGES) \ .aggregate(max_discount=Max('value')) if package: actual_discounts = actual_discounts.filter(package=package) packages_max_discounts = actual_discounts.filter(product=Discount.PRODUCT_ONE_PACKAGE).values('package') \ .annotate(max_discount=Max('value')) return all_packages_max_discount, packages_max_discounts class Discount(models.Model): PRODUCT_ALL_COURSES = 0 PRODUCT_ALL_PACKAGES = 1 PRODUCT_ONE_COURSE = 2 PRODUCT_ONE_PACKAGE = 3 USAGE_TYPE_LIMIT = 0 USAGE_TYPE_UNLIMITED = 1 PRODUCT_CHOICES = ( (PRODUCT_ALL_COURSES, 'Все курсы'), (PRODUCT_ALL_PACKAGES, 'Все подписки'), (PRODUCT_ONE_COURSE, 'Один курс'), (PRODUCT_ONE_PACKAGE, 'Одна подписка') ) USAGE_TYPE_CHOICES = ( (USAGE_TYPE_LIMIT, 'Ограничен по количеству использований'), (USAGE_TYPE_UNLIMITED, 'Не ограничен по количеству использований') ) name = models.CharField(verbose_name='Название', max_length=200) client = models.ForeignKey(User, on_delete=models.CASCADE, related_name='discounts', null=True, blank=True, verbose_name='Пользователь скидки') product = models.PositiveSmallIntegerField('На какие продукты распространяется скидка', choices=PRODUCT_CHOICES, default=PRODUCT_ALL_COURSES) course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_discounts', null=True, blank=True, verbose_name='Курс на который распространяется скидка') package = models.ForeignKey(Package, verbose_name='Подписка на которую распространяется скидка', on_delete=models.CASCADE, related_name='package_discounts', null=True, blank=True) promo_id = models.CharField(verbose_name='Промокод', max_length=20, null=True, blank=True) usage_type = models.PositiveSmallIntegerField(verbose_name='Тип использования', choices=USAGE_TYPE_CHOICES, default=USAGE_TYPE_LIMIT) usage_count_limit = models.PositiveIntegerField(verbose_name='Сколько раз можно использовать', null=True, blank=True) activate_count = models.PositiveIntegerField(verbose_name='Сколько раз использован', default=0) value = models.PositiveIntegerField(verbose_name='Размер скидки в %', default=0, validators=[MaxValueValidator(100, 'Размер скидки не может быть больше 100')]) start = models.DateTimeField(verbose_name='Дата начала действия скидки', default=now) end = models.DateTimeField(verbose_name='Дата окончания скидки') is_active = models.BooleanField(verbose_name='Активность', default=True) author = models.ForeignKey(User, verbose_name='Автор', null=True, blank=True, related_name='author_discounts', on_delete=models.CASCADE) objects = DiscountManager() def __str__(self): return self.name class Meta: verbose_name = 'Скидка' verbose_name_plural = 'Скидки'