import arrow import short_url from django.db.models import Func, F from paymentwall import Pingback from polymorphic.models import PolymorphicModel from polymorphic.managers import PolymorphicManager 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 from django.utils.timezone import now from django.conf import settings 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 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}|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) 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', 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 add_months(cls, sourcedate, months=1): result = arrow.get(sourcedate, settings.TIME_ZONE).shift(months=months) if months == 1: if (sourcedate.month == 2 and sourcedate.day >= 28) or (sourcedate.day == 31 and result.day <= 30) \ or (sourcedate.month == 1 and sourcedate.day >= 29 and result.day == 28): result = result.replace(day=1, month=result.month + 1) return result.datetime @classmethod def calc_amount(cls, payment=None, user=None, course=None, date_start=None, weekdays=None): date_start = date_start or now().date() date_end = Payment.add_months(date_start, 1) 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 weekdays = payment.weekdays date_start = payment.date_start 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 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, ) school_schedules_purchased = school_payments.annotate( joined_weekdays=Func(F('weekdays'), function='unnest', ) ).values_list('joined_weekdays', flat=True).distinct() weekdays = list(set(map(int, weekdays)) - set(school_schedules_purchased)) prev_school_payment = school_payments.filter(add_days=False).last() add_days = bool(prev_school_payment) else: add_days = False school_schedules = SchoolSchedule.objects.filter( weekday__in=weekdays, ) if add_days: date_end = prev_school_payment.date_end weekdays_count = weekdays_in_date_range(date_start, prev_school_payment.date_end) all_weekdays_count = weekdays_in_date_range(prev_school_payment.date_start, prev_school_payment.date_end) for ss in school_schedules: price += ss.month_price // all_weekdays_count.get(ss.weekday, 0) * weekdays_count.get( ss.weekday, 0) else: price = school_schedules.aggregate( models.Sum('month_price'), ).get('month_price__sum', 0) if not (payment and payment.id) and price >= config.SERVICE_DISCOUNT_MIN_AMOUNT: discount = config.SERVICE_DISCOUNT 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 = Payment.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) 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 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) referral = models.ForeignKey('user.Referral', on_delete=models.SET_NULL, null=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',) 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