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
|
email_notification_sent: bool = False
|
||||||
nostr_notification_sent: bool = False
|
nostr_notification_sent: bool = False
|
||||||
refunded: 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):
|
class CreateTicket(BaseModel):
|
||||||
|
|
@ -133,6 +138,9 @@ class CreateTicket(BaseModel):
|
||||||
nostr_identifier: str | None = None
|
nostr_identifier: str | None = None
|
||||||
payment_method: str | None = None
|
payment_method: str | None = None
|
||||||
fiat_provider: 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
|
@root_validator
|
||||||
def validate_identifiers(cls, values):
|
def validate_identifiers(cls, values):
|
||||||
|
|
|
||||||
|
|
@ -50,14 +50,19 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
||||||
if ticket.paid:
|
if ticket.paid:
|
||||||
return ticket
|
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):
|
async with _event_paid_lock(ticket.event):
|
||||||
ticket.paid = True
|
ticket.paid = True
|
||||||
await update_ticket(ticket)
|
await update_ticket(ticket)
|
||||||
|
|
||||||
event = await get_event(ticket.event)
|
event = await get_event(ticket.event)
|
||||||
assert event, "Couldn't get event from ticket being paid"
|
assert event, "Couldn't get event from ticket being paid"
|
||||||
event.sold += 1
|
event.sold += quantity
|
||||||
event.amount_tickets -= 1
|
event.amount_tickets -= quantity
|
||||||
await update_event(event)
|
await update_event(event)
|
||||||
|
|
||||||
# Republish the NIP-52 calendar event so connected clients see
|
# Republish the NIP-52 calendar event so connected clients see
|
||||||
|
|
|
||||||
17
views_api.py
17
views_api.py
|
|
@ -508,8 +508,16 @@ async def api_ticket_create(
|
||||||
)
|
)
|
||||||
if event.canceled:
|
if event.canceled:
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
|
||||||
if event.amount_tickets > 0 and event.sold >= event.amount_tickets:
|
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.")
|
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
|
name = data.name
|
||||||
email = data.email
|
email = data.email
|
||||||
|
|
@ -531,7 +539,7 @@ async def api_ticket_create(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Invalid Nostr identifier.",
|
detail="Invalid Nostr identifier.",
|
||||||
) from exc
|
) from exc
|
||||||
price = event.price_per_ticket
|
unit_price = event.price_per_ticket
|
||||||
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
||||||
|
|
||||||
if promo_code:
|
if promo_code:
|
||||||
|
|
@ -543,7 +551,9 @@ async def api_ticket_create(
|
||||||
# get the promocode
|
# get the promocode
|
||||||
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
|
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
|
||||||
extra["promo_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:
|
if payment_method == "fiat" and not event.allow_fiat:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -613,6 +623,7 @@ async def api_ticket_create(
|
||||||
"nostr_identifier": nostr_identifier,
|
"nostr_identifier": nostr_identifier,
|
||||||
"ticket_base_url": str(request.base_url).rstrip("/"),
|
"ticket_base_url": str(request.base_url).rstrip("/"),
|
||||||
"sats_paid": payment.sat,
|
"sats_paid": payment.sat,
|
||||||
|
"quantity": quantity,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue