import logging from django.db import transaction from django.utils import timezone from labs.models import ( User, UserCreditSummary, UserCreditDetail, CreditTransaction, Exam, UserExamRedemption, ) from labs.billing.user import init_user_credit_record from labs.billing.exceptions import InsufficientCreditsError logger = logging.getLogger(__name__) @transaction.atomic def redeem_exam_with_credits(*, user: User, exam: Exam) -> UserExamRedemption: """ Redeem exam access using user credits. Args: user: The user who is redeeming the exam exam: The exam to be redeemed Returns: UserExamRedemption: The created redemption record Raises: InsufficientCreditsError: If user doesn't have enough credits """ credits_to_deduct = exam.credits assert credits_to_deduct >= 0 assert isinstance(credits_to_deduct, int) try: credit_summary = UserCreditSummary.objects.select_for_update().get(user=user) except UserCreditSummary.DoesNotExist: init_user_credit_record(user) credit_summary = UserCreditSummary.objects.select_for_update().get(user=user) existing_redemption = ( UserExamRedemption.objects.filter( user=user, exam=exam, exam_attempt__isnull=True, ) .order_by("id") .first() ) if existing_redemption: return existing_redemption previous = { "free_credits": credit_summary.free_credits, "paid_credits": credit_summary.paid_credits, } # credit_summary's amount may not be accurate due to some free credits may have expired # but it's always bigger than or equal to the actual amount estimated_total_amount = credit_summary.free_credits + credit_summary.paid_credits if estimated_total_amount < credits_to_deduct: raise InsufficientCreditsError() now = timezone.now() paid_credit_detail = UserCreditDetail.objects.get( user=user, credit_type=UserCreditDetail.CreditType.PAID ) free_credit_details = UserCreditDetail.objects.filter( user=user, credit_type=UserCreditDetail.CreditType.FREE, amount__gt=0, expires_at__gte=now, ).order_by("expires_at", "id") # This is a accurate amount of credits that the user currently has total_amount = paid_credit_detail.amount + sum( detail.amount for detail in free_credit_details ) if total_amount < credits_to_deduct: raise InsufficientCreditsError() # Deduct expiring free credits first, then paid credits. remaining_credits_to_deduct = credits_to_deduct deducted_free_credits = 0 deducted_paid_credits = 0 free_credits_log = [] paid_credits_log = {} for fd in free_credit_details: if remaining_credits_to_deduct <= 0: break deduct_amount = min(fd.amount, remaining_credits_to_deduct) fd.amount -= deduct_amount remaining_credits_to_deduct -= deduct_amount deducted_free_credits += deduct_amount free_credits_log.append( { "id": fd.id, "amount": -deduct_amount, } ) fd.save() if remaining_credits_to_deduct > 0: deducted_paid_credits = min( paid_credit_detail.amount, remaining_credits_to_deduct ) remaining_credits_to_deduct -= deducted_paid_credits paid_credit_detail.amount -= deducted_paid_credits paid_credits_log = { "id": paid_credit_detail.id, "amount": -deducted_paid_credits, } paid_credit_detail.save() if remaining_credits_to_deduct > 0: raise InsufficientCreditsError() credit_summary.free_credits = sum( detail.amount for detail in free_credit_details if detail.amount > 0 ) credit_summary.paid_credits = paid_credit_detail.amount credit_summary.save() redemption = UserExamRedemption.objects.create( user=user, exam=exam, redeem_type=UserExamRedemption.RedeemType.CREDITS, redeem_detail={ "credits": credits_to_deduct, }, ) CreditTransaction.objects.create( user=user, transaction_type=CreditTransaction.TransactionType.REDEEM_EXAM, paid_credits=-deducted_paid_credits, free_credits=-deducted_free_credits, log={ "paid_credits": paid_credits_log, "free_credits": free_credits_log, "previous": previous, }, remark=f"Redeem exam {exam.code}, spend {credits_to_deduct} credits", exam_redemption=redemption, ) return redemption