import logging from django.db import transaction, models from django.utils import timezone from labs.models import ( User, UserCreditSummary, UserCreditDetail, CreditTransaction, UserLabRedemption, LabInstance, ) from labs.billing.user import init_user_credit_record from labs.billing.exceptions import InsufficientCreditsError logger = logging.getLogger(__name__) @transaction.atomic def redeem_lab_with_credits( *, user: User, lab_code: str, credits_to_deduct: int ) -> UserLabRedemption: """ Redeem lab access using user credits. Args: user: The user who is redeeming the lab lab_code: The lab to be redeemed credits_to_deduct: Amount of credits to be deducted Returns: UserLabRedemption: The created redemption record Raises: InsufficientCreditsError: If user doesn't have enough 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) 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 = UserLabRedemption.objects.create( user=user, lab_code=lab_code, ) CreditTransaction.objects.create( user=user, transaction_type=CreditTransaction.TransactionType.REDEEM_LAB, 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 lab {lab_code}, spend {credits_to_deduct} credits", redemption=redemption, ) return redemption def refund_credits_for_lab_failure(*, lab_instance: LabInstance): """ Refund credits for lab failure, it happened when lab instance failed to start. Args: lab_instance: The lab instance that failed to start Returns: None """ if lab_instance.status != LabInstance.LabInstanceStatus.CREATING_FAILED: logger.error( f"Lab instance {lab_instance.id} is not in creating_failed status, should not refund credits" ) return if lab_instance.access_type != LabInstance.LabAccessType.CREDITS: logger.info( f"Lab instance {lab_instance.id} is not redeemed with credits, skip refunding credits" ) return user = lab_instance.user with transaction.atomic(): credit_summary = UserCreditSummary.objects.select_for_update().get(user=user) previous = { "free_credits": credit_summary.free_credits, "paid_credits": credit_summary.paid_credits, } original_txn = CreditTransaction.objects.get( user=user, transaction_type=CreditTransaction.TransactionType.REDEEM_LAB, redemption__lab_instance=lab_instance, ) if CreditTransaction.objects.filter( user=user, transaction_type=CreditTransaction.TransactionType.REDEEM_LAB_REFUND, reference_transaction=original_txn, ).exists(): logger.info( f"Lab instance {lab_instance.id} is already refunded, skip refunding credits" ) return paid_credits_log = {} consumed_paid_credits = abs(original_txn.paid_credits) paid_credit_detail = UserCreditDetail.objects.get( user=user, credit_type=UserCreditDetail.CreditType.PAID ) if consumed_paid_credits > 0: paid_credit_detail.amount += consumed_paid_credits paid_credit_detail.save() paid_credits_log = { "id": paid_credit_detail.id, "amount": consumed_paid_credits, } free_credits_log = [] for fdl in original_txn.log["free_credits"]: consumed_free_credits = abs(fdl["amount"]) fd = UserCreditDetail.objects.get( user=user, credit_type=UserCreditDetail.CreditType.FREE, id=fdl["id"] ) fd.amount += consumed_free_credits fd.save() free_credits_log.append( { "id": fd.id, "amount": consumed_free_credits, } ) credit_summary.free_credits = ( UserCreditDetail.objects.filter( user=user, credit_type=UserCreditDetail.CreditType.FREE, amount__gt=0, expires_at__gte=timezone.now(), ).aggregate(total_amount=models.Sum("amount"))["total_amount"] or 0 ) credit_summary.paid_credits = paid_credit_detail.amount credit_summary.save() CreditTransaction.objects.create( user=user, transaction_type=CreditTransaction.TransactionType.REDEEM_LAB_REFUND, reference_transaction=original_txn, paid_credits=abs(original_txn.paid_credits), free_credits=abs(original_txn.free_credits), remark=f"Refund credits for lab {lab_instance.lab_code} {lab_instance.id}", log={ "paid_credits": paid_credits_log, "free_credits": free_credits_log, "previous": previous, }, )