222 lines
8.6 KiB
Python
222 lines
8.6 KiB
Python
"""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/<slug>/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"
|