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:
parent
36568d3eee
commit
5afc8885cf
3 changed files with 30 additions and 6 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
19
views_api.py
19
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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue