feat: multi-ticket purchases as N rows sharing one payment_hash
Replaces the previous "one row, N seats via extra.quantity" model
with proper one-row-per-attendee semantics. Each attendee gets a
unique scannable id; the door PUT /register/{ticket_id} marks
them registered independently — so a buyer can purchase 3 tickets,
hand 2 QRs to friends arriving separately, and each attendee can
enter on their own schedule.
Schema (migrations_fork.py m002):
- ticket.payment_hash: new TEXT column shared across all rows of
a multi-ticket purchase. Backfilled `payment_hash = id` for
pre-migration rows (id WAS the payment_hash by invariant).
Wire:
- TicketPaymentRequest grows `ticket_ids: list[str]` so the
webapp gets every scannable id back in the create response.
- POST /tickets/{event_id}/{payment_hash} polling endpoint now
reports `ticket_ids` (every row) + keeps `ticket_id` for
back-compat.
- api_ticket_create loops quantity times; the first row reuses
payment_hash as id (preserves legacy `id == payment_hash`
invariant for single-ticket purchases), the rest get
urlsafe_short_hash() uuids.
Payment flow:
- on_invoice_paid fetches all rows by payment_hash and marks each
paid via set_ticket_paid, which now increments event.sold by 1
per row (was N per row via extra.quantity — simpler now). The
per-event asyncio lock still serializes counter + republish so
concurrent multi-ticket purchases for the same event don't
reorder the published Nostr state.
- Each paid row triggers its own send_ticket_notification_in_
background call — no-op for buyers without nostr_identifier /
email, useful when the buyer set those on the row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
36568d3eee
commit
59068fe09d
5 changed files with 157 additions and 45 deletions
32
crud.py
32
crud.py
|
|
@ -41,8 +41,19 @@ async def create_ticket(
|
|||
email: str | None = None,
|
||||
user_id: str | None = None,
|
||||
extra: dict | None = None,
|
||||
ticket_id: str | None = None,
|
||||
) -> Ticket:
|
||||
"""Persist one ticket row.
|
||||
|
||||
`payment_hash` is the LNbits invoice hash shared across all rows
|
||||
of a multi-ticket purchase. `ticket_id` is the row primary key /
|
||||
scannable id; defaults to `payment_hash` for single-ticket
|
||||
purchases so the legacy id == payment_hash invariant holds.
|
||||
Multi-ticket callers pass a unique uuid here so each attendee
|
||||
gets a distinct scannable QR.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
row_id = ticket_id or payment_hash
|
||||
|
||||
# name/email columns are NOT NULL in the schema, so we store "" when only
|
||||
# user_id is supplied. _parse_ticket_row reverses this on read.
|
||||
|
|
@ -54,7 +65,7 @@ async def create_ticket(
|
|||
db_email = email or ""
|
||||
|
||||
db_ticket = Ticket(
|
||||
id=payment_hash,
|
||||
id=row_id,
|
||||
wallet=wallet,
|
||||
event=event,
|
||||
name=db_name,
|
||||
|
|
@ -65,11 +76,12 @@ async def create_ticket(
|
|||
reg_timestamp=now,
|
||||
time=now,
|
||||
extra=TicketExtra(**extra) if extra else TicketExtra(),
|
||||
payment_hash=payment_hash,
|
||||
)
|
||||
await db.insert("events.ticket", db_ticket)
|
||||
|
||||
return Ticket(
|
||||
id=payment_hash,
|
||||
id=row_id,
|
||||
wallet=wallet,
|
||||
event=event,
|
||||
name=name,
|
||||
|
|
@ -80,6 +92,7 @@ async def create_ticket(
|
|||
reg_timestamp=now,
|
||||
time=now,
|
||||
extra=TicketExtra(**extra) if extra else TicketExtra(),
|
||||
payment_hash=payment_hash,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -93,6 +106,21 @@ async def update_ticket(ticket: Ticket) -> Ticket:
|
|||
return ticket
|
||||
|
||||
|
||||
async def get_tickets_by_payment_hash(payment_hash: str) -> list[Ticket]:
|
||||
"""All ticket rows sharing the given LNbits invoice payment_hash.
|
||||
|
||||
For a single-ticket purchase returns one row (legacy invariant
|
||||
`id == payment_hash` still holds). For a multi-ticket purchase
|
||||
returns the N rows created with shared `payment_hash` but
|
||||
distinct `id`s — each attendee's scannable QR.
|
||||
"""
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM events.ticket WHERE payment_hash = :ph",
|
||||
{"ph": payment_hash},
|
||||
)
|
||||
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
||||
|
||||
|
||||
async def get_ticket(payment_hash: str) -> Ticket | None:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM events.ticket WHERE id = :id",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue