Parent: #1
Industry analogue
BookMyShow / Ticketmaster: two people end up with the same seat, or capacity+1 is issued after a cart expires and the next person is already offered that inventory. This is a trust-killer. One oversell incident is enough.
What Tickity does today
events.purchaseTicket in convex/events.ts:
- Requires waiting-list status
offered.
- Also allows
expired so a payment that finishes after the 30-minute timer still issues a ticket.
- Does not re-check
totalTickets - (purchased + activeOffers).
- Inserts a
tickets row and patches the waiting-list entry to purchased.
Meanwhile waitingList.expireOffer (and the 1-minute cron) already marked the offer expired and called processQueue, which can offer that same seat to the next waiter.
Result: original payer + next person in queue can both hold inventory for one seat.
There is also no unique constraint on (userId, eventId) for tickets or waiting-list rows (convex/schema.ts). Duplicates are prevented only by a query + OCC.
Why this is P0
Correctness, not scale. Even 3 concurrent buyers can oversell. At 1L it is guaranteed.
Proposed fix
- Purchase must be inventory-safe. Refuse unless:
- waiting-list row is still
offered, belongs to this user, and offerExpiresAt > now, or
- a documented recovery path runs inside the same mutation that re-reserves a free spot (re-check capacity, do not steal an active offer).
- Re-check
availableSpots (or an atomic remaining-count) before insert.
- Add uniqueness:
- waiting list: one active (
waiting | offered | purchased) row per (userId, eventId)
- tickets: one
valid/used ticket per (userId, eventId) unless we explicitly support multiples
- tickets: unique
paymentIntentId (idempotent capture)
- Remove the blanket
expired && payment succeeded → still sell branch, or make it capacity-aware and conflict with processQueue.
Acceptance
- Load test: N tickets, N+1 payers (one offer expired mid-pay). Issued tickets never exceed
totalTickets.
- Duplicate
purchaseTicket with the same Razorpay payment id is a no-op, not a second ticket.
- Unique indexes (or equivalent Convex invariant) reject the double insert.
Files
convex/events.ts (purchaseTicket)
convex/waitingList.ts (expireOffer, processQueue)
convex/schema.ts
app/actions/purchaseTicketDirect.ts
Parent: #1
Industry analogue
BookMyShow / Ticketmaster: two people end up with the same seat, or capacity+1 is issued after a cart expires and the next person is already offered that inventory. This is a trust-killer. One oversell incident is enough.
What Tickity does today
events.purchaseTicketinconvex/events.ts:offered.expiredso a payment that finishes after the 30-minute timer still issues a ticket.totalTickets - (purchased + activeOffers).ticketsrow and patches the waiting-list entry topurchased.Meanwhile
waitingList.expireOffer(and the 1-minute cron) already marked the offer expired and calledprocessQueue, which can offer that same seat to the next waiter.Result: original payer + next person in queue can both hold inventory for one seat.
There is also no unique constraint on
(userId, eventId)for tickets or waiting-list rows (convex/schema.ts). Duplicates are prevented only by a query + OCC.Why this is P0
Correctness, not scale. Even 3 concurrent buyers can oversell. At 1L it is guaranteed.
Proposed fix
offered, belongs to this user, andofferExpiresAt > now, oravailableSpots(or an atomic remaining-count) before insert.waiting|offered|purchased) row per(userId, eventId)valid/usedticket per(userId, eventId)unless we explicitly support multiplespaymentIntentId(idempotent capture)expired && payment succeeded → still sellbranch, or make it capacity-aware and conflict withprocessQueue.Acceptance
totalTickets.purchaseTicketwith the same Razorpay payment id is a no-op, not a second ticket.Files
convex/events.ts(purchaseTicket)convex/waitingList.ts(expireOffer,processQueue)convex/schema.tsapp/actions/purchaseTicketDirect.ts