feat: return buyers to the calling app after Stripe, branded QR endpoint

`_resolve_frontend_root` honours `CreateTicket.frontend_url` when its
origin is one of LNBITS_CORS_ALLOWED_ORIGINS, the LNbits base URL or
LNBITS_CUSTOM_FRONTEND_URL (400 otherwise — a silent fallback would send
the buyer to the wrong app), and falls back to request.base_url as before.
Under that root the fiat path now parameterises the hosted checkout via
`extra["checkout"]` (lnbits StripeCheckoutOptions): success_url
`/events/{id}?checkout=success&tickets=<ids>`, cancel_url
`/events/{id}?checkout=cancelled`, customer_email, an event-named line
item and event_id/quantity/ticket_ids metadata. Ticket ids are minted
before the invoice so the success URL can carry them (the payment_hash
only exists afterwards). ticket_base_url on the rows uses the same root,
so the emailed link lands in the webapp when the webapp was the client.

Purchases are gated on `effective_payment_methods(event)` (checked after
the free-ticket short-circuit, which charges nothing on any rail).

New anonymous `GET /events/api/v1/qr/{ticket_id}` returns a PNG of
`ticket://<id>` — port of upstream v1.6.8's endpoint without ticket-image
compositing — built at error-correction H with the instance QR logo
(`lnbits_qr_logo`) pasted in the centre, matching the client-side QRs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
This commit is contained in:
Padreug 2026-09-06 19:53:05 +02:00
commit f77ad28bdd
4 changed files with 292 additions and 20 deletions

22
tests/test_ticket_qr.py Normal file
View file

@ -0,0 +1,22 @@
from io import BytesIO
from PIL import Image
from ..views_api import make_qr_png
def test_make_qr_png_renders_requested_size():
img = make_qr_png("ticket://abc123", size=200)
assert img.size == (200, 200)
def test_make_qr_png_pastes_a_centred_logo():
logo = Image.new("RGBA", (64, 64), (255, 0, 0, 255))
img = make_qr_png("ticket://abc123", size=300, logo=logo)
assert img.size == (300, 300)
# The centre pixel is inside the pasted logo, so it is red — a plain
# QR would only ever have black or white there.
assert img.getpixel((150, 150))[:3] == (255, 0, 0)
out = BytesIO()
img.save(out, format="PNG")
assert out.getvalue().startswith(b"\x89PNG")