You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

294 lines
12 KiB

from decimal import Decimal
import arrow
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.status != self.ACCEPTED:
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 course:
price = course.price
else:
if user:
school_payments = SchoolPayment.objects.filter(
user=user,
date_start__lte=date_start,
date_end__gte=date_start,
status__in=[
Pingback.PINGBACK_TYPE_REGULAR,
Pingback.PINGBACK_TYPE_GOODWILL,
Pingback.PINGBACK_TYPE_RISK_REVIEWED_ACCEPTED,
],
)
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 self.status is None and not self.bonus:
self.amount = amount_data.get('amount')
if isinstance(self, SchoolPayment):
self.weekdays = amount_data.get('weekdays')
super().save(*args, **kwargs)
if isinstance(self, CoursePayment) and self.is_paid():
author_balance = getattr(self, 'authorbalance', None)
if not author_balance:
AuthorBalance.objects.create(
author=self.course.author,
amount=self.amount,
payment=self,
)
else:
author_balance.amount = self.amount
author_balance.save()
# Если юзер реферал и нет платежа, где применялась скидка
if hasattr(self.user, 'referral') and not self.user.referral.payment and self.is_paid():
# Платеж - как сигнал, что скидка применилась
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')
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 UserBonus(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='bonuses')
amount = models.DecimalField(max_digits=8, decimal_places=2, default=0, editable=False)
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)
class Meta:
ordering = ('created_at',)