feat: guest checkout, Stripe return flow, emailed QR tickets, per-event payment methods (v1.6.1-aio.8) #36
4 changed files with 292 additions and 20 deletions
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
commit
f77ad28bdd
|
|
@ -6,12 +6,13 @@ from loguru import logger
|
||||||
from .crud import db
|
from .crud import db
|
||||||
from .tasks import wait_for_paid_invoices
|
from .tasks import wait_for_paid_invoices
|
||||||
from .views import events_generic_router
|
from .views import events_generic_router
|
||||||
from .views_api import events_api_router, tickets_api_router
|
from .views_api import events_api_router, qr_api_router, tickets_api_router
|
||||||
|
|
||||||
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
||||||
events_ext.include_router(events_generic_router)
|
events_ext.include_router(events_generic_router)
|
||||||
events_ext.include_router(events_api_router)
|
events_ext.include_router(events_api_router)
|
||||||
events_ext.include_router(tickets_api_router)
|
events_ext.include_router(tickets_api_router)
|
||||||
|
events_ext.include_router(qr_api_router)
|
||||||
|
|
||||||
events_static_files = [
|
events_static_files = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
52
tests/test_frontend_root.py
Normal file
52
tests/test_frontend_root.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from ..models import CreateTicket
|
||||||
|
from ..views_api import _allowed_frontend_origins, _resolve_frontend_root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lnbits_settings(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
settings,
|
||||||
|
"lnbits_custom_frontend_url",
|
||||||
|
"https://Front.Example/login",
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request(base_url: str = "https://lnbits.example/"):
|
||||||
|
return SimpleNamespace(base_url=base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlist_collects_every_configured_origin(lnbits_settings):
|
||||||
|
assert _allowed_frontend_origins() == {
|
||||||
|
"https://lnbits.example",
|
||||||
|
"https://app.example",
|
||||||
|
"https://front.example",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings):
|
||||||
|
data = CreateTicket(user_id="u1")
|
||||||
|
assert _resolve_frontend_root(data, _request()) == "https://lnbits.example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings):
|
||||||
|
data = CreateTicket(user_id="u1", frontend_url="https://app.example/events/")
|
||||||
|
assert _resolve_frontend_root(data, _request()) == "https://app.example/events"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlisted_origin_is_rejected_loudly(lnbits_settings):
|
||||||
|
data = CreateTicket(user_id="u1", frontend_url="https://evil.example/events")
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_resolve_frontend_root(data, _request())
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "frontend_url" in exc.value.detail
|
||||||
22
tests/test_ticket_qr.py
Normal file
22
tests/test_ticket_qr.py
Normal 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")
|
||||||
235
views_api.py
235
views_api.py
|
|
@ -1,8 +1,13 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pyqrcode # type: ignore[import-untyped]
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Depends,
|
Depends,
|
||||||
|
|
@ -12,18 +17,19 @@ from fastapi import (
|
||||||
WebSocket,
|
WebSocket,
|
||||||
WebSocketDisconnect,
|
WebSocketDisconnect,
|
||||||
)
|
)
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
from lnbits.core.crud.wallets import get_wallet
|
||||||
from lnbits.core.models import Account, User, WalletTypeInfo
|
from lnbits.core.models import Account, User, WalletTypeInfo
|
||||||
from lnbits.core.models.payments import CreateInvoice
|
from lnbits.core.models.payments import CreateInvoice
|
||||||
from lnbits.core.services import create_payment_request
|
from lnbits.core.services import create_payment_request
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
check_admin,
|
check_admin,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.exchange_rates import (
|
from lnbits.utils.exchange_rates import (
|
||||||
fiat_amount_as_satoshis,
|
fiat_amount_as_satoshis,
|
||||||
|
|
@ -31,6 +37,8 @@ from lnbits.utils.exchange_rates import (
|
||||||
satoshis_amount_as_fiat,
|
satoshis_amount_as_fiat,
|
||||||
)
|
)
|
||||||
from lnbits.utils.nostr import normalize_public_key
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
|
from loguru import logger
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_event,
|
create_event,
|
||||||
|
|
@ -64,6 +72,8 @@ from .models import (
|
||||||
PublicTicket,
|
PublicTicket,
|
||||||
Ticket,
|
Ticket,
|
||||||
TicketPaymentRequest,
|
TicketPaymentRequest,
|
||||||
|
TicketResendResult,
|
||||||
|
effective_payment_methods,
|
||||||
)
|
)
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
from .services import (
|
from .services import (
|
||||||
|
|
@ -76,6 +86,7 @@ from .tasks import deregister_payment_listener, register_payment_listener
|
||||||
|
|
||||||
events_api_router = APIRouter(prefix="/api/v1/events")
|
events_api_router = APIRouter(prefix="/api/v1/events")
|
||||||
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||||
|
qr_api_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
def _is_fiat_currency(currency: str | None) -> bool:
|
def _is_fiat_currency(currency: str | None) -> bool:
|
||||||
|
|
@ -513,6 +524,136 @@ async def api_get_ticket(ticket_id: str) -> Ticket:
|
||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
def _origin(url: str | None) -> str | None:
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
parts = urlsplit(url.strip())
|
||||||
|
if not parts.scheme or not parts.netloc:
|
||||||
|
return None
|
||||||
|
return f"{parts.scheme.lower()}://{parts.netloc.lower()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_frontend_origins() -> set[str]:
|
||||||
|
"""Origins a buyer-side client may name in `CreateTicket.frontend_url`.
|
||||||
|
|
||||||
|
The CORS allow-list is literally "which web apps may talk to this
|
||||||
|
LNbits", so it is the natural allow-list for "which web apps may be
|
||||||
|
linked from a ticket email". The LNbits host itself and the configured
|
||||||
|
custom frontend are always fine.
|
||||||
|
"""
|
||||||
|
origins: set[str] = set()
|
||||||
|
for candidate in [
|
||||||
|
*getattr(settings, "lnbits_cors_allowed_origins", []),
|
||||||
|
settings.lnbits_baseurl,
|
||||||
|
getattr(settings, "lnbits_custom_frontend_url", None),
|
||||||
|
]:
|
||||||
|
origin = _origin(candidate)
|
||||||
|
if origin:
|
||||||
|
origins.add(origin)
|
||||||
|
return origins
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
|
||||||
|
"""Root under which `/events/{event_id}` and `/events/ticket/{ticket_id}`
|
||||||
|
resolve for the buyer — the calling app when it says so, else the LNbits
|
||||||
|
host (the extension's own Quasar pages)."""
|
||||||
|
if not data.frontend_url:
|
||||||
|
return str(request.base_url).rstrip("/")
|
||||||
|
origin = _origin(data.frontend_url)
|
||||||
|
if not origin or origin not in _allowed_frontend_origins():
|
||||||
|
# Fail loud rather than silently falling back: a wrong root means
|
||||||
|
# the buyer is returned to (and emailed a link into) the wrong app.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="frontend_url origin is not allowed.",
|
||||||
|
)
|
||||||
|
return data.frontend_url.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
_qr_logo_cache: dict[str, Image.Image | None] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_qr_logo() -> Image.Image | None:
|
||||||
|
"""LNbits' "QR Code/Favicon Logo" setting, as a Pillow image (cached).
|
||||||
|
|
||||||
|
Local `/static/...` values resolve inside the LNbits package; absolute
|
||||||
|
URLs are fetched once. Any failure just yields a plain QR.
|
||||||
|
"""
|
||||||
|
source = (settings.lnbits_qr_logo or "").strip()
|
||||||
|
if not source:
|
||||||
|
return None
|
||||||
|
if source in _qr_logo_cache:
|
||||||
|
return _qr_logo_cache[source]
|
||||||
|
logo: Image.Image | None = None
|
||||||
|
try:
|
||||||
|
if source.startswith(("http://", "https://")):
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
resp = await client.get(source)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logo = Image.open(BytesIO(resp.content)).convert("RGBA")
|
||||||
|
else:
|
||||||
|
local = Path(settings.lnbits_path) / source.lstrip("/")
|
||||||
|
if local.is_file():
|
||||||
|
logo = Image.open(local).convert("RGBA")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"QR logo '{source}' unavailable: {exc}")
|
||||||
|
logo = None
|
||||||
|
_qr_logo_cache[source] = logo
|
||||||
|
return logo
|
||||||
|
|
||||||
|
|
||||||
|
def make_qr_png(
|
||||||
|
data: str,
|
||||||
|
size: int = 235,
|
||||||
|
border: int = 4,
|
||||||
|
logo: Image.Image | None = None,
|
||||||
|
) -> Image.Image:
|
||||||
|
"""Render `data` as a QR image (upstream v1.6.8 shape). With `logo`, the
|
||||||
|
code is built at error-correction level H and the logo is pasted in the
|
||||||
|
centre on a white pad at ≤ 20 % of the width — the same look LNbits'
|
||||||
|
client-side `lnbits-qrcode` component produces."""
|
||||||
|
qr = pyqrcode.create(data, error="H" if logo is not None else "M")
|
||||||
|
matrix = qr.code
|
||||||
|
modules = len(matrix)
|
||||||
|
|
||||||
|
total_modules = modules + border * 2
|
||||||
|
box_size = max(1, size // total_modules)
|
||||||
|
img_size = total_modules * box_size
|
||||||
|
|
||||||
|
img = Image.new("RGBA", (img_size, img_size), "white")
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
for y, row in enumerate(matrix):
|
||||||
|
for x, cell in enumerate(row):
|
||||||
|
if cell:
|
||||||
|
x0 = (x + border) * box_size
|
||||||
|
y0 = (y + border) * box_size
|
||||||
|
draw.rectangle(
|
||||||
|
[x0, y0, x0 + box_size - 1, y0 + box_size - 1],
|
||||||
|
fill="black",
|
||||||
|
)
|
||||||
|
|
||||||
|
if img_size != size:
|
||||||
|
img = img.resize((size, size), Image.Resampling.NEAREST)
|
||||||
|
|
||||||
|
if logo is not None:
|
||||||
|
logo_size = max(8, int(size * 0.2))
|
||||||
|
pad = max(2, logo_size // 8)
|
||||||
|
scaled = logo.copy()
|
||||||
|
scaled.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS)
|
||||||
|
plate = Image.new(
|
||||||
|
"RGBA", (scaled.width + 2 * pad, scaled.height + 2 * pad), "white"
|
||||||
|
)
|
||||||
|
plate.paste(scaled, (pad, pad), scaled)
|
||||||
|
img.paste(
|
||||||
|
plate,
|
||||||
|
((size - plate.width) // 2, (size - plate.height) // 2),
|
||||||
|
plate,
|
||||||
|
)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
async def _issue_free_tickets(
|
async def _issue_free_tickets(
|
||||||
*,
|
*,
|
||||||
event: Event,
|
event: Event,
|
||||||
|
|
@ -522,7 +663,7 @@ async def _issue_free_tickets(
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
promo_code: str | None,
|
promo_code: str | None,
|
||||||
nostr_identifier: str | None,
|
nostr_identifier: str | None,
|
||||||
request: Request,
|
frontend_root: str,
|
||||||
) -> TicketPaymentRequest:
|
) -> TicketPaymentRequest:
|
||||||
"""Issue `quantity` free tickets without minting an invoice.
|
"""Issue `quantity` free tickets without minting an invoice.
|
||||||
|
|
||||||
|
|
@ -552,7 +693,7 @@ async def _issue_free_tickets(
|
||||||
extra={
|
extra={
|
||||||
"applied_promo_code": promo_code,
|
"applied_promo_code": promo_code,
|
||||||
"nostr_identifier": nostr_identifier,
|
"nostr_identifier": nostr_identifier,
|
||||||
"ticket_base_url": str(request.base_url).rstrip("/"),
|
"ticket_base_url": frontend_root,
|
||||||
"sats_paid": 0,
|
"sats_paid": 0,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -588,7 +729,9 @@ async def api_ticket_create(
|
||||||
quantity = data.quantity
|
quantity = data.quantity
|
||||||
if event.amount_tickets > 0:
|
if event.amount_tickets > 0:
|
||||||
if event.sold >= event.amount_tickets:
|
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
|
remaining = event.amount_tickets - event.sold
|
||||||
if quantity > remaining:
|
if quantity > remaining:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -618,6 +761,7 @@ async def api_ticket_create(
|
||||||
) from exc
|
) from exc
|
||||||
unit_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}
|
||||||
|
frontend_root = _resolve_frontend_root(data, request)
|
||||||
|
|
||||||
if promo_code:
|
if promo_code:
|
||||||
# check if promo_code exists in event.extra.promo_codes
|
# check if promo_code exists in event.extra.promo_codes
|
||||||
|
|
@ -645,13 +789,16 @@ async def api_ticket_create(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
promo_code=promo_code,
|
promo_code=promo_code,
|
||||||
nostr_identifier=nostr_identifier,
|
nostr_identifier=nostr_identifier,
|
||||||
request=request,
|
frontend_root=frontend_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
if payment_method == "fiat" and not event.allow_fiat:
|
# Organizer-controlled rails (extra.payment_methods; legacy events fall
|
||||||
|
# back to Lightning + fiat-if-allow_fiat). Checked after the free path
|
||||||
|
# because a free claim charges nothing on any rail.
|
||||||
|
if payment_method not in effective_payment_methods(event):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Fiat payments are not enabled for this event.",
|
detail="Payment method not enabled for this event.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if _is_fiat_currency(event.currency):
|
if _is_fiat_currency(event.currency):
|
||||||
|
|
@ -692,6 +839,36 @@ async def api_ticket_create(
|
||||||
else:
|
else:
|
||||||
invoice_unit = "sat"
|
invoice_unit = "sat"
|
||||||
|
|
||||||
|
# Each row gets a fresh urlsafe_short_hash id so single- and
|
||||||
|
# multi-ticket purchases stay shape-consistent — every scannable
|
||||||
|
# ticket id is a short hash, never the long bolt11 payment_hash.
|
||||||
|
# The shared `payment_hash` column is the join key for invoice
|
||||||
|
# lookup (poll endpoint, ws notifier, set_ticket_paid loop). Ids are
|
||||||
|
# minted BEFORE the invoice so the fiat success URL can carry them
|
||||||
|
# (the payment_hash only exists after `create_payment_request`).
|
||||||
|
ticket_ids: list[str] = [urlsafe_short_hash() for _ in range(quantity)]
|
||||||
|
|
||||||
|
if payment_method == "fiat":
|
||||||
|
# Parameterise the provider's hosted checkout (consumed by
|
||||||
|
# lnbits/fiat/stripe.py `StripeCheckoutOptions`): bring the buyer
|
||||||
|
# back to the app they came from, lock the email they gave us, and
|
||||||
|
# label the line item with the event rather than the raw memo.
|
||||||
|
ticket_label = "ticket" if quantity == 1 else "tickets"
|
||||||
|
extra["checkout"] = {
|
||||||
|
"success_url": (
|
||||||
|
f"{frontend_root}/events/{event.id}"
|
||||||
|
f"?checkout=success&tickets={','.join(ticket_ids)}"
|
||||||
|
),
|
||||||
|
"cancel_url": f"{frontend_root}/events/{event.id}?checkout=cancelled",
|
||||||
|
"customer_email": email,
|
||||||
|
"line_item_name": f"{event.name} — {quantity} {ticket_label}",
|
||||||
|
"metadata": {
|
||||||
|
"event_id": event.id,
|
||||||
|
"quantity": str(quantity),
|
||||||
|
"ticket_ids": ",".join(ticket_ids),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
payment = await create_payment_request(
|
payment = await create_payment_request(
|
||||||
wallet_id=event.wallet,
|
wallet_id=event.wallet,
|
||||||
invoice_data=CreateInvoice(
|
invoice_data=CreateInvoice(
|
||||||
|
|
@ -703,15 +880,8 @@ async def api_ticket_create(
|
||||||
extra=extra,
|
extra=extra,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Each row gets a fresh urlsafe_short_hash id so single- and
|
|
||||||
# multi-ticket purchases stay shape-consistent — every scannable
|
|
||||||
# ticket id is a short hash, never the long bolt11 payment_hash.
|
|
||||||
# The shared `payment_hash` column is the join key for invoice
|
|
||||||
# lookup (poll endpoint, ws notifier, set_ticket_paid loop).
|
|
||||||
ticket_ids: list[str] = []
|
|
||||||
sats_per_ticket = payment.sat // quantity if quantity else payment.sat
|
sats_per_ticket = payment.sat // quantity if quantity else payment.sat
|
||||||
for _ in range(quantity):
|
for row_id in ticket_ids:
|
||||||
row_id = urlsafe_short_hash()
|
|
||||||
await create_ticket(
|
await create_ticket(
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
wallet=event.wallet,
|
wallet=event.wallet,
|
||||||
|
|
@ -724,11 +894,10 @@ async def api_ticket_create(
|
||||||
"applied_promo_code": promo_code,
|
"applied_promo_code": promo_code,
|
||||||
"refund_address": refund_address,
|
"refund_address": refund_address,
|
||||||
"nostr_identifier": nostr_identifier,
|
"nostr_identifier": nostr_identifier,
|
||||||
"ticket_base_url": str(request.base_url).rstrip("/"),
|
"ticket_base_url": frontend_root,
|
||||||
"sats_paid": sats_per_ticket,
|
"sats_paid": sats_per_ticket,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
ticket_ids.append(row_id)
|
|
||||||
|
|
||||||
return TicketPaymentRequest(
|
return TicketPaymentRequest(
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
|
|
@ -824,10 +993,10 @@ async def api_ticket_delete(
|
||||||
await delete_ticket(ticket_id)
|
await delete_ticket(ticket_id)
|
||||||
|
|
||||||
|
|
||||||
@tickets_api_router.post("/{ticket_id}/resend-email")
|
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
|
||||||
async def api_ticket_resend_email(
|
async def api_ticket_resend_email(
|
||||||
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
) -> Ticket:
|
) -> TicketResendResult:
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -959,3 +1128,31 @@ async def api_event_ticket_stats(
|
||||||
for t in paid_tickets
|
for t in paid_tickets
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
|
||||||
|
async def api_ticket_qr(ticket_id: str):
|
||||||
|
"""PNG of the ticket's scan payload (`ticket://<id>`), branded with the
|
||||||
|
instance QR logo. Anonymous by design — it is what the ticket email
|
||||||
|
embeds — and the id is the same bearer token the ticket page exposes.
|
||||||
|
Port of upstream v1.6.8 without ticket-image compositing."""
|
||||||
|
ticket = await get_ticket(ticket_id)
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
logo = await _load_qr_logo()
|
||||||
|
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
|
||||||
|
output = BytesIO()
|
||||||
|
image.save(output, format="PNG")
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
output,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue