Ilo/apps/payments/services/orders.py
2026-05-02 20:01:30 +03:30

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"