import logging from contextlib import suppress from django.conf import settings 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 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 # Отмена начисления CANCELLED_DEPOSIT = 20 # Отмена списания CANCELLED_WITHDRAWAL = 21 CHOICES = ( (OTHER_DEPOSIT, 'Другое начисление'), (SIGNUP, 'Бонус за регистрацию'), (DEFAULT_PURCHASE, 'Бонус за покупку'), (FOR_INVITER, 'Бонус за первую покупку приглашенного'), (INVITED_FIRST_PURCHASE, 'Бонус за первую покупку'), (OTHER_WITHDRAWAL, 'Другое списание'), (SPENT_PURCHASE, 'Списание бонусов за заказ'), (CANCELLED_DEPOSIT, 'Отмена начисления'), (CANCELLED_WITHDRAWAL, 'Отмена списания'), ) class BonusProgramTransactionQuerySet(models.QuerySet): # TODO: optimize queries def with_base_related(self): return self class BonusProgramTransaction(models.Model): """ Represents the history of all bonus program transactions """ type = models.PositiveSmallIntegerField('Тип транзакции', choices=BonusType.CHOICES) user = models.ForeignKey('User', verbose_name='Пользователь транзакции', on_delete=models.CASCADE) 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 | BonusType.CANCELLED_DEPOSIT: comment = self.comment or "—" msg = TGBonusMessage.OTHER_DEPOSIT.format(amount=self.amount, comment=comment) case BonusType.OTHER_WITHDRAWAL | BonusType.CANCELLED_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_transaction(self): # Skip already cancelled transactions # TODO: if reverse transaction is being deleted, revert the source one? if self.was_cancelled or self.type in (BonusType.OTHER_WITHDRAWAL, BonusType.OTHER_DEPOSIT): 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() transaction.user_id = self.user_id transaction.type = bonus_type transaction.amount = self.amount * -1 transaction.comment = comment transaction.order = self.order transaction.save() self.was_cancelled = True self.save() def delete(self, *args, **kwargs): # Don't delete transaction, cancel it instead self.cancel_transaction() def save(self, *args, **kwargs): 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): 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 @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 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() def recalculate_balance(self): # TODO: use this method when checking the available balance upon order creation total_balance = BonusProgramTransaction.objects \ .filter(user_id=self.id) \ .aggregate(total_balance=Sum('amount'))['total_balance'] or 0 self.balance = max(0, total_balance) self.save(update_fields=['balance']) def add_signup_bonus(self): bonus_type = BonusType.SIGNUP amount = self._get_bonus_amount("signup") already_exists = (BonusProgramTransaction.objects .filter(user_id=self.id, type=bonus_type) .exists()) if already_exists: self._log(logging.INFO, "User already had signup bonus") return self.update_balance(amount, bonus_type) def add_order_bonus(self, order): from store.models import Checklist bonus_type = BonusType.DEFAULT_PURCHASE amount = self._get_bonus_amount("default_purchase") if order.status != Checklist.Status.CHINA_RUSSIA: return already_exists = (BonusProgramTransaction.objects .filter(user_id=self.id, type=bonus_type, order_id=order.id) .exists()) if already_exists: self._log(logging.INFO, f"User already got bonus for order #{order.id}") return self.update_balance(amount, bonus_type, order=order) def add_referral_bonus(self, order: Checklist, for_inviter: bool): amount = self._get_bonus_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 CHINA_RUSSIA status if order.status != Checklist.Status.CHINA_RUSSIA 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 # Check if user didn't receive bonus yet already_exists = (BonusProgramTransaction.objects .filter(user_id=user.id, type=bonus_type, order_id=order.id) .exists()) if already_exists: self._log(logging.INFO, f"User already got referral bonus for order #{order.id}") return # Add bonuses user.update_balance(amount, bonus_type, order=order) @staticmethod def _get_bonus_amount(config_key) -> int: amount = 0 with suppress(KeyError): amount = settings.BONUS_PROGRAM_CONFIG["amounts"][config_key] return amount # TODO: move to custom logger def _log(self, level, message: str): message = f"[BonusProgram #{self.id}] {message}" logger.log(level, message)