diff --git a/models.py b/models.py index d3f43d3..85b57a7 100644 --- a/models.py +++ b/models.py @@ -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): diff --git a/services.py b/services.py index 0a2de28..72f4fe5 100644 --- a/services.py +++ b/services.py @@ -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 diff --git a/views_api.py b/views_api.py index b25d2c4..cb52cc3 100644 --- a/views_api.py +++ b/views_api.py @@ -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, }, )