"""Order lifecycle helpers — create, complete, fail.""" from __future__ import annotations from typing import Optional from django.conf import settings from django.db import transaction from django.utils import timezone from apps.enrollments.models import Enrollment from ..models import Gateway, Order, OrderStatus, PromoCode, Referral from .gateway import PaymentGatewayError, get_payment_backend class OrderError(Exception): """Raised by start_checkout when an order can't be created.""" def _split(amount_toman: int, commission_percent: int) -> tuple[int, int]: fee = (amount_toman * commission_percent) // 100 instructor_share = amount_toman - fee return fee, instructor_share def _resolve_discount(course, base_amount: int, *, promo_code: str | None, referral_code: str | None, buyer): """Return (discount_toman, promo_code_obj, referrer_user, referrer_credit_toman). Raises OrderError on validation failures.""" from apps.accounts.models import User if promo_code and referral_code: raise OrderError("Use either a promo code OR a referral code, not both.") if promo_code: code = promo_code.strip().upper() try: pc = PromoCode.objects.get(code=code) except PromoCode.DoesNotExist: raise OrderError("Promo code not found.") if not pc.is_valid_now(): raise OrderError("Promo code is not currently valid.") if not pc.applies_to_course(course): raise OrderError("Promo code does not apply to this course.") return pc.discount_for(base_amount), pc, None, 0 if referral_code: code = referral_code.strip().upper() try: referrer = User.objects.get(referral_code=code) except User.DoesNotExist: raise OrderError("Referral code not found.") if buyer is not None and referrer.id == buyer.id: raise OrderError("You cannot apply your own referral code.") buyer_pct = int(getattr(settings, "REFERRAL_BUYER_DISCOUNT_PERCENT", 10)) ref_pct = int(getattr(settings, "REFERRAL_REFERRER_CREDIT_PERCENT", 10)) buyer_discount = (base_amount * buyer_pct) // 100 referrer_credit = ((base_amount - buyer_discount) * ref_pct) // 100 return buyer_discount, None, referrer, referrer_credit return 0, None, None, 0 def start_checkout(student, course, *, promo_code: str | None = None, referral_code: str | None = None) -> tuple[Order, str]: """Create a pending Order for `student` on `course` and return (order, redirect_url). Optionally applies a promo code or a referral code (mutually exclusive). If the discount drives the amount to 0, the Order is created with gateway=`free`, immediately marked paid, and the Enrollment is created — `redirect_url` will then be the configured PAYMENT_SUCCESS_REDIRECT_URL with `order_id` so the frontend can show the receipt. """ if course.is_free: raise OrderError("Course is free; use /api/courses//enroll/ instead.") if Enrollment.objects.filter(student=student, course=course).exists(): raise OrderError("Already enrolled in this course.") base_amount = int(course.price_toman or 0) discount, pc_obj, referrer, _referrer_credit = _resolve_discount( course, base_amount, promo_code=promo_code, referral_code=referral_code, buyer=student, ) final_amount = max(0, base_amount - discount) commission_percent = int(getattr(settings, "ILO_COMMISSION_PERCENT", 15)) fee, instructor_share = _split(final_amount, commission_percent) if final_amount == 0: # Discount/promo dropped the price to zero — skip the gateway entirely. with transaction.atomic(): order = Order.objects.create( student=student, course=course, base_amount_toman=base_amount, discount_amount_toman=discount, amount_toman=0, ilo_fee_toman=0, instructor_share_toman=0, commission_percent_snapshot=commission_percent, gateway=Gateway.FREE, status=OrderStatus.PAID, paid_at=timezone.now(), promo_code=pc_obj, referrer=referrer, ref_id="free-with-discount", ) Enrollment.objects.get_or_create(student=student, course=course) if pc_obj: PromoCode.objects.filter(pk=pc_obj.pk).update(used_count=pc_obj.used_count + 1) if referrer: Referral.objects.create( referrer=referrer, buyer=student, order=order, course=course, buyer_discount_toman=discount, referrer_credit_toman=_referrer_credit, ) _record_paid_events(order, free_via_discount=True) success_url = getattr(settings, "PAYMENT_SUCCESS_REDIRECT_URL", "/payment/success") sep = "&" if "?" in success_url else "?" return order, f"{success_url}{sep}order_id={order.pk}&ref_id={order.ref_id}" backend = get_payment_backend() order = Order.objects.create( student=student, course=course, base_amount_toman=base_amount, discount_amount_toman=discount, amount_toman=final_amount, ilo_fee_toman=fee, instructor_share_toman=instructor_share, commission_percent_snapshot=commission_percent, gateway=Gateway(backend.gateway_id), status=OrderStatus.PENDING, promo_code=pc_obj, referrer=referrer, ) callback_url = ( f"{getattr(settings, 'PAYMENT_CALLBACK_BASE_URL', 'http://localhost:8000').rstrip('/')}" f"/api/payments/callback/{backend.gateway_id}/?order_id={order.pk}" ) try: redirect_url = backend.start(order, callback_url) except PaymentGatewayError as e: order.mark_failed(str(e)) raise return order, redirect_url def _record_paid_events(order: Order, *, free_via_discount: bool = False): from apps.enrollments.models import ActivityKind from apps.enrollments.services.events import record_event record_event( course=order.course, actor=order.student, kind=ActivityKind.ENROLLED.value, payload={ "price_toman": order.amount_toman, "via": "free_with_discount" if free_via_discount else "checkout", "order_id": order.pk, }, ) record_event( course=order.course, actor=order.student, kind=ActivityKind.PAID.value, payload={ "order_id": order.pk, "amount_toman": order.amount_toman, "discount_toman": order.discount_amount_toman, "instructor_share_toman": order.instructor_share_toman, "ref_id": order.ref_id, }, ) def complete_or_fail(order: Order, callback_status: str) -> tuple[bool, str]: """Idempotently verify a callback. See views.GatewayCallbackView for context.""" if order.status == OrderStatus.PAID: return True, "already paid" if order.status not in (OrderStatus.PENDING,): return False, f"order is in terminal state: {order.status}" if callback_status and callback_status.upper() != "OK": order.mark_failed(f"Gateway status: {callback_status}") return False, "user cancelled or gateway reported failure" backend = get_payment_backend() ok, ref_id, error = backend.verify(order) if not ok: order.mark_failed(error or "verify failed") return False, error or "verify failed" with transaction.atomic(): order.mark_paid(ref_id=ref_id or "") enrollment, created = Enrollment.objects.get_or_create( student=order.student, course=order.course ) # Bump promo usage and settle referral exactly once per Order. if order.promo_code_id: PromoCode.objects.filter(pk=order.promo_code_id).update( used_count=PromoCode.objects.get(pk=order.promo_code_id).used_count + 1 ) if order.referrer_id and not Referral.objects.filter(order=order).exists(): ref_pct = int(getattr(settings, "REFERRAL_REFERRER_CREDIT_PERCENT", 10)) Referral.objects.create( referrer_id=order.referrer_id, buyer=order.student, order=order, course=order.course, buyer_discount_toman=order.discount_amount_toman, referrer_credit_toman=(order.amount_toman * ref_pct) // 100, ) if created: _record_paid_events(order) return True, "paid"