Ticket signup: hold the card, then confirm by email
Python · Django · advanced · modification
Adds the ticket signup endpoint. After the body is parsed, one `transaction.atomic()` block locks the event row first, so the duplicate, closed and capacity checks, the card hold and the ticket insert are serialised per event. The card is only authorised here (the capture job charges it once the ticket exists), so a rollback after the hold never charges anyone. The confirmation email is registered with `transaction.on_commit(..., robust=True)`: it goes out only after the ticket commits, and an SMTP failure is logged instead of turning a committed signup into a 500. Gateway failures map to 402/502 with a generic message, and the log line carries the event id and the exception type, never the token. Tested locally with concurrent signups against Postgres.
Django 5.1 on PostgreSQL 16, `ATOMIC_REQUESTS = False`, `USE_TZ = True`, session auth; the frontend sends the CSRF token in `X-CSRFToken`. Existing models: `events.Event` with `title`, `starts_at` (aware datetime), `capacity` and `price_cents` (positive integers; every event is paid) and `currency` (ISO 4217 code); `tickets.Ticket` with `event` (FK, `related_name="tickets"`), `user`, `authorization_id`, `captured_at` (null until the capture job runs), `created_at` and a `UniqueConstraint` on (`event`, `user`). Tickets are never deleted or cancelled (support handles refunds and keeps the ticket), and this endpoint is the only code that creates them. `payments.gateway` is the existing client: `authorize(*, amount_cents, currency, payment_method)` returns a `Hold` with an `id`, raises `gateway.CardDeclined` (a subclass of `gateway.GatewayError`) when the card is declined and `GatewayError` for any other failure, including its built-in 5-second timeout, and never retries. Email goes through the provider's SMTP relay with `EMAIL_TIMEOUT = 10` and `DEFAULT_FROM_EMAIL` set. Events are local: `TIME_ZONE` is the venue city's zone, so local times need no zone label. Error reporting does not capture frame locals. Every account has a verified, non-empty `email`.
Requirements
- Add `POST /api/events/<event_id>/signup/` for signed-in attendees. The body is JSON `{"payment_method": "<token>"}`, where the token is the non-empty string the gateway's card widget returns. Responses: `201 {"ticket_id": ...}` for a new ticket; `200` with the existing ticket's id if the user already has a ticket for that event (checked first, whatever the event's state, and without charging again); `401` when not signed in; `400` for a malformed body; `404` for an unknown event; `409 {"error": "signups closed"}` once the event has started (`starts_at <= now`); `409 {"error": "sold out"}` when the event already has `capacity` tickets; `402 {"error": "card declined"}`; `502 {"error": "payment provider unavailable"}` for any other gateway failure.
- Payment: one `gateway.authorize()` hold for the event's `price_cents` in its `currency` per new ticket, and no `authorize()` call for the `200`, `400`, `401`, `404` and `409` responses. The ticket stores the hold's id, and the existing `capture_ticket_holds` job (every 5 minutes) captures the holds of committed tickets. A hold whose ticket never commits is never captured: the gateway releases it after 7 days without charging the card, so failure paths need no void call.
- Capacity and one ticket per user must hold under concurrent requests: an event never gets more than `capacity` tickets, and two concurrent signups never produce two tickets for one user, and never two holds unless the first one fails (a signup that runs after a failed one, concurrent or a retry, may place a new hold; the orphaned one lapses uncharged, as in requirement 2). Serialise signups per event on the event row (`select_for_update`) for the whole check, authorize and insert sequence; at our volume, holding that lock during the gateway call (5-second timeout) is the accepted trade-off.
- Send the confirmation email only after the ticket has committed. Sending it inline in the request is fine (the project has no task queue). An email failure must not fail the signup: the ticket stays, the response is still `201`, and the failure is logged (support resends from the admin).
- Log lines must not contain card tokens.
Files touched
- config/urls.py
- tickets/urls.py
- tickets/views.py
- tickets/emails.py
--- config/urls.py
path("admin/", admin.site.urls),
path("events/", include("events.urls")),
+ path("api/", include("tickets.urls")),
]
--- tickets/urls.py
+from django.urls import path
+
+from . import views
+
+app_name = "tickets"
+
+urlpatterns = [
+ path("events/<int:event_id>/signup/", views.signup, name="signup"),
+]
+
--- tickets/views.py
+import json
+import logging
+
+from django.db import transaction
+from django.http import JsonResponse
+from django.shortcuts import get_object_or_404
+from django.utils import timezone
+from django.views.decorators.http import require_POST
+
+from events.models import Event
+from payments import gateway
+