Source code for django_program.registration.models

"""Registration, ticketing, cart, order, and payment models for django-program."""

from decimal import Decimal

from django.conf import settings
from django.db import models
from django.utils import timezone


[docs] class TicketType(models.Model): """A purchasable ticket category for a conference. Defines a class of ticket (e.g. "Early Bird", "Student", "Corporate") with pricing, availability windows, and optional quantity limits. Ticket types flagged with ``requires_voucher`` are hidden from the public storefront until unlocked by a matching voucher code. """ conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="ticket_types", ) name = models.CharField(max_length=200) slug = models.SlugField(max_length=200) description = models.TextField(blank=True, default="") price = models.DecimalField(max_digits=10, decimal_places=2) available_from = models.DateTimeField(null=True, blank=True) available_until = models.DateTimeField(null=True, blank=True) total_quantity = models.PositiveIntegerField( default=0, help_text="Maximum number of tickets available. 0 means unlimited.", ) limit_per_user = models.PositiveIntegerField(default=10) requires_voucher = models.BooleanField( default=False, help_text="When True, this ticket type is hidden unless unlocked by a voucher.", ) is_active = models.BooleanField(default=True) order = models.PositiveIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["order", "name"] unique_together = [("conference", "slug")] def __str__(self) -> str: return f"{self.name} ({self.conference.slug})" @property def remaining_quantity(self) -> int | None: """Return the number of tickets still available for purchase. Counts tickets in paid/partially-refunded orders plus pending orders with an active inventory hold (``hold_expires_at`` in the future). Returns: The remaining count, or ``None`` if this ticket type has unlimited quantity (``total_quantity == 0``). """ if self.total_quantity == 0: return None now = timezone.now() sold = ( self.order_line_items.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 ) return self.total_quantity - sold @property def is_available(self) -> bool: """Check whether this ticket type can currently be purchased. A ticket is available when all of the following are true: * ``is_active`` is ``True`` * The current time is within the ``available_from`` / ``available_until`` window (if set) * There is remaining quantity (or quantity is unlimited) """ if not self.is_active: return False now = timezone.now() if self.available_from and now < self.available_from: return False if self.available_until and now > self.available_until: return False remaining = self.remaining_quantity return not (remaining is not None and remaining <= 0)
[docs] class AddOn(models.Model): """An optional extra attached to a ticket (e.g. workshop, t-shirt). Add-ons can optionally be restricted to specific ticket types via the ``requires_ticket_types`` relation. When that relation is empty the add-on is available to holders of any ticket type. """ conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="addons", ) name = models.CharField(max_length=200) slug = models.SlugField(max_length=200) description = models.TextField(blank=True, default="") price = models.DecimalField(max_digits=10, decimal_places=2) requires_ticket_types = models.ManyToManyField( TicketType, blank=True, related_name="available_addons", help_text="Ticket types this add-on is available for. Empty means all.", ) available_from = models.DateTimeField(null=True, blank=True) available_until = models.DateTimeField(null=True, blank=True) total_quantity = models.PositiveIntegerField( default=0, help_text="Maximum number available. 0 means unlimited.", ) is_active = models.BooleanField(default=True) order = models.PositiveIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["order", "name"] unique_together = [("conference", "slug")] def __str__(self) -> str: return f"{self.name} ({self.conference.slug})"
[docs] class Voucher(models.Model): """A discount or access code for tickets and add-ons. Vouchers can provide a percentage discount, a fixed amount off, or full complimentary access (100% off). They can also unlock hidden ticket types that require a voucher to purchase. """
[docs] class VoucherType(models.TextChoices): """The type of discount a voucher provides.""" COMP = "comp", "Complimentary (100% off)" PERCENTAGE = "percentage", "Percentage discount" FIXED_AMOUNT = "fixed_amount", "Fixed amount discount"
conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="vouchers", ) code = models.CharField(max_length=100) voucher_type = models.CharField( max_length=20, choices=VoucherType.choices, default=VoucherType.COMP, ) discount_value = models.DecimalField( max_digits=10, decimal_places=2, default=0, help_text="Percentage (0-100) or fixed amount depending on voucher_type.", ) applicable_ticket_types = models.ManyToManyField( TicketType, blank=True, related_name="vouchers", help_text="Ticket types this voucher applies to. Empty means all.", ) applicable_addons = models.ManyToManyField( AddOn, blank=True, related_name="vouchers", help_text="Add-ons this voucher applies to. Empty means all.", ) max_uses = models.PositiveIntegerField(default=1) times_used = models.PositiveIntegerField(default=0) valid_from = models.DateTimeField(null=True, blank=True) valid_until = models.DateTimeField(null=True, blank=True) unlocks_hidden_tickets = models.BooleanField( default=False, help_text="When True, reveals ticket types that require a voucher.", ) is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: unique_together = [("conference", "code")] def __str__(self) -> str: return f"{self.code} ({self.conference.slug})" @property def is_valid(self) -> bool: """Check whether this voucher can currently be redeemed. A voucher is valid when it is active, has remaining uses, and the current time falls within the optional validity window. """ if not self.is_active: return False if self.times_used >= self.max_uses: return False now = timezone.now() if self.valid_from and now < self.valid_from: return False return not (self.valid_until and now > self.valid_until)
[docs] class Cart(models.Model): """A user's shopping cart for a conference. Carts hold ticket and add-on selections before checkout. They transition through statuses from ``OPEN`` to ``CHECKED_OUT`` when submitted to checkout and converted to an order (which may still be pending payment), or to ``EXPIRED`` / ``ABANDONED`` when the session times out. """
[docs] class Status(models.TextChoices): """Lifecycle states for a shopping cart.""" OPEN = "open", "Open" CHECKED_OUT = "checked_out", "Checked Out" EXPIRED = "expired", "Expired" ABANDONED = "abandoned", "Abandoned"
user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="carts", ) conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="carts", ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.OPEN, ) voucher = models.ForeignKey( Voucher, on_delete=models.SET_NULL, null=True, blank=True, related_name="carts", ) expires_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-created_at"] def __str__(self) -> str: return f"Cart {self.pk} ({self.user}, {self.status})"
[docs] class CartItem(models.Model): """A single item (ticket or add-on) in a cart. Each cart item references exactly one of ``ticket_type`` or ``addon``, enforced by a database-level check constraint. The ``unit_price`` and ``line_total`` properties compute pricing from the referenced item. """ cart = models.ForeignKey( Cart, on_delete=models.CASCADE, related_name="items", ) ticket_type = models.ForeignKey( TicketType, on_delete=models.CASCADE, null=True, blank=True, related_name="cart_items", ) addon = models.ForeignKey( AddOn, on_delete=models.CASCADE, null=True, blank=True, related_name="cart_items", ) quantity = models.PositiveIntegerField(default=1) created_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [ models.CheckConstraint( condition=( models.Q(ticket_type__isnull=False, addon__isnull=True) | models.Q(ticket_type__isnull=True, addon__isnull=False) ), name="registration_cartitem_exactly_one_type", ), models.UniqueConstraint( fields=["cart", "ticket_type"], condition=models.Q(ticket_type__isnull=False), name="registration_cartitem_unique_ticket_per_cart", ), models.UniqueConstraint( fields=["cart", "addon"], condition=models.Q(addon__isnull=False), name="registration_cartitem_unique_addon_per_cart", ), ] def __str__(self) -> str: item = self.ticket_type or self.addon return f"{self.quantity}x {item}" @property def unit_price(self) -> Decimal: """Return the per-unit price of this cart item.""" if self.ticket_type is not None: return self.ticket_type.price if self.addon is not None: return self.addon.price return Decimal("0.00") @property def line_total(self) -> Decimal: """Return the total price for this line (unit_price * quantity).""" return self.unit_price * self.quantity
[docs] class Order(models.Model): """A completed checkout with billing and payment info. Orders are created when a cart is checked out. They capture a snapshot of the pricing, discounts, and billing details at the time of purchase. The ``reference`` field holds a unique human-readable order number. """
[docs] class Status(models.TextChoices): """Lifecycle states for an order.""" PENDING = "pending", "Pending" PAID = "paid", "Paid" REFUNDED = "refunded", "Refunded" PARTIALLY_REFUNDED = "partially_refunded", "Partially Refunded" CANCELLED = "cancelled", "Cancelled"
conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="orders", ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="orders", ) status = models.CharField( max_length=25, choices=Status.choices, default=Status.PENDING, ) subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0) discount_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) total = models.DecimalField(max_digits=10, decimal_places=2, default=0) voucher_code = models.CharField( max_length=100, blank=True, default="", help_text="Snapshot of the voucher code applied at checkout.", ) voucher_details = models.TextField( blank=True, default="", help_text="JSON snapshot of the voucher state at checkout time.", ) billing_name = models.CharField(max_length=200, blank=True, default="") billing_email = models.EmailField(blank=True, default="") billing_company = models.CharField(max_length=200, blank=True, default="") reference = models.CharField( max_length=100, unique=True, help_text='Unique order reference, e.g. "ORD-A1B2C3".', ) hold_expires_at = models.DateTimeField( null=True, blank=True, help_text="When set on pending orders, inventory is reserved until this timestamp.", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-created_at"] def __str__(self) -> str: return f"{self.reference} ({self.status})"
[docs] class OrderLineItem(models.Model): """A snapshot of a purchased item at checkout time. Line items are immutable records of what was purchased, including the price and description at the time of checkout. They may reference the original ``TicketType`` or ``AddOn`` for traceability, but those links are optional since the source item could be deleted after the order is placed. """ order = models.ForeignKey( Order, on_delete=models.CASCADE, related_name="line_items", ) description = models.CharField(max_length=300) quantity = models.PositiveIntegerField(default=1) unit_price = models.DecimalField(max_digits=10, decimal_places=2) discount_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) line_total = models.DecimalField(max_digits=10, decimal_places=2) ticket_type = models.ForeignKey( TicketType, on_delete=models.SET_NULL, null=True, blank=True, related_name="order_line_items", ) addon = models.ForeignKey( AddOn, on_delete=models.SET_NULL, null=True, blank=True, related_name="order_line_items", ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["id"] def __str__(self) -> str: return f"{self.quantity}x {self.description}"
[docs] class Payment(models.Model): """A payment record against an order. Each payment represents a single financial transaction (Stripe charge, complimentary comp, credit application, or manual entry). An order may have multiple payments if it is partially refunded and re-paid. """
[docs] class Method(models.TextChoices): """Supported payment methods.""" STRIPE = "stripe", "Stripe" COMP = "comp", "Complimentary" CREDIT = "credit", "Credit" MANUAL = "manual", "Manual"
[docs] class Status(models.TextChoices): PENDING = "pending", "Pending" PROCESSING = "processing", "Processing" SUCCEEDED = "succeeded", "Succeeded" FAILED = "failed", "Failed" REFUNDED = "refunded", "Refunded"
order = models.ForeignKey( Order, on_delete=models.CASCADE, related_name="payments", ) method = models.CharField( max_length=20, choices=Method.choices, default=Method.STRIPE, ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.SUCCEEDED, help_text="Defaults to SUCCEEDED for backward compatibility with existing data.", ) amount = models.DecimalField(max_digits=10, decimal_places=2) stripe_payment_intent_id = models.CharField(max_length=200, blank=True, default="") stripe_charge_id = models.CharField(max_length=200, blank=True, default="") reference = models.CharField(max_length=200, blank=True, default="") note = models.TextField(blank=True, default="") created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_payments", ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"] def __str__(self) -> str: return f"{self.method} {self.amount} for {self.order.reference}"
[docs] class Credit(models.Model): """A store credit that can be applied to future orders. Credits are typically issued as part of a refund workflow. They are tied to a specific conference and user, and can be applied to a new order or left to expire. """
[docs] class Status(models.TextChoices): """Lifecycle states for a store credit.""" AVAILABLE = "available", "Available" APPLIED = "applied", "Applied" EXPIRED = "expired", "Expired"
user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="credits", ) conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="credits", ) amount = models.DecimalField(max_digits=10, decimal_places=2) remaining_amount = models.DecimalField( max_digits=10, decimal_places=2, default=0, help_text="Unspent credit balance that can be applied to future orders.", ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.AVAILABLE, ) applied_to_order = models.ForeignKey( Order, on_delete=models.SET_NULL, null=True, blank=True, related_name="applied_credits", ) source_order = models.ForeignKey( Order, on_delete=models.SET_NULL, null=True, blank=True, related_name="issued_credits", ) note = models.TextField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-created_at"] def __str__(self) -> str: return f"Credit {self.amount} for {self.user} ({self.status})"
[docs] def save(self, *args: object, **kwargs: object) -> None: """Initialize remaining balance for newly created available credits.""" remaining = getattr(self, "remaining_amount", Decimal("0.00")) if self._state.adding and self.status == Credit.Status.AVAILABLE and remaining == 0: self.remaining_amount = self.amount super().save(*args, **kwargs)
[docs] class StripeCustomer(models.Model): """Maps a Django user to a Stripe customer for a specific conference. Each user gets a separate Stripe customer per conference since each conference may use a different Stripe account. """ user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stripe_customers", ) conference = models.ForeignKey( "program_conference.Conference", on_delete=models.CASCADE, related_name="stripe_customers", ) stripe_customer_id = models.CharField(max_length=200) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = [("user", "conference"), ("conference", "stripe_customer_id")] def __str__(self) -> str: return f"{self.user}{self.stripe_customer_id}"
[docs] class StripeEvent(models.Model): """A record of a Stripe webhook event for idempotent processing. Stores the full event payload and tracks whether the event has been successfully processed by the webhook handler. """ stripe_id = models.CharField(max_length=200, unique=True) kind = models.CharField(max_length=200) livemode = models.BooleanField(default=False) payload = models.JSONField(default=dict) customer_id = models.CharField(max_length=200, blank=True, default="") processed = models.BooleanField(default=False) api_version = models.CharField(max_length=50, blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"] def __str__(self) -> str: status = "processed" if self.processed else "pending" return f"{self.kind} ({status})"
[docs] class EventProcessingException(models.Model): """Records an error that occurred while processing a webhook event. Captures the full traceback and contextual data so that failed events can be investigated and retried. """ event = models.ForeignKey( StripeEvent, on_delete=models.SET_NULL, null=True, blank=True, related_name="processing_exceptions", ) data = models.TextField(blank=True, default="") message = models.CharField(max_length=500) traceback = models.TextField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"] def __str__(self) -> str: return str(self.message)[:80]