Ticket oversell: capacity is never reserved between invoice creation and settlement #69

Open
opened 2026-10-09 17:17:36 +00:00 by padreug · 0 comments
Owner

Capacity is enforced only in api_ticket_create (views_api.py:816-821), against event.amount_tickets (or the selected wave's), but that counter is decremented only when the invoice settles in set_ticket_paid (services.py:87-97). Nothing holds a seat for an unpaid invoice. With one seat left, K concurrent buyers all pass the gate, all receive an invoice, and all can pay; set_ticket_paid then runs K times. The per-event asyncio.Lock in set_ticket_paid serialises the decrement but does not reject the K-1 payments that cannot be seated, so the buyer is charged for a ticket that does not exist in inventory. Since the v1.6.8 rebase the same shape exists per wave (selected_wave.amount_tickets -= 1).

Fix direction: reserve at invoice creation with a guarded conditional UPDATE (... SET reserved = reserved + :q WHERE id = :id AND sold + reserved + :q <= capacity) inside one db.connect() block so the ext DB lock serialises it; confirm the reservation in set_ticket_paid; release on purchase failure and in purge_unpaid_tickets. This has to live on the wave (extra.ticket_waves[].amount_tickets) as well as the event-level fallback. The sandbox implemented this against the pre-waves target (sandbox-team/events PR #14, atomicity.py + migrations_fork m003 + tests/test_reservation.py), which is a usable starting point for the event-level path.

Found during reforge run #1 (sandbox events#2).

Capacity is enforced only in `api_ticket_create` (`views_api.py:816-821`), against `event.amount_tickets` (or the selected wave's), but that counter is decremented only when the invoice settles in `set_ticket_paid` (`services.py:87-97`). Nothing holds a seat for an unpaid invoice. With one seat left, K concurrent buyers all pass the gate, all receive an invoice, and all can pay; `set_ticket_paid` then runs K times. The per-event `asyncio.Lock` in `set_ticket_paid` serialises the decrement but does not reject the K-1 payments that cannot be seated, so the buyer is charged for a ticket that does not exist in inventory. Since the v1.6.8 rebase the same shape exists per wave (`selected_wave.amount_tickets -= 1`). Fix direction: reserve at invoice creation with a guarded conditional UPDATE (`... SET reserved = reserved + :q WHERE id = :id AND sold + reserved + :q <= capacity`) inside one `db.connect()` block so the ext DB lock serialises it; confirm the reservation in `set_ticket_paid`; release on purchase failure and in `purge_unpaid_tickets`. This has to live on the wave (`extra.ticket_waves[].amount_tickets`) as well as the event-level fallback. The sandbox implemented this against the pre-waves target (sandbox-team/events PR #14, `atomicity.py` + migrations_fork m003 + `tests/test_reservation.py`), which is a usable starting point for the event-level path. Found during reforge run #1 (sandbox events#2).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/events#69
No description provided.