Skip to content

[P0] Oversell: expired offer + successful payment still creates a ticket #2

Description

@akashcorex

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:

  1. Requires waiting-list status offered.
  2. Also allows expired so a payment that finishes after the 30-minute timer still issues a ticket.
  3. Does not re-check totalTickets - (purchased + activeOffers).
  4. 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

  1. 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).
  2. Re-check availableSpots (or an atomic remaining-count) before insert.
  3. 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)
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureBackend design, data model, isolationbugSomething isn't workingpaymentsRazorpay, fulfillment, refunds, payoutspriority:p0Correctness bug or will fail a 1 lakh onsale

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions