from datetime import timedelta, date, datetime from decimal import Decimal import arrow import short_url from paymentwall import Pingback from polymorphic.models import PolymorphicModel from polymorphic.managers import PolymorphicManager from django.db.models import Func, F 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 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}|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