feat: multi-ticket purchase (quantity 1-10 on a single invoice)

CreateTicket gains a bounded `quantity` field (Field(default=1,
ge=1, le=10)). One ticket row represents N seats on a single
invoice — no schema migration needed; the row's TicketExtra
carries the multiplier.

- api_ticket_create scales the invoice amount by quantity (after
  promo discount applies) and persists `quantity` in
  TicketExtra.quantity.
- Capacity check pre-validates that the requested quantity fits in
  the remaining seats; returns 400 with the actual remaining count
  rather than 410 sold-out so the client can surface a useful
  message.
- set_ticket_paid reads ticket.extra.quantity and adjusts
  event.sold / event.amount_tickets by that amount. Pre-existing
  rows that don't carry quantity default to 1 (TicketExtra schema
  default).
- The per-event asyncio lock added earlier covers this correctly:
  two parallel multi-ticket purchases on the same event serialize
  on counter mutation + republish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-23 22:09:23 +02:00
commit 5afc8885cf
3 changed files with 30 additions and 6 deletions

View file

@ -122,6 +122,11 @@ class TicketExtra(BaseModel):
email_notification_sent: bool = False
nostr_notification_sent: bool = False
refunded: bool = False
# Number of tickets purchased on this row's invoice (1-10).
# One row per purchase keeps the schema unchanged; quantity is
# surfaced separately so set_ticket_paid can adjust capacity by
# the right amount and the front-end can render "N tickets".
quantity: int = 1
class CreateTicket(BaseModel):
@ -133,6 +138,9 @@ class CreateTicket(BaseModel):
nostr_identifier: str | None = None
payment_method: str | None = None
fiat_provider: str | None = None
# Number of tickets to buy on this single invoice. Bounded so a
# bad client can't run away with the organizer's capacity.
quantity: int = Field(default=1, ge=1, le=10)
@root_validator
def validate_identifiers(cls, values):

View file

@ -50,14 +50,19 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
if ticket.paid:
return ticket
# One ticket row may represent N seats sold on a single invoice;
# event counters move by the row's quantity (default 1 for legacy
# purchases that pre-date the multi-ticket field).
quantity = max(1, ticket.extra.quantity)
async with _event_paid_lock(ticket.event):
ticket.paid = True
await update_ticket(ticket)
event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid"
event.sold += 1
event.amount_tickets -= 1
event.sold += quantity
event.amount_tickets -= quantity
await update_event(event)
# Republish the NIP-52 calendar event so connected clients see

View file

@ -508,8 +508,16 @@ async def api_ticket_create(
)
if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
if event.amount_tickets > 0 and event.sold >= event.amount_tickets:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
quantity = data.quantity
if event.amount_tickets > 0:
if event.sold >= event.amount_tickets:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
remaining = event.amount_tickets - event.sold
if quantity > remaining:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Only {remaining} ticket(s) remaining for this event.",
)
name = data.name
email = data.email
@ -531,7 +539,7 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.",
) from exc
price = event.price_per_ticket
unit_price = event.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
if promo_code:
@ -543,7 +551,9 @@ async def api_ticket_create(
# get the promocode
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
extra["promo_code"] = promo.code
price = event.price_per_ticket * (1 - promo.discount_percent / 100)
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100)
# Scale by quantity AFTER the promo applies. One invoice, N tickets.
price = unit_price * quantity
if payment_method == "fiat" and not event.allow_fiat:
raise HTTPException(
@ -613,6 +623,7 @@ async def api_ticket_create(
"nostr_identifier": nostr_identifier,
"ticket_base_url": str(request.base_url).rstrip("/"),
"sats_paid": payment.sat,
"quantity": quantity,
},
)