Registration Flow¶
This document covers the full cart-to-payment pipeline: how items get into a cart, how carts become orders, how payments are collected, and how refunds work. All service code lives in django_program.registration.services.
Models¶
Before getting into the flow, here are the models involved:
Model |
Purpose |
|---|---|
A purchasable ticket category (e.g. “Individual”, “Student”). Has pricing, availability windows, stock limits, and an optional voucher gate. |
|
An optional extra (e.g. tutorial, t-shirt). Can require specific ticket types via |
|
A discount or access code. Three types: |
|
A user’s shopping cart. Statuses: |
|
A line in the cart. References exactly one of |
|
A completed checkout. Statuses: |
|
Immutable snapshot of a purchased item at checkout time. |
|
A financial transaction against an order. Methods: |
|
A store credit issued from a refund, applicable to future orders. |
Global Ticket Capacity¶
The Conference.total_capacity field sets a hard venue-wide cap on the number of tickets sold across all ticket types. Add-ons do not count toward this cap since they do not consume venue seats.
Setting the capacity¶
Set total_capacity on the Conference model through any of these:
Management dashboard – edit the conference and fill in the “Total capacity” field on the
ConferenceForm.Django admin – set the field directly on the Conference admin page.
TOML bootstrap – add
total_capacity = 2500to the[conference]table in your config file.
A value of 0 means unlimited (no global cap enforced). This is the default.
How enforcement works¶
Global capacity is checked at two points in the registration flow:
Adding a ticket to the cart –
add_ticket()callsvalidate_global_capacity()with the cart’s current ticket count plus the new quantity. If the total exceeds the remaining global capacity, aValidationErroris raised.Checkout –
CheckoutService.checkout()re-validates global capacity for all ticket items in the cart via_revalidate_global_capacity(). This catches the case where capacity filled up between adding items and checking out.
Both paths call the same underlying function in django_program.registration.services.capacity:
from django_program.registration.services.capacity import (
get_global_remaining,
get_global_sold_count,
)
# How many tickets have been sold (paid + pending with active hold)?
sold = get_global_sold_count(conference)
# How many tickets are left? Returns None if capacity is unlimited.
remaining = get_global_remaining(conference)
Sold count calculation¶
get_global_sold_count() counts OrderLineItem quantities where:
The line item is a ticket (not an add-on), identified by
addon__isnull=True.The order status is
PAID,PARTIALLY_REFUNDED, orPENDINGwithhold_expires_atstill in the future.
Using addon__isnull=True instead of ticket_type__isnull=False is deliberate: if a ticket type is deleted (SET_NULL on the FK), its line items are still counted. This prevents overselling after administrative cleanup.
Concurrency safety¶
validate_global_capacity() acquires a row-level lock on the Conference row with select_for_update() before reading the sold count. The caller must already be inside a transaction.atomic block. This prevents two concurrent requests from both seeing “1 ticket remaining” and both succeeding.
Error messages¶
When capacity is exceeded, the user sees one of:
"This conference is sold out (venue capacity: 2500)."– when zero tickets remain."Only 12 tickets remaining for this conference (venue capacity: 2500)."– when some tickets remain but fewer than requested.
Cart Lifecycle¶
The cart service (django_program.registration.services.cart) is a collection of stateless functions. No classes to instantiate.
Getting a Cart¶
from django_program.registration.services.cart import get_or_create_cart
cart = get_or_create_cart(user, conference)
get_or_create_cart does three things in sequence:
Expires any stale open carts for this user/conference combination (sets status to
EXPIREDwhereexpires_at < now).Looks for an existing
OPENcart. If found, returns it (fixing upexpires_atif it was null).Creates a new
OPENcart withexpires_atset tonow + cart_expiry_minutes(fromDJANGO_PROGRAMconfig, default 30 minutes).
Each user gets one open cart per conference at a time.
Adding Tickets¶
from django_program.registration.services.cart import add_ticket
item = add_ticket(cart, ticket_type, qty=1)
add_ticket runs inside @transaction.atomic and validates the following, in order:
Cart is open and not expired.
Ticket belongs to this conference (compares
conference_id).Ticket is available –
is_activeis true, current time is within theavailable_from/available_untilwindow, and remaining stock is sufficient.Stock check – uses
SELECT FOR UPDATEon the existing CartItem row to prevent race conditions. Checksremaining_quantityagainst the total of what is already in the cart plus the newqty.Per-user limit – sums quantity in cart plus quantity in previous paid/partially-refunded orders for this ticket type. If the total exceeds
limit_per_user, the add is rejected.Voucher requirement – if
ticket_type.requires_voucherisTrue, the cart must have a voucher attached whereunlocks_hidden_ticketsisTrueand the voucher’sapplicable_ticket_typesincludes this ticket type (or is empty, meaning all types qualify).
If a CartItem for this ticket type already exists, the quantity is incremented. If not, a new row is created. Concurrent inserts are handled: if the CREATE hits an IntegrityError (unique constraint on cart + ticket_type), it falls back to SELECT FOR UPDATE and increment.
After a successful add, the cart’s expires_at is pushed forward to now + cart_expiry_minutes.
Adding Add-Ons¶
from django_program.registration.services.cart import add_addon
item = add_addon(cart, addon, qty=1)
Same pattern as add_ticket, with one extra validation: if the add-on has requires_ticket_types set, at least one of those ticket types must already be in the cart. You cannot buy a tutorial add-on without a conference ticket.
Removing Items¶
from django_program.registration.services.cart import remove_item
remove_item(cart, item_id)
When you remove a ticket, remove_item cascades. It checks every add-on in the cart: if an add-on required the ticket type being removed, and no other qualifying ticket type remains in the cart, that add-on is also deleted. This prevents orphaned add-ons that would fail validation at checkout.
Updating Quantity¶
from django_program.registration.services.cart import update_quantity
item = update_quantity(cart, item_id, qty=3) # set absolute quantity
item = update_quantity(cart, item_id, qty=0) # removes the item, returns None
update_quantity sets the absolute quantity (not a delta). It re-validates stock and per-user limits for the new value. If qty <= 0, the item is removed via remove_item and the function returns None.
Applying a Voucher¶
from django_program.registration.services.cart import apply_voucher
voucher = apply_voucher(cart, "SPKR-A3K9M2X1")
Looks up the voucher by code and conference. Validates that the voucher is_valid (active, has remaining uses, within validity window). Attaches it to the cart.
A cart holds at most one voucher. Calling apply_voucher again replaces the previous one.
Voucher System¶
Vouchers live on the Voucher model. Three types:
COMP¶
100% off all applicable items. The discount equals the full line total for each applicable line.
PERCENTAGE¶
discount_value% off applicable items. Applied per-line with ROUND_HALF_UP to the nearest cent.
# A 20% voucher on a $100 ticket:
# discount = ($100.00 * 20 / 100) = $20.00
FIXED_AMOUNT¶
discount_value dollars off, distributed proportionally across applicable lines. The last applicable line gets the remainder to avoid rounding drift.
# A $25 voucher on a $100 ticket + $25 t-shirt ($125 applicable total):
# ticket share = ($25 * $100 / $125) = $20.00
# t-shirt share = remainder = $5.00
Voucher Scoping¶
Vouchers have two M2M fields: applicable_ticket_types and applicable_addons. When empty, the voucher applies to all items of that type. When populated, only matching items get the discount.
Vouchers can also set unlocks_hidden_tickets = True. This lets the voucher reveal ticket types that have requires_voucher = True (e.g., speaker tickets or student tickets that should not appear in the public storefront).
Validity Rules¶
A voucher is valid when all of the following are true:
is_activeisTruetimes_used < max_usesCurrent time is within the
valid_from/valid_untilwindow (if set)
Pricing Summary¶
from django_program.registration.services.cart import get_summary
summary = get_summary(cart)
# summary.items -> list[LineItemSummary]
# summary.subtotal -> Decimal (before discount)
# summary.discount -> Decimal (total discount from voucher)
# summary.total -> Decimal (subtotal - discount, floored at $0.00)
Each LineItemSummary contains:
Field |
Type |
Description |
|---|---|---|
|
|
CartItem primary key |
|
|
Ticket type name or add-on name |
|
|
Number of this item |
|
|
Per-unit price |
|
|
Discount applied to this line |
|
|
Final line total after discount |
The summary computation:
Iterates cart items, computes undiscounted line totals (
unit_price * quantity).Identifies which lines are voucher-applicable based on the M2M scope.
Applies the discount strategy for the voucher type (comp, percentage, or fixed).
Sets each line’s
line_totaltoundiscounted - discount.Returns the aggregate:
total = max(subtotal - discount, 0.00).
Checkout¶
from django_program.registration.services.checkout import CheckoutService
order = CheckoutService.checkout(
cart,
billing_name="Alice Smith",
billing_email="alice@example.com",
billing_company="",
)
CheckoutService is a class with static methods. checkout() runs inside @transaction.atomic and does the following:
Expires stale pending orders for this conference. Any
PENDINGorder whosehold_expires_athas passed is markedCANCELLEDand its voucher usage is decremented.Locks the cart with
SELECT FOR UPDATE. Verifies status isOPENand not expired.Validates the cart is not empty.
Re-validates stock for every item at checkout time. This catches the case where stock ran out between the user adding items and clicking “checkout”.
Computes the pricing summary using
get_summary_from_items().Validates the voucher is still valid (active, has uses remaining, within date window).
Creates an Order with status
PENDING. The order reference is generated as{prefix}-{8 random alphanumeric chars}(e.g.ORD-A1B2C3D4). Retries up to 10 times on reference collision.Copies each CartItem into an OrderLineItem. Line items are immutable snapshots – they capture the price, description, and discount at checkout time.
Marks the cart as
CHECKED_OUT.Increments voucher usage atomically with a conditional
UPDATEthat re-checks validity constraints.
The order’s hold_expires_at is set to now + pending_order_expiry_minutes (default 15 minutes). During this window, the ordered items are counted as “sold” for stock purposes. If payment is not completed before the hold expires, the order auto-cancels on the next checkout attempt for this conference.
Cancelling an Order¶
order = CheckoutService.cancel_order(order)
Cancellation reverses everything: credit payments are restored to AVAILABLE, the order status becomes CANCELLED, and voucher usage is decremented. Only PENDING orders can be cancelled.
Applying Store Credits¶
payment = CheckoutService.apply_credit(order, credit)
Deducts the credit amount from the order’s remaining balance. If the credit covers the full amount, the order transitions to PAID and the order_paid signal fires. Partial credit application leaves the order PENDING for the remaining balance.
Payment¶
Three payment paths exist, all on PaymentService:
Stripe Payment¶
from django_program.registration.services.payment import PaymentService
client_secret = PaymentService.initiate_payment(order)
# Pass client_secret to Stripe.js on the frontend
initiate_payment() does:
Creates a Stripe customer for this user/conference (or retrieves the existing one).
Creates a Stripe
PaymentIntentwith the order total, currency, and metadata (order_id,conference_id,reference).Creates a
Paymentrecord with statusPENDINGand thestripe_payment_intent_id.Returns the
client_secretfor the frontend to confirm via Stripe.js.
The StripeClient is initialized per-conference – each conference can use different Stripe account keys.
Complimentary Payment¶
payment = PaymentService.record_comp(order)
For zero-total orders (speaker comps, 100% voucher discounts). Creates a Payment with method COMP and amount $0.00, immediately transitions the order to PAID.
Manual Payment¶
payment = PaymentService.record_manual(
order,
amount=Decimal("100.00"),
reference="Receipt #1234",
note="Cash payment at registration desk",
staff_user=request.user,
)
For at-the-door payments, wire transfers, or any off-platform method. If cumulative succeeded payments meet or exceed the order total, the order transitions to PAID.
Webhooks¶
Stripe webhook events are handled by a registry-based dispatch system in django_program.registration.webhooks.
Setup¶
The webhook is included automatically by the registration URL conf. Mount it with the standard conference-slug prefix:
from django.urls import include, path
urlpatterns = [
path(
"<slug:conference_slug>/register/",
include("django_program.registration.urls"),
),
]
This exposes the webhook at /<conference_slug>/register/webhooks/stripe/. Each
conference has its own webhook endpoint. The view verifies the event signature against
the conference’s webhook secret, deduplicates by Stripe event ID (stored in
StripeEvent), and dispatches to the
registered handler.
The view always returns HTTP 200, even on processing errors. Errors are captured to EventProcessingException with the full traceback.
Handled Events¶
Stripe Event |
Handler |
What It Does |
|---|---|---|
|
|
Creates/updates a |
|
|
Marks the matching |
|
|
Compares |
|
|
Logs the dispute details for manual review. No automated action. |
The order_paid Signal¶
from django_program.registration.signals import order_paid
@receiver(order_paid)
def handle_order_paid(sender, order, user, **kwargs):
# Send confirmation email, provision badge, etc.
...
Fired when an order transitions to PAID, whether from a Stripe webhook, a comp payment, a manual payment, or a credit application that covers the full balance. Sender is the Order class.
Refunds¶
RefundService handles refund creation and credit-as-payment application.
Creating a Refund¶
from django_program.registration.services.refund import RefundService
credit = RefundService.create_refund(
order,
amount=Decimal("50.00"),
reason="requested_by_customer",
staff_user=request.user,
)
create_refund():
Validates the order is
PAIDorPARTIALLY_REFUNDED.Calculates the refundable balance:
total_paid_via_stripe - total_already_refunded.Calls
StripeClient.create_refund()against the Stripe API (with idempotency key).Creates a
Creditwith statusAVAILABLEand a note documenting the refund.Updates the order status:
REFUNDEDif the cumulative refund covers the full total,PARTIALLY_REFUNDEDotherwise.
The reason parameter is passed directly to Stripe. Valid values: "requested_by_customer", "duplicate", "fraudulent".
Applying Credits to New Orders¶
payment = RefundService.apply_credit_as_refund(credit, new_order)
Takes an available credit and applies it as payment toward a pending order. Deducts from credit.remaining_amount, creates a CREDIT payment. If the order is fully paid, transitions it to PAID and fires order_paid.
Credits are scoped to a user and conference – you cannot apply a credit from one conference to an order for a different conference.
Concurrency¶
The registration system is built for concurrent access. Key patterns:
SELECT FOR UPDATEon cart items duringadd_ticketandadd_addonto prevent double-counting.Unique constraints on (cart, ticket_type) and (cart, addon) prevent duplicate rows from concurrent inserts. The upsert pattern catches
IntegrityErrorand falls back to lock-and-increment.SELECT FOR UPDATEon the cart during checkout, and on the order during payment/refund operations.Idempotency keys on all Stripe API calls (customer creation, payment intent, refund) so retried requests are safe.
Webhook deduplication via
StripeEvent.stripe_idunique constraint. Duplicate events are acknowledged with HTTP 200 and skipped.
State Diagram¶
Cart: OPEN ──checkout──> CHECKED_OUT
│
└──expiry──> EXPIRED
└──abandon──> ABANDONED
Order: PENDING ──payment──> PAID ──refund──> PARTIALLY_REFUNDED ──full refund──> REFUNDED
│ │
│ └──refund──> REFUNDED
└──cancel/expire──> CANCELLED
Payment: PENDING ──success──> SUCCEEDED ──refund──> REFUNDED
│
└──failure──> FAILED
Credit: AVAILABLE ──apply──> APPLIED
│
└──expire──> EXPIRED