diff --git a/docs/configuration.md b/docs/configuration.md
index 14f8032..b640658 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -34,6 +34,17 @@ DJANGO_PROGRAM = {
"publisher": "pycon", # default
"flight": "sponsors", # default
},
+ # Feature toggles (all default to True)
+ "features": {
+ "registration_enabled": True,
+ "sponsors_enabled": True,
+ "travel_grants_enabled": True,
+ "programs_enabled": True,
+ "pretalx_sync_enabled": True,
+ "public_ui_enabled": True,
+ "manage_ui_enabled": True,
+ "all_ui_enabled": True,
+ },
# General
"cart_expiry_minutes": 30, # default
"pending_order_expiry_minutes": 15, # default
@@ -93,6 +104,157 @@ Most conferences can ignore this section entirely.
| `currency_symbol` | `str` | `"$"` | Display symbol for the currency. |
| `max_grant_amount` | `int` | `3000` | Maximum travel grant amount in the configured currency. |
+### Feature toggles
+
+Feature toggles disable entire modules and UI sections per-conference. Every toggle
+defaults to `True` (enabled). Disable a module by setting its flag to `False` in
+settings, or override it per-conference through the database.
+
+#### Settings defaults
+
+Add a `features` dict to `DJANGO_PROGRAM` in your Django settings:
+
+```python
+DJANGO_PROGRAM = {
+ "features": {
+ "registration_enabled": True,
+ "sponsors_enabled": True,
+ "travel_grants_enabled": False, # disable travel grants globally
+ "programs_enabled": True,
+ "pretalx_sync_enabled": True,
+ "public_ui_enabled": True,
+ "manage_ui_enabled": True,
+ "all_ui_enabled": True, # master switch for all UI
+ },
+ # ... other settings ...
+}
+```
+
+Changing these values requires a server restart.
+
+#### Available toggles
+
+**Module toggles** control backend functionality:
+
+| Key | Default | Controls |
+|---|---|---|
+| `registration_enabled` | `True` | Ticket types, cart, checkout, orders |
+| `sponsors_enabled` | `True` | Sponsor levels, benefits, comp vouchers |
+| `travel_grants_enabled` | `True` | Travel grant applications and review |
+| `programs_enabled` | `True` | Activities and signups |
+| `pretalx_sync_enabled` | `True` | Speaker/talk/room sync from Pretalx |
+
+**UI toggles** control interface visibility:
+
+| Key | Default | Controls |
+|---|---|---|
+| `public_ui_enabled` | `True` | Public-facing conference pages |
+| `manage_ui_enabled` | `True` | Organizer management dashboard |
+| `all_ui_enabled` | `True` | Master switch -- when `False`, both `public_ui` and `manage_ui` are forced off regardless of their individual values |
+
+#### Per-conference database overrides
+
+Each conference can override the global defaults through a `FeatureFlags` row in the
+database. These overrides take effect immediately without a server restart.
+
+The `FeatureFlags` model uses nullable booleans with three states:
+
+- **`None`** (blank) -- use the default from `DJANGO_PROGRAM["features"]`
+- **`True`** -- force the feature on for this conference
+- **`False`** -- force the feature off for this conference
+
+Resolution order for each flag:
+
+1. If the conference has a `FeatureFlags` row with an explicit `True` or `False`, that value wins.
+2. Otherwise, the default from `DJANGO_PROGRAM["features"]` is used.
+3. For `public_ui` and `manage_ui`, the `all_ui_enabled` master switch is evaluated first.
+
+#### Django admin
+
+Open the Conference admin page. The **Feature flags** inline appears below the
+conference fields, grouped into "Module Toggles" and "UI Toggles". Each dropdown
+offers three choices:
+
+- **Default (enabled)** -- inherit from settings
+- **Yes -- force ON** -- override to enabled
+- **No -- force OFF** -- override to disabled
+
+Feature flags are also available as a standalone admin model at
+**Conference > Feature flags** for a cross-conference overview.
+
+#### Checking features in Python code
+
+Use `is_feature_enabled()` to check a toggle at runtime:
+
+```python
+from django_program.features import is_feature_enabled
+
+# Check against global settings only
+if is_feature_enabled("registration"):
+ ...
+
+# Check with per-conference DB override
+if is_feature_enabled("sponsors", conference=my_conference):
+ ...
+```
+
+Raise a 404 when a feature is disabled:
+
+```python
+from django_program.features import require_feature
+
+def my_view(request):
+ require_feature("registration", conference=request.conference)
+ # ... view logic ...
+```
+
+Guard a class-based view with the `FeatureRequiredMixin`:
+
+```python
+from django_program.features import FeatureRequiredMixin
+
+class TicketListView(ConferenceMixin, FeatureRequiredMixin, ListView):
+ required_feature = ("registration", "public_ui")
+```
+
+The mixin checks all listed features during `dispatch()`. If the view also uses
+`ConferenceMixin` (placed before `FeatureRequiredMixin` in the MRO), per-conference
+overrides are picked up automatically from `self.conference`.
+
+#### Using features in templates
+
+Add the context processor to your `TEMPLATES` setting:
+
+```python
+TEMPLATES = [
+ {
+ "OPTIONS": {
+ "context_processors": [
+ # ... existing processors ...
+ "django_program.context_processors.program_features",
+ ],
+ },
+ },
+]
+```
+
+Then use `program_features` in your templates to conditionally render sections:
+
+```html+django
+{% if program_features.registration_enabled %}
+ Buy Tickets
+{% endif %}
+
+{% if program_features.sponsors_enabled %}
+ Our Sponsors
+{% endif %}
+```
+
+The context processor resolves each flag through `is_feature_enabled()`, so master
+switch logic and per-conference DB overrides are applied. When the request has a
+`conference` attribute (set by middleware or the view), that conference's `FeatureFlags`
+row is consulted automatically.
+
### Accessing config in code
```python
@@ -145,8 +307,14 @@ slug = "pycon-us-2027" # optional, auto-generated from name
venue = "Convention Center" # optional
pretalx_event_slug = "pycon-us" # optional, for Pretalx sync
website_url = "https://..." # optional
+total_capacity = 2500 # optional, 0 = unlimited (default)
```
+`total_capacity` sets the maximum number of tickets sold across all ticket types for the
+entire conference. Add-ons do not count toward this limit. Set to `0` or omit entirely
+for unlimited capacity. See [Global Ticket Capacity](registration-flow.md#global-ticket-capacity)
+for enforcement details.
+
### Sections (required, at least one)
```toml
diff --git a/docs/index.rst b/docs/index.rst
index fdeca6c..5b29811 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -41,6 +41,18 @@ Built for PyCon US but designed to work for any conference that uses Django.
Speaker and schedule sync architecture, schema regeneration, and the pretalx-client package.
+ .. grid-item-card:: Programs & Activities
+ :link: programs-activities
+ :link-type: doc
+
+ Travel grants, sprints, tutorials, open spaces, and activity signups.
+
+ .. grid-item-card:: Management Dashboard
+ :link: management-dashboard
+ :link-type: doc
+
+ Financial overview, voucher bulk generation, and organizer tools.
+
.. grid-item-card:: API Reference
:link: api/index
:link-type: doc
@@ -103,6 +115,8 @@ Quick Start
getting-started/index
configuration
registration-flow
+ programs-activities
+ management-dashboard
.. toctree::
:maxdepth: 2
diff --git a/docs/management-dashboard.md b/docs/management-dashboard.md
new file mode 100644
index 0000000..2780daf
--- /dev/null
+++ b/docs/management-dashboard.md
@@ -0,0 +1,136 @@
+# Management Dashboard
+
+The management dashboard is the organizer-facing interface for administering a conference. It lives under the `/manage/` URL prefix (configured in your project's `urls.py`) and requires authentication. All conference-scoped pages sit under `/manage//`.
+
+Access to the standard management views is restricted to users who meet at least one of these criteria:
+
+- Django superuser
+- Holds the `program_conference.change_conference` permission
+
+The financial overview dashboard at `/manage//financial/` additionally allows access to users who belong to the "Program: Finance & Accounting" group.
+
+The sidebar organizes features into sections: **Content** (sections, rooms, speakers, talks, schedule, overrides), **Registration** (financial overview, orders, ticket types, add-ons, vouchers), **Sponsors** (levels, sponsors), and **Programs** (activities, travel grants).
+
+---
+
+## Financial Overview
+
+The financial overview dashboard gives organizers a single-page summary of revenue, orders, payments, and ticket sales for a conference.
+
+**URL**: `/manage//financial/`
+
+Navigate to it from the sidebar under **Registration > Financial**.
+
+### Summary Cards
+
+Four headline metrics appear at the top of the page:
+
+| Card | What It Shows |
+|---|---|
+| **Net Revenue** | Gross revenue from paid orders minus total refunds. Gross revenue is shown beneath when refunds exist. |
+| **Total Orders** | Count of all orders in any status. Shows the paid count below when paid orders exist. |
+| **Active Carts** | Number of open, non-expired carts. |
+| **Credits Outstanding** | Remaining spendable balance of available credits. Shows the total applied amount beneath. |
+
+Net revenue is calculated as: `SUM(total) of paid orders - SUM(amount) of credits linked to source orders`. Refund amounts come from `Credit` records tied to source orders rather than summing refunded order totals, so partial refunds are counted accurately.
+
+### Orders by Status
+
+A table breaking down order counts by status: **Paid**, **Pending**, **Refunded**, **Partial Refund**, and **Cancelled**.
+
+### Carts by Status
+
+Counts for each cart state: **Active** (open and not expired), **Checked Out**, **Expired**, and **Abandoned**.
+
+### Payments by Method
+
+For each payment method (Stripe, manual, etc.), the dashboard shows the number of payments and total dollar amount collected through that method.
+
+### Ticket Type Sales
+
+A per-ticket-type breakdown showing:
+
+- **Ticket Type** name
+- **Price** per unit
+- **Sold** count (tickets on paid and partially-refunded orders)
+- **Remaining** inventory (or an infinity symbol for uncapped ticket types)
+- **Revenue** generated by that ticket type
+
+### Recent Orders
+
+The 20 most recent orders across all statuses, showing reference number (linked to the order detail page), user email, status badge, total, and creation date.
+
+### Active Carts
+
+All currently open, non-expired carts listing the user, item count, creation time, and expiration time.
+
+---
+
+## Voucher Management
+
+### Bulk Code Generation
+
+Generate a batch of voucher codes from a single form instead of creating them one at a time.
+
+**URL**: `/manage//vouchers/bulk/generate/`
+
+Navigate to it from the voucher list page or go directly to the URL. After generation, the dashboard redirects to the voucher list with a success message.
+
+### Form Fields
+
+| Field | Required | Description |
+|---|---|---|
+| **Prefix** | Yes | A fixed string prepended to every generated code (max 20 characters). Use something descriptive like `SPEAKER-` or `SPONSOR-`. |
+| **Count** | Yes | Number of codes to create. Minimum 1, maximum 500. |
+| **Voucher Type** | Yes | The discount model: **Complimentary** (100% off), **Percentage discount**, or **Fixed amount discount**. |
+| **Discount Value** | Yes | The percentage (0--100) or fixed dollar amount. Ignored for comp vouchers. Defaults to 0. |
+| **Applicable Ticket Types** | No | Restrict these vouchers to specific ticket types. Leave unchecked for all ticket types. Rendered as checkboxes scoped to the current conference. |
+| **Applicable Add-ons** | No | Restrict these vouchers to specific add-ons. Leave unchecked for all add-ons. Rendered as checkboxes scoped to the current conference. |
+| **Max Uses** | Yes | Maximum number of times each individual code can be redeemed. Defaults to 1. |
+| **Valid From** | No | Start of the validity window (datetime picker). Leave blank for immediately valid. |
+| **Valid Until** | No | End of the validity window (datetime picker). Leave blank for no expiration. |
+| **Unlocks Hidden Tickets** | No | When checked, these vouchers reveal ticket types that are hidden behind a voucher requirement. |
+
+### Generated Code Format
+
+Each code follows the pattern `{prefix}{8 random characters}`. The random portion uses uppercase letters and digits (A-Z, 0-9) generated with `secrets.choice` for cryptographic randomness. Codes are checked against existing vouchers for the same conference and prefix before insertion.
+
+All vouchers in the batch are created in a single database transaction. If any step fails, the entire batch is rolled back and no partial codes are left behind.
+
+### After Generation
+
+On success, the form redirects to the voucher list at `/manage//vouchers/` and displays a message confirming how many codes were created and the prefix used. Each generated voucher appears in the standard voucher list where it can be individually edited.
+
+---
+
+## Other Dashboard Features
+
+The management sidebar exposes several additional views beyond financial reporting and voucher generation:
+
+**Content**
+
+- **Dashboard** -- Conference overview with key stats and quick actions.
+- **Settings** -- Edit conference name, dates, slug, and configuration.
+- **Sections** -- Create and manage conference sections (tracks or categories).
+- **Rooms** -- Manage physical or virtual rooms for scheduling.
+- **Speakers** -- Browse and view speaker profiles synced from Pretalx.
+- **Talks** -- List, filter by submission type, view details, and edit talks. The sidebar shows submission type sub-navigation with counts.
+- **Schedule** -- View and edit schedule slots.
+- **Overrides** -- Override Pretalx-synced data for talks, speakers, rooms, sponsors, and submission type defaults.
+
+**Registration**
+
+- **Orders** -- Full order list with filtering, detail views, and manual payment recording.
+- **Ticket Types** -- Create and edit ticket types with pricing, availability windows, and stock limits.
+- **Add-ons** -- Manage conference add-ons (t-shirts, tutorials, etc.).
+- **Vouchers** -- Individual voucher CRUD alongside the bulk generation tool.
+
+**Sponsors**
+
+- **Levels** -- Define sponsorship tiers and their associated benefits.
+- **Sponsors** -- Manage individual sponsors and their level assignments.
+
+**Programs**
+
+- **Activities** -- Create sprints, tutorials, and open spaces with signup caps. Includes per-activity dashboards with attendee export and waitlist promotion.
+- **Travel Grants** -- Review applications, send messages, and record disbursements. Includes receipt review queue with approve/flag workflow.
diff --git a/docs/pretalx-integration.md b/docs/pretalx-integration.md
index 7c91f21..0639c04 100644
--- a/docs/pretalx-integration.md
+++ b/docs/pretalx-integration.md
@@ -58,6 +58,173 @@ consumes `PretalxClient` and maps the typed dataclasses into Django ORM models
`src/django_program/pretalx/client.py` bridges the workspace package into the
Django app's import namespace.
+## Overrides
+
+Pretalx is the source of truth for your schedule, but the real world does not
+care about your source of truth. Speakers cancel at the last minute. Rooms get
+renamed. A talk moves from Hall A to Hall B because of a fire alarm. The
+override system lets organizers patch any synced field locally without touching
+the upstream Pretalx record, and those patches survive re-syncs.
+
+### How Overrides Work
+
+Each synced model (`Talk`, `Speaker`, `Room`) has a matching one-to-one override
+model (`TalkOverride`, `SpeakerOverride`, `RoomOverride`). Override fields
+default to blank/null. When a field is populated, it takes priority over the
+synced value. When blank, the original synced value shows through.
+
+The synced models expose `effective_*` properties that handle this resolution:
+
+```python
+talk = Talk.objects.get(pretalx_code="ABCDEF")
+
+# Returns the synced title unless a TalkOverride sets override_title
+talk.effective_title
+
+# Returns "cancelled" if is_cancelled=True on the override,
+# otherwise override_state if set, otherwise the synced state
+talk.effective_state
+```
+
+Use `effective_*` properties in templates and views. Use the bare fields
+(`talk.title`, `talk.state`) only when you need the raw synced value.
+
+### Override Models
+
+#### TalkOverride
+
+One-to-one with `Talk`. Overridable fields:
+
+| Field | Type | Effect |
+|------------------------|---------------|--------------------------------------------------|
+| `override_title` | `CharField` | Replaces `talk.effective_title` |
+| `override_abstract` | `TextField` | Replaces `talk.effective_abstract` |
+| `override_state` | `CharField` | Replaces `talk.effective_state` |
+| `override_room` | `ForeignKey` | Replaces `talk.effective_room` |
+| `override_slot_start` | `DateTimeField` | Replaces `talk.effective_slot_start` |
+| `override_slot_end` | `DateTimeField` | Replaces `talk.effective_slot_end` |
+| `is_cancelled` | `BooleanField` | Forces `talk.effective_state` to `"cancelled"` |
+| `note` | `TextField` | Internal-only note (not displayed to attendees) |
+
+The `is_cancelled` flag takes priority over `override_state`. If both are set,
+the talk shows as cancelled.
+
+#### SpeakerOverride
+
+One-to-one with `Speaker`. Overridable fields:
+
+| Field | Type | Effect |
+|------------------------|-------------|-------------------------------------------|
+| `override_name` | `CharField` | Replaces `speaker.effective_name` |
+| `override_biography` | `TextField` | Replaces `speaker.effective_biography` |
+| `override_avatar_url` | `URLField` | Replaces `speaker.effective_avatar_url` |
+| `override_email` | `EmailField`| Replaces `speaker.effective_email` |
+| `note` | `TextField` | Internal-only note |
+
+#### RoomOverride
+
+One-to-one with `Room`. Overridable fields:
+
+| Field | Type | Effect |
+|-------------------------|------------------------|------------------------------------------|
+| `override_name` | `CharField` | Replaces `room.effective_name` |
+| `override_description` | `TextField` | Replaces `room.effective_description` |
+| `override_capacity` | `PositiveIntegerField` | Replaces `room.effective_capacity` |
+| `note` | `TextField` | Internal-only note |
+
+### SubmissionTypeDefault
+
+`SubmissionTypeDefault` is not an override in the same sense. It provides
+fallback room and time-slot values for talks of a given Pretalx submission type
+(e.g. "Poster", "Tutorial") that arrive from Pretalx without scheduling data.
+
+| Field | Type | Purpose |
+|----------------------|--------------|--------------------------------------------------|
+| `submission_type` | `CharField` | The Pretalx type name to match (case-sensitive) |
+| `default_room` | `ForeignKey` | Room assigned to unscheduled talks of this type |
+| `default_date` | `DateField` | Date for synthesized slot times |
+| `default_start_time` | `TimeField` | Start time combined with `default_date` |
+| `default_end_time` | `TimeField` | End time combined with `default_date` |
+
+Type defaults are applied automatically at the end of `sync_all()` via
+`apply_type_defaults()`. They only affect talks where `room` is `None`, so
+talks already assigned a room by Pretalx are left untouched.
+
+### Creating Overrides via Django Admin
+
+All override models are registered in the Django admin. Navigate to
+**Pretalx > Talk Overrides** (or Speaker Overrides, Room Overrides) to create
+or edit overrides.
+
+#### Cancel a talk
+
+1. Go to **Pretalx > Talk Overrides > Add**.
+2. Select the talk from the `talk` field.
+3. Check `is_cancelled`.
+4. Add a note explaining why (e.g. "Speaker flight cancelled").
+5. Save. The talk's `effective_state` now returns `"cancelled"`.
+
+#### Move a talk to a different room
+
+1. Go to **Pretalx > Talk Overrides > Add**.
+2. Select the talk.
+3. Set `override_room` to the new room.
+4. Optionally adjust `override_slot_start` and `override_slot_end` if the time
+ also changed.
+5. Save.
+
+#### Rename a room on-site
+
+1. Go to **Pretalx > Room Overrides > Add**.
+2. Select the room.
+3. Set `override_name` to the correct name (e.g. "Hall B" instead of
+ "Ballroom 2").
+4. Save. All schedule displays using `room.effective_name` now show the new
+ name.
+
+#### Set up defaults for poster sessions
+
+1. Go to **Pretalx > Submission Type Defaults > Add**.
+2. Set `submission_type` to `"Poster"` (must match the Pretalx type exactly).
+3. Set `default_room` to the poster hall.
+4. Set `default_date`, `default_start_time`, and `default_end_time` for the
+ poster session window.
+5. Save. On the next sync, any poster talks arriving without scheduling data
+ get assigned to this room and time slot.
+
+### Overrides and Sync
+
+Overrides are stored in separate database tables from the synced data. When
+`sync_all()` runs, it updates the `Talk`, `Speaker`, and `Room` tables with
+fresh data from Pretalx. The override tables are not touched. This means:
+
+- Overrides persist across syncs. A `TalkOverride` created before a sync is
+ still there after the sync completes.
+- The synced (base) fields get updated to match Pretalx. If Pretalx changes a
+ talk's title, the `Talk.title` field updates, but `TalkOverride.override_title`
+ stays as-is. The `effective_title` property still returns the override value.
+- Override resolution happens at read time via the `effective_*` properties, not
+ during sync. There is no merge step.
+- Deleting an override restores the synced values instantly. The base data was
+ never modified.
+
+The `conference` field on each override is validated against the parent entity's
+conference. The admin and model validation both reject overrides that reference
+entities from a different conference.
+
+### The `is_empty` Property
+
+Each override model has an `is_empty` property that returns `True` when no
+override fields carry a value (only the `note` field is populated, or everything
+is blank). Use this to identify overrides that were created but never filled in:
+
+```python
+stale = TalkOverride.objects.all()
+for override in stale:
+ if override.is_empty:
+ override.delete()
+```
+
## Schema Regeneration
### Prerequisites
diff --git a/docs/programs-activities.md b/docs/programs-activities.md
new file mode 100644
index 0000000..6ef5c68
--- /dev/null
+++ b/docs/programs-activities.md
@@ -0,0 +1,158 @@
+# Programs & Activities
+
+The `django_program.programs` app handles conference activities (sprints, tutorials, open spaces) and travel grant applications. All views are scoped to a conference via the `conference_slug` URL parameter and gated behind feature flags.
+
+## Travel Grants
+
+Travel grants follow the PyCon US model: attendees apply for financial assistance, reviewers evaluate and offer grants, and recipients submit receipts for reimbursement.
+
+### Application flow
+
+An attendee submits a {class}`~django_program.programs.models.TravelGrant` through the `TravelGrantApplyView`. The application form collects:
+
+- **Request type** -- ticket only, or ticket plus travel grant.
+- **Travel details** -- origin city, international flag, first-time attendee status.
+- **Travel plan breakdown** -- airfare and lodging with descriptions and estimated amounts.
+- **Requested amount** -- total USD amount requested. Validated against `max_grant_amount` from `DJANGO_PROGRAM` settings (default `3000`).
+- **Applicant profile** -- experience level, occupation, community involvement, and reason for the grant.
+- **Days attending** -- which conference days the applicant plans to attend (see [Day Detection](#day-detection) below).
+
+Each user can submit one grant application per conference, enforced by a `unique_together` constraint on `(conference, user)`.
+
+### Day detection
+
+The `days_attending` field renders as a set of checkboxes with labels derived from the conference schedule. The form uses a two-tier strategy to build these labels:
+
+**Tier 1: Schedule-derived days** -- `get_conference_days()` in `django_program.programs.utils` queries `ScheduleSlot` records for the conference. For each unique date, it counts the `submission_type` of linked talks. When one type accounts for more than half the slots on a given day, that type name is appended to the date label:
+
+```
+Wednesday, May 14 (Tutorial)
+Thursday, May 15 (Tutorial)
+Friday, May 16 (Talk)
+Saturday, May 17 (Talk)
+Sunday, May 18
+```
+
+**Tier 2: Section-based fallback** -- when no schedule slots exist (e.g., before a Pretalx sync), the form falls back to `_build_day_choices()`. This method iterates conference `Section` records and labels each date with the section name and day number:
+
+```
+Tutorials Day 1 -- Wednesday, May 14
+Tutorials Day 2 -- Thursday, May 15
+Talks Day 1 -- Friday, May 16
+Talks Day 2 -- Saturday, May 17
+Sprints Day 1 -- Sunday, May 18
+```
+
+Days not covered by any section get a generic `Day N` label.
+
+The selected checkboxes are serialized to a comma-separated string for storage in `TravelGrant.days_attending`. On edit, the stored string is split back into checkbox selections.
+
+### Grant statuses and lifecycle
+
+The `TravelGrant.GrantStatus` choices define the full lifecycle:
+
+| Status | Description | Applicant can... |
+|---|---|---|
+| `SUBMITTED` | Application received, awaiting review. | Edit, withdraw, send messages. |
+| `INFO_NEEDED` | Reviewers requested more information. | Provide info (resets to `SUBMITTED`), edit, withdraw. |
+| `OFFERED` | Grant approved and offer extended. | Accept or decline. |
+| `NEED_MORE` | Applicant requesting additional funds. | -- |
+| `ACCEPTED` | Applicant accepted the offer. | Upload receipts, submit payment info. |
+| `REJECTED` | Application denied. | -- |
+| `DECLINED` | Applicant declined the offer. | -- |
+| `WITHDRAWN` | Applicant withdrew the application. | -- |
+| `DISBURSED` | Funds sent to the recipient. | -- |
+
+State transitions are enforced by property guards on the model (`is_editable`, `is_actionable`, `show_accept_button`, etc.) and checked in each view before allowing the action.
+
+### Receipts and payment info
+
+After accepting a grant, the recipient uploads expense receipts through `ReceiptUploadView`. Both receipt uploads and payment info submission require the grant to be in `ACCEPTED` status **and** have a non-zero `approved_amount` set by a reviewer. Until a reviewer sets the approved amount, the receipt and payment sections are locked.
+
+- **Receipt types**: `AIRFARE` and `LODGING`.
+- **File validation**: PDF, JPG, JPEG, or PNG, max 10 MB.
+- **Review workflow**: receipts start as pending, can be approved or flagged by reviewers with a reason.
+- **Deletion**: applicants can delete receipts that have not been approved or flagged.
+
+Payment information is collected via {class}`~django_program.programs.models.PaymentInfo`, a one-to-one model on the grant. Supported payment methods:
+
+- Zelle
+- PayPal
+- ACH (Direct Deposit)
+- Wire Transfer
+- Wise
+- Check
+
+Sensitive fields (bank account numbers, routing numbers, email addresses) are stored using `EncryptedCharField` and `EncryptedTextField`. Method-specific fields are validated in `PaymentInfoForm.clean()` -- selecting PayPal requires a PayPal email, selecting ACH requires bank name, account number, and routing number, and so on.
+
+A grant is ready for disbursement (`is_ready_for_disbursement`) when its status is `ACCEPTED`, it has a `PaymentInfo` record, and at least one approved receipt exists.
+
+### Max grant amount
+
+The maximum requestable amount is set by `DJANGO_PROGRAM["max_grant_amount"]` (default `3000`). The `TravelGrantApplicationForm` validates this during `clean()` when the request type is `TICKET_AND_GRANT`. Configure it in your Django settings:
+
+```python
+DJANGO_PROGRAM = {
+ "max_grant_amount": 5000, # USD
+ # ...
+}
+```
+
+### Messaging
+
+Both applicants and reviewers can exchange messages on a grant via {class}`~django_program.programs.models.TravelGrantMessage`. Each message has a `visible` flag:
+
+- **Applicant messages** are always visible to reviewers.
+- **Reviewer messages** with `visible=True` appear on the applicant's status page.
+- **Reviewer messages** with `visible=False` are internal notes, visible only to other reviewers.
+
+### Admin management
+
+Reviewers manage grants through the management dashboard (`django_program.manage`). The `TravelGrantForm` in the dashboard exposes `status`, `approved_amount`, `promo_code`, and `reviewer_notes`. The `ReviewerMessageForm` lets reviewers send messages with visibility control.
+
+The disbursement flow uses `DisbursementForm` to record the actual amount sent and timestamp.
+
+Permissions are controlled by the `review_travel_grant` custom permission on the `TravelGrant` model and `review_receipt` on the `Receipt` model.
+
+## Activities
+
+Activities represent scheduled or drop-in events at the conference: sprints, workshops, tutorials, lightning talks, social events, open spaces, and summits.
+
+### Activity types
+
+The {class}`~django_program.programs.models.Activity` model supports these types via `ActivityType`:
+
+| Type | Label |
+|---|---|
+| `SPRINT` | Sprint |
+| `WORKSHOP` | Workshop |
+| `TUTORIAL` | Tutorial |
+| `LIGHTNING_TALK` | Lightning Talk |
+| `SOCIAL` | Social Event |
+| `OPEN_SPACE` | Open Space |
+| `SUMMIT` | Summit |
+| `OTHER` | Other |
+
+### Signup caps and waitlisting
+
+Set `max_participants` on an activity to limit signups. When `max_participants` is `None`, signups are unlimited.
+
+When an activity reaches capacity, new signups are created with status `WAITLISTED` instead of `CONFIRMED`. Cancelling a confirmed signup automatically promotes the oldest waitlisted signup via `promote_next_waitlisted()`, which runs inside a transaction with `select_for_update()` to prevent race conditions.
+
+```python
+activity.spots_remaining # int | None -- None means unlimited
+```
+
+Each user can have at most one active (non-cancelled) signup per activity, enforced by a conditional `UniqueConstraint`.
+
+### Pretalx integration
+
+Activities can link to Pretalx submission types via the `pretalx_submission_type` field. During a Pretalx sync, all talks matching that submission type are added to the activity's `talks` M2M relationship. The activity detail page then displays linked talks grouped by day with their speakers and schedule slots.
+
+### API reference
+
+For full model, form, and view details, see the autodoc reference:
+
+- {mod}`django_program.programs.models`
+- {mod}`django_program.programs.forms`
+- {mod}`django_program.programs.views`
diff --git a/docs/registration-flow.md b/docs/registration-flow.md
index 4419f9a..171ac1f 100644
--- a/docs/registration-flow.md
+++ b/docs/registration-flow.md
@@ -18,6 +18,63 @@ Before getting into the flow, here are the models involved:
| {class}`~django_program.registration.models.Payment` | A financial transaction against an order. Methods: `STRIPE`, `COMP`, `CREDIT`, `MANUAL`. |
| {class}`~django_program.registration.models.Credit` | 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 = 2500` to 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:
+
+1. **Adding a ticket to the cart** -- `add_ticket()` calls `validate_global_capacity()` with the cart's current ticket count plus the new quantity. If the total exceeds the remaining global capacity, a `ValidationError` is raised.
+
+2. **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`:
+
+```python
+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`, or `PENDING` with `hold_expires_at` still 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.