"""Checkout service for converting carts into orders.
Handles the atomic checkout flow, credit application, and order cancellation.
All methods are stateless and operate on model instances directly.
"""
import json
import secrets
import string
from datetime import timedelta
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import IntegrityError, models, transaction
from django.utils import timezone
from django_program.registration.models import (
Cart,
Credit,
Order,
OrderLineItem,
Payment,
Voucher,
)
from django_program.registration.services.capacity import validate_global_capacity
from django_program.registration.services.cart import get_summary_from_items
from django_program.registration.signals import order_paid
from django_program.settings import get_config
def _generate_reference() -> str:
"""Generate a unique order reference using the configured prefix.
The prefix is set via ``DJANGO_PROGRAM["order_reference_prefix"]``
(default ``"ORD"``), producing references like ``PYCON-A1B2C3D4``.
"""
config = get_config()
chars = string.ascii_uppercase + string.digits
suffix = "".join(secrets.choice(chars) for _ in range(8))
return f"{config.order_reference_prefix}-{suffix}"
def _snapshot_voucher(voucher: Voucher) -> str:
"""Serialize voucher state at checkout time as JSON."""
return json.dumps(
{
"code": voucher.code,
"voucher_type": voucher.voucher_type,
"discount_value": str(voucher.discount_value),
"unlocks_hidden_tickets": voucher.unlocks_hidden_tickets,
}
)
[docs]
class CheckoutService:
"""Stateless service for checkout operations.
Converts carts into orders, applies credits, and handles cancellations.
"""
[docs]
@staticmethod
@transaction.atomic
def checkout(
cart: Cart,
*,
billing_name: str = "",
billing_email: str = "",
billing_company: str = "",
) -> Order:
"""Convert a cart into an order atomically.
Re-validates stock, pricing, and voucher validity at checkout time to
prevent stale-cart issues. Creates an Order with PENDING status,
snapshots each CartItem into OrderLineItems, records voucher details,
and marks the cart as CHECKED_OUT.
Args:
cart: The open cart to check out.
billing_name: Customer billing name.
billing_email: Customer billing email.
billing_company: Customer billing company.
Returns:
The newly created Order with PENDING status.
Raises:
ValidationError: If the cart is empty, expired, not open, or if
stock/price validation fails at checkout time.
"""
now = timezone.now()
_expire_stale_pending_orders(conference_id=cart.conference_id, now=now)
cart = Cart.objects.select_for_update().select_related("voucher").get(pk=cart.pk)
if cart.status != Cart.Status.OPEN:
raise ValidationError("Only open carts can be checked out.")
if cart.expires_at and cart.expires_at < now:
raise ValidationError("Cart has expired.")
items = list(cart.items.select_for_update().select_related("ticket_type", "addon"))
if not items:
raise ValidationError("Cannot check out an empty cart.")
_revalidate_stock(items, cart.conference)
summary = get_summary_from_items(cart, items)
voucher = cart.voucher
_validate_voucher_for_checkout(voucher)
voucher_code = voucher.code if voucher else ""
voucher_details = _snapshot_voucher(voucher) if voucher else ""
hold_expires_at = now + timedelta(minutes=get_config().pending_order_expiry_minutes)
max_retries = 10
for attempt in range(max_retries):
reference = _generate_reference()
try:
order_kwargs = {
"conference": cart.conference,
"user": cart.user,
"status": Order.Status.PENDING,
"subtotal": summary.subtotal,
"discount_amount": summary.discount,
"total": summary.total,
"voucher_code": voucher_code,
"voucher_details": voucher_details,
"billing_name": billing_name,
"billing_email": billing_email,
"billing_company": billing_company,
"reference": reference,
}
if _order_has_hold_expires_at():
order_kwargs["hold_expires_at"] = hold_expires_at
order = Order.objects.create(**order_kwargs)
break
except IntegrityError:
if attempt >= max_retries - 1:
raise
continue
items_by_id = {item.pk: item for item in items}
for line in summary.items:
cart_item = items_by_id.get(line.item_id)
if cart_item is None:
raise ValidationError("Cart changed during checkout. Please try again.")
OrderLineItem.objects.create(
order=order,
description=line.description,
quantity=line.quantity,
unit_price=line.unit_price,
discount_amount=line.discount,
line_total=line.line_total,
ticket_type=cart_item.ticket_type,
addon=cart_item.addon,
)
cart.status = Cart.Status.CHECKED_OUT
cart.save(update_fields=["status", "updated_at"])
_increment_voucher_usage(voucher=voucher, now=now)
return order
[docs]
@staticmethod
@transaction.atomic
def apply_credit(order: Order, credit: Credit) -> Payment:
"""Apply a store credit to an order.
Creates a CREDIT payment record and marks the credit as APPLIED.
If the credit covers the full remaining balance, transitions the
order to PAID and fires the ``order_paid`` signal.
Args:
order: The order to apply the credit to.
credit: The available credit to apply.
Returns:
The created Payment record.
Raises:
ValidationError: If the order is not PENDING, the credit is not
AVAILABLE, or the credit belongs to a different conference/user.
"""
order = Order.objects.select_for_update().get(pk=order.pk)
credit = Credit.objects.select_for_update().get(pk=credit.pk)
if order.status != Order.Status.PENDING:
raise ValidationError("Credits can only be applied to pending orders.")
if credit.status != Credit.Status.AVAILABLE:
raise ValidationError("Only available credits can be applied.")
if credit.user_id != order.user_id:
raise ValidationError("Credit does not belong to this user.")
if credit.conference_id != order.conference_id:
raise ValidationError("Credit does not belong to this conference.")
existing_payments = order.payments.filter(status=Payment.Status.SUCCEEDED).aggregate(total=models.Sum("amount"))
paid_so_far = existing_payments["total"] or Decimal("0.00")
remaining_balance = order.total - paid_so_far
if remaining_balance <= Decimal("0.00"):
raise ValidationError("Order is already fully paid.")
if credit.remaining_amount <= Decimal("0.00"):
raise ValidationError("Credit has no remaining balance.")
apply_amount = min(credit.remaining_amount, remaining_balance)
payment = Payment.objects.create(
order=order,
method=Payment.Method.CREDIT,
status=Payment.Status.SUCCEEDED,
amount=apply_amount,
)
credit.remaining_amount -= apply_amount
credit.status = Credit.Status.APPLIED if credit.remaining_amount <= Decimal("0.00") else Credit.Status.AVAILABLE
credit.applied_to_order = order
credit.save(update_fields=["remaining_amount", "status", "applied_to_order", "updated_at"])
new_paid = paid_so_far + apply_amount
if new_paid >= order.total:
order.status = Order.Status.PAID
if _order_has_hold_expires_at():
order.hold_expires_at = None
order.save(update_fields=["status", "hold_expires_at", "updated_at"])
else:
order.save(update_fields=["status", "updated_at"])
order_paid.send(sender=Order, order=order, user=order.user)
return payment
[docs]
@staticmethod
@transaction.atomic
def cancel_order(order: Order) -> Order:
"""Cancel a pending order and release associated resources.
Reverses any succeeded credit payments (restoring the Credit to
AVAILABLE), transitions the order to CANCELLED, and decrements the
voucher usage counter if a voucher was used.
Args:
order: The order to cancel.
Returns:
The updated Order with CANCELLED status.
Raises:
ValidationError: If the order cannot be cancelled (not PENDING).
"""
order = Order.objects.select_for_update().get(pk=order.pk)
if order.status != Order.Status.PENDING:
raise ValidationError(
f"Only pending orders can be cancelled. This order is '{order.get_status_display()}'."
)
_reverse_credit_payments(order)
order.status = Order.Status.CANCELLED
if _order_has_hold_expires_at():
order.hold_expires_at = None
order.save(update_fields=["status", "hold_expires_at", "updated_at"])
else:
order.save(update_fields=["status", "updated_at"])
if order.voucher_code:
Voucher.objects.filter(
conference=order.conference,
code=order.voucher_code,
times_used__gt=0,
).update(times_used=models.F("times_used") - 1)
return order
def _reverse_credit_payments(order: Order) -> None:
"""Reverse any succeeded credit payments on a pending order.
Marks each CREDIT payment as REFUNDED and restores the associated
Credits back to AVAILABLE so they can be reused on future orders.
Credits are matched deterministically by PK order and capped at
their original amount to prevent over-restoration.
"""
credit_payments = order.payments.filter(
method=Payment.Method.CREDIT,
status=Payment.Status.SUCCEEDED,
).order_by("pk")
applied_credits = list(Credit.objects.select_for_update().filter(applied_to_order=order).order_by("pk"))
credits_iter = iter(applied_credits)
for payment in credit_payments:
payment.status = Payment.Status.REFUNDED
payment.save(update_fields=["status"])
try:
credit = next(credits_iter)
except StopIteration:
continue
max_restorable = credit.amount - credit.remaining_amount
restore_amount = min(payment.amount, max_restorable)
if restore_amount <= Decimal("0.00"):
continue
credit.remaining_amount += restore_amount
credit.status = Credit.Status.AVAILABLE
credit.applied_to_order = None
credit.save(update_fields=["remaining_amount", "status", "applied_to_order", "updated_at"])
def _validate_voucher_for_checkout(voucher: Voucher | None) -> None:
"""Fail checkout if the attached voucher is no longer valid."""
if voucher is not None and not voucher.is_valid:
raise ValidationError(f"Voucher code '{voucher.code}' is no longer valid.")
def _increment_voucher_usage(*, voucher: Voucher | None, now: object) -> None:
"""Atomically increment voucher usage, enforcing validity constraints."""
if voucher is None:
return
voucher_updated = (
Voucher.objects.filter(
pk=voucher.pk,
is_active=True,
times_used__lt=models.F("max_uses"),
)
.filter(
models.Q(valid_from__isnull=True) | models.Q(valid_from__lte=now),
)
.filter(
models.Q(valid_until__isnull=True) | models.Q(valid_until__gte=now),
)
.update(times_used=models.F("times_used") + 1)
)
if voucher_updated != 1:
raise ValidationError(f"Voucher code '{voucher.code}' is no longer valid.")
def _revalidate_stock(items: list[object], conference: object) -> None:
"""Re-validate stock availability for all cart items at checkout time.
Args:
items: Pre-fetched cart items to validate.
conference: The conference these items belong to.
Raises:
ValidationError: If any item has insufficient stock or missing prerequisites.
"""
now = timezone.now()
ticket_type_ids = {item.ticket_type_id for item in items if item.ticket_type_id is not None}
for item in items:
if item.ticket_type is not None:
_revalidate_ticket_stock(item)
elif item.addon is not None:
_revalidate_addon_stock(item, now, ticket_type_ids)
_revalidate_global_capacity(items, conference)
def _revalidate_global_capacity(items: list[object], conference: object) -> None:
"""Validate that checkout ticket quantities fit within the global cap.
Sums ticket quantities from the cart items and validates against the
conference's ``total_capacity``.
Args:
items: Pre-fetched cart items from the checkout flow.
conference: The conference to validate capacity against.
Raises:
ValidationError: If the total tickets would exceed the global cap.
"""
ticket_items = [item for item in items if item.ticket_type_id is not None]
if not ticket_items:
return
total_qty = sum(item.quantity for item in ticket_items)
validate_global_capacity(conference, total_qty)
def _revalidate_ticket_stock(item: object) -> None:
"""Validate a ticket type is still available with sufficient stock."""
tt = item.ticket_type
if not tt.is_available:
raise ValidationError(f"Ticket type '{tt.name}' is no longer available.")
remaining = tt.remaining_quantity
if remaining is not None and remaining < item.quantity:
raise ValidationError(f"Only {remaining} tickets of type '{tt.name}' remaining, but {item.quantity} requested.")
def _revalidate_addon_stock(item: object, now: object, ticket_type_ids: set[int]) -> None:
"""Validate an add-on is still available within its window, has stock, and prerequisites are met."""
addon = item.addon
required_ids = set(addon.requires_ticket_types.values_list("pk", flat=True))
if required_ids and not required_ids & ticket_type_ids:
raise ValidationError(f"Add-on '{addon.name}' requires a ticket type that is not in your cart.")
if not addon.is_active:
raise ValidationError(f"Add-on '{addon.name}' is no longer active.")
if addon.available_from and now < addon.available_from:
raise ValidationError(f"Add-on '{addon.name}' is not yet available.")
if addon.available_until and now > addon.available_until:
raise ValidationError(f"Add-on '{addon.name}' is no longer available.")
if addon.total_quantity > 0:
sold = (
OrderLineItem.objects.filter(addon=addon)
.filter(
models.Q(order__status__in=[Order.Status.PAID, Order.Status.PARTIALLY_REFUNDED])
| models.Q(order__status=Order.Status.PENDING, order__hold_expires_at__gt=now)
)
.aggregate(total=models.Sum("quantity"))["total"]
or 0
)
remaining = addon.total_quantity - sold
if remaining < item.quantity:
raise ValidationError(
f"Only {remaining} of add-on '{addon.name}' remaining, but {item.quantity} requested."
)
def _expire_stale_pending_orders(*, conference_id: int, now: object) -> None:
"""Mark stale pending orders as cancelled and release voucher usage."""
if not _order_has_hold_expires_at():
return
stale_qs = Order.objects.filter(
conference_id=conference_id,
status=Order.Status.PENDING,
hold_expires_at__isnull=False,
hold_expires_at__lte=now,
)
voucher_codes = list(stale_qs.exclude(voucher_code="").values_list("voucher_code", flat=True))
stale_qs.update(status=Order.Status.CANCELLED, hold_expires_at=None)
for code in voucher_codes:
Voucher.objects.filter(
conference_id=conference_id,
code=code,
times_used__gt=0,
).update(times_used=models.F("times_used") - 1)
def _order_has_hold_expires_at() -> bool:
"""Return True when the Order model has hold_expires_at in this runtime."""
return hasattr(Order, "hold_expires_at")