kwork-poizonstore/account/models/bonus.py
phzhik 00686e9dc4 + BonusProgramConfig
* Moved GlobalSettings to core app
* Moved bonus program logic from User to BonusProgram class
* Worked on error handling a bit
2024-05-24 02:19:00 +04:00

309 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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):
# TODO: optimize queries
def with_base_related(self):
return self.select_related('order')
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('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
amount = BonusProgramConfig.load().amount_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
self.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)