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()