326 lines
12 KiB
Python
326 lines
12 KiB
Python
import logging
|
||
|
||
from django.conf import settings
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import models
|
||
from django.db.models import Sum
|
||
from django.utils import timezone
|
||
from django.utils.crypto import get_random_string
|
||
from django.utils.formats import localize
|
||
from django.utils.functional import cached_property
|
||
|
||
from core.models import BonusProgramConfig
|
||
from store.models import Checklist
|
||
from tg_bot.messages import TGBonusMessage
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BonusType:
|
||
# Другое начисление
|
||
OTHER_DEPOSIT = 0
|
||
|
||
# Клиент передал номер ТГ-боту
|
||
SIGNUP = 1
|
||
|
||
# Клиент сделал заказ
|
||
DEFAULT_PURCHASE = 2
|
||
|
||
# Приглашенный клиент сделал свою первую покупку, бонус реферреру
|
||
FOR_INVITER = 3
|
||
|
||
# Клиент сделал заказ и получил бонус за первую покупку от реферрера
|
||
INVITED_FIRST_PURCHASE = 4
|
||
|
||
# Другое списание
|
||
OTHER_WITHDRAWAL = 10
|
||
|
||
# Клиент потратил баллы на заказ
|
||
SPENT_PURCHASE = 11
|
||
|
||
CHOICES = (
|
||
(OTHER_DEPOSIT, 'Другое начисление'),
|
||
(SIGNUP, 'Бонус за регистрацию'),
|
||
(DEFAULT_PURCHASE, 'Бонус за покупку'),
|
||
(FOR_INVITER, 'Бонус за первую покупку приглашенного'),
|
||
(INVITED_FIRST_PURCHASE, 'Бонус за первую покупку'),
|
||
|
||
(OTHER_WITHDRAWAL, 'Другое списание'),
|
||
(SPENT_PURCHASE, 'Списание бонусов за заказ'),
|
||
)
|
||
|
||
LOG_NAMES = {
|
||
OTHER_DEPOSIT: 'OTHER_DEPOSIT',
|
||
SIGNUP: 'SIGNUP',
|
||
DEFAULT_PURCHASE: 'DEFAULT_PURCHASE',
|
||
FOR_INVITER: 'FOR_INVITER',
|
||
INVITED_FIRST_PURCHASE: 'INVITED_FIRST_PURCHASE',
|
||
|
||
OTHER_WITHDRAWAL: 'OTHER_WITHDRAWAL',
|
||
SPENT_PURCHASE: 'SPENT_PURCHASE',
|
||
}
|
||
|
||
ONE_TIME_TYPES = {SIGNUP, FOR_INVITER, INVITED_FIRST_PURCHASE}
|
||
ORDER_TYPES = {DEFAULT_PURCHASE, FOR_INVITER, INVITED_FIRST_PURCHASE, SPENT_PURCHASE}
|
||
|
||
|
||
class BonusProgramTransactionQuerySet(models.QuerySet):
|
||
def with_base_related(self):
|
||
return self.select_related('order', 'user')
|
||
|
||
def cancel(self):
|
||
for t in self:
|
||
t.cancel()
|
||
|
||
|
||
class BonusProgramTransaction(models.Model):
|
||
""" Represents the history of all bonus program transactions """
|
||
|
||
type = models.PositiveSmallIntegerField('Тип транзакции', choices=BonusType.CHOICES)
|
||
user = models.ForeignKey('account.User', verbose_name='Пользователь транзакции', on_delete=models.CASCADE, related_name='bonus_transactions')
|
||
date = models.DateTimeField('Дата транзакции', auto_now_add=True)
|
||
amount = models.SmallIntegerField('Количество, руб')
|
||
comment = models.CharField('Комментарий', max_length=200, null=True, blank=True)
|
||
was_cancelled = models.BooleanField('Была отменена', editable=False, default=False)
|
||
|
||
# Bound objects
|
||
order = models.ForeignKey('store.Checklist', verbose_name="Связанный заказ", null=True, blank=True, on_delete=models.SET_NULL)
|
||
|
||
objects = BonusProgramTransactionQuerySet.as_manager()
|
||
|
||
class Meta:
|
||
ordering = ['-date']
|
||
verbose_name = "История баланса"
|
||
verbose_name_plural = "История баланса"
|
||
|
||
def _notify_user_about_new_transaction(self):
|
||
msg = None
|
||
|
||
match self.type:
|
||
case BonusType.SIGNUP:
|
||
msg = TGBonusMessage.SIGNUP.format(amount=self.amount)
|
||
|
||
case BonusType.DEFAULT_PURCHASE:
|
||
msg = TGBonusMessage.PURCHASE_ADDED.format(amount=self.amount, order_id=self.order.id)
|
||
|
||
case BonusType.FOR_INVITER:
|
||
msg = TGBonusMessage.FOR_INVITER.format(amount=self.amount)
|
||
|
||
case BonusType.INVITED_FIRST_PURCHASE:
|
||
msg = TGBonusMessage.INVITED_FIRST_PURCHASE.format(amount=self.amount, order_id=self.order.id)
|
||
|
||
case BonusType.OTHER_DEPOSIT:
|
||
comment = self.comment or "—"
|
||
msg = TGBonusMessage.OTHER_DEPOSIT.format(amount=self.amount, comment=comment)
|
||
|
||
case BonusType.OTHER_WITHDRAWAL:
|
||
comment = self.comment or "—"
|
||
msg = TGBonusMessage.OTHER_WITHDRAWAL.format(amount=abs(self.amount), comment=comment)
|
||
|
||
case BonusType.SPENT_PURCHASE:
|
||
msg = TGBonusMessage.PURCHASE_SPENT.format(amount=abs(self.amount), order_id=self.order_id)
|
||
|
||
case _:
|
||
pass
|
||
|
||
if msg is not None:
|
||
self.user.notify_tg_bot(msg)
|
||
|
||
def cancel(self):
|
||
# Skip transactions that refers to cancelled ones
|
||
if self.was_cancelled:
|
||
return
|
||
|
||
date_formatted = localize(timezone.localtime(self.date))
|
||
|
||
if self.amount > 0:
|
||
comment = f"Отмена начисления #{self.id} от {date_formatted}"
|
||
bonus_type = BonusType.OTHER_WITHDRAWAL
|
||
elif self.amount < 0:
|
||
comment = f"Отмена списания #{self.id} от {date_formatted}"
|
||
bonus_type = BonusType.OTHER_DEPOSIT
|
||
else:
|
||
return
|
||
|
||
# Create reverse transaction, user's balance will be recalculated in post_save signal
|
||
transaction = BonusProgramTransaction(
|
||
user_id=self.user_id,
|
||
type=bonus_type,
|
||
amount=self.amount * -1,
|
||
comment=comment,
|
||
order=self.order,
|
||
was_cancelled=True
|
||
)
|
||
transaction.save()
|
||
|
||
self.was_cancelled = True
|
||
self.save()
|
||
|
||
def delete(self, *args, **kwargs):
|
||
# Don't delete transaction, cancel it instead
|
||
self.cancel()
|
||
|
||
def clean(self):
|
||
# No underflow or dummy transactions allowed. Fail loudly
|
||
if self.amount == 0 or (self.user.balance + self.amount) < 0:
|
||
raise ValidationError("No underflow or dummy transactions allowed")
|
||
|
||
# Check for uniqueness for given user
|
||
qs = self.user.bonus_history.filter(type=self.type)
|
||
if self.id:
|
||
qs = qs.exclude(id=self.id)
|
||
|
||
bonus_name = BonusType.LOG_NAMES.get(self.type, self.type)
|
||
|
||
if self.type in BonusType.ONE_TIME_TYPES:
|
||
if qs.exists():
|
||
raise ValidationError(f"User {self.user_id} already got {bonus_name} one-time bonus")
|
||
|
||
if self.type in BonusType.ORDER_TYPES:
|
||
# Check that order is defined
|
||
if self.order_id is None:
|
||
raise ValidationError("Order is required for that type")
|
||
|
||
# Check for duplicates for the same order
|
||
already_exists = qs.filter(order_id=self.order_id).exists()
|
||
if already_exists:
|
||
raise ValidationError(f"User {self.user_id} already got {bonus_name} bonus for order #{self.order_id}")
|
||
|
||
def save(self, *args, **kwargs):
|
||
try:
|
||
self.full_clean()
|
||
except Exception as e:
|
||
# Catch all validation errors here and log it
|
||
logger.error(f"Error during bonus saving: {e}")
|
||
return
|
||
|
||
self.user.recalculate_balance()
|
||
|
||
if self.id is None:
|
||
self._notify_user_about_new_transaction()
|
||
|
||
return super().save(*args, **kwargs)
|
||
|
||
|
||
def generate_referral_code():
|
||
""" Generate unique numeric referral code for User """
|
||
from account.models import User
|
||
|
||
while True:
|
||
allowed_chars = "0123456789"
|
||
code = get_random_string(settings.REFERRAL_CODE_LENGTH, allowed_chars)
|
||
|
||
# Hacky code for migrations
|
||
if "referral_code" not in User._meta.fields:
|
||
return code
|
||
|
||
if not User.objects.filter(referral_code=code).exists():
|
||
return code
|
||
|
||
|
||
class BonusProgramMixin(models.Model):
|
||
"""BonusProgram fields for User model"""
|
||
|
||
balance = models.PositiveSmallIntegerField('Баланс, руб', default=0, editable=False)
|
||
referral_code = models.CharField(max_length=settings.REFERRAL_CODE_LENGTH, default=generate_referral_code,
|
||
editable=False)
|
||
|
||
class Meta:
|
||
abstract = True
|
||
|
||
@cached_property
|
||
def bonus_history(self):
|
||
return BonusProgramTransaction.objects.filter(user_id=self.id)
|
||
|
||
def update_balance(self, amount, bonus_type, comment=None, order=None):
|
||
# No underflow or dummy transactions allowed, fail silently
|
||
if amount == 0 or (self.balance + amount) < 0:
|
||
return
|
||
|
||
# Create bonus transaction, user's balance will be recalculated in post_save signal
|
||
transaction = BonusProgramTransaction(user_id=self.id,
|
||
amount=amount, type=bonus_type,
|
||
comment=comment, order=order)
|
||
transaction.save()
|
||
self.refresh_from_db(fields=['balance'])
|
||
|
||
def recalculate_balance(self):
|
||
total_balance = self.bonus_history \
|
||
.aggregate(total_balance=Sum('amount'))['total_balance'] or 0
|
||
|
||
self.balance = max(0, total_balance)
|
||
self.save(update_fields=['balance'])
|
||
|
||
|
||
class BonusProgram:
|
||
@staticmethod
|
||
def spend_bonuses(order: 'Checklist'):
|
||
# Check if data is sufficient
|
||
if order is None or order.customer_id is None:
|
||
return
|
||
|
||
# Always use fresh balance
|
||
order.customer.recalculate_balance()
|
||
|
||
# Spend full_price bonuses or nothing
|
||
to_spend = min(order.customer.balance, order.full_price)
|
||
order.customer.update_balance(-to_spend, BonusType.SPENT_PURCHASE, order=order)
|
||
|
||
@staticmethod
|
||
def add_signup_bonus(user: 'User'):
|
||
bonus_type = BonusType.SIGNUP
|
||
amount = BonusProgramConfig.load().amount_signup
|
||
|
||
user.update_balance(amount, bonus_type)
|
||
|
||
@staticmethod
|
||
def add_order_bonus(order: Checklist):
|
||
bonus_type = BonusType.DEFAULT_PURCHASE
|
||
|
||
# Check if data is sufficient
|
||
if order is None or order.customer_id is None:
|
||
return
|
||
|
||
# Check if eligible
|
||
if order.status != settings.BONUS_ELIGIBILITY_STATUS:
|
||
return
|
||
|
||
level = BonusProgramLevel.objects.level_for_order_count(order.customer.completed_orders_count)
|
||
amount = getattr(level, 'amount_default_purchase', 0)
|
||
|
||
# Add bonuses
|
||
order.customer.update_balance(amount, bonus_type, order=order)
|
||
|
||
@staticmethod
|
||
def add_referral_bonus(order: Checklist, for_inviter: bool):
|
||
amount = BonusProgramConfig.load().amount_referral
|
||
|
||
# Check if data is sufficient
|
||
if order.customer_id is None or order.customer.inviter is None:
|
||
return
|
||
|
||
# Check if eligible
|
||
# Bonus is only for first purchase and only for orders that reached the COMPLETED status
|
||
if order.status != settings.BONUS_ELIGIBILITY_STATUS or order.customer.customer_orders.count() != 1:
|
||
return
|
||
|
||
user = order.customer.inviter if for_inviter else order.customer
|
||
bonus_type = BonusType.FOR_INVITER if for_inviter else BonusType.INVITED_FIRST_PURCHASE
|
||
|
||
# Add bonuses
|
||
user.update_balance(amount, bonus_type, order=order)
|
||
|
||
|
||
class BonusProgramLevelQuerySet(models.QuerySet):
|
||
def level_for_order_count(self, count):
|
||
return self.filter(orders_count__lt=count).order_by('-orders_count').first()
|
||
|
||
|
||
class BonusProgramLevel(models.Model):
|
||
slug = models.SlugField('Идентификатор', unique=True)
|
||
name = models.CharField('Название', max_length=30)
|
||
orders_count = models.PositiveSmallIntegerField('Минимальное количество заказов', unique=True)
|
||
amount_default_purchase = models.PositiveSmallIntegerField('Бонус за обычную покупку')
|
||
|
||
objects = BonusProgramLevelQuerySet.as_manager()
|