feat: issue free tickets without minting an invoice

Free events (price_per_ticket == 0) tried to mint a 0-amount Lightning
invoice via create_payment_request — an invoice that can't settle, and
which the invoice listener would never mark paid, so the ticket never
became scannable.

api_ticket_create now short-circuits when the final charge is 0 (a free
event or a 100%-off promo, computed after promo + quantity) before any
invoice / fiat-provider logic: _issue_free_tickets creates the N rows and
runs each through the existing set_ticket_paid — the same path
on_invoice_paid drives for a settled payment (flip paid, bump
sold/available under the per-event lock, republish the NIP-52 event) —
plus the ticket notification. The response carries a new
TicketPaymentRequest.paid=True with no payment_request so the client
skips the QR / payment-poll and goes straight to the ticket QRs.

No invoice means sats_paid=0, so free tickets are naturally skipped by
refund_tickets. All rows in a batch share one synthetic payment_hash —
the join key the poll / WebSocket / My-Tickets lookups use — mirroring
the paid multi-ticket path.

Self-service forfeit (#28), abuse/identity limits (#29) and
pay-what-you-want/donation tickets (#30) are tracked as follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-20 09:03:44 +02:00
commit 9d7efd7662
2 changed files with 82 additions and 1 deletions

View file

@ -183,6 +183,10 @@ class TicketPaymentRequest(BaseModel):
fiat_payment_request: str | None = None
fiat_provider: str | None = None
is_fiat: bool = False
# True when the tickets are already issued + paid with no invoice to
# settle — free events (price 0) or a 100%-off promo. The client skips
# the QR / payment-poll step and goes straight to the ticket QRs.
paid: bool = False
# Row ids created on this invoice — one for single-ticket
# purchases, N for multi-ticket (each independently scannable at
# the door). Buyers fetch these after payment to render N QRs in