feat: guest checkout, Stripe return flow, emailed QR tickets, per-event payment methods (v1.6.1-aio.8) #36

Merged
padreug merged 7 commits from feat/guest-checkout-email into main 2026-09-07 09:27:51 +00:00
20 changed files with 844 additions and 114 deletions

3
.gitignore vendored
View file

@ -2,3 +2,6 @@ __pycache__
node_modules
.mypy_cache
.venv
# lnbits data dir created by `make test` (settings default lnbits_data_folder)
data/

View file

@ -30,10 +30,15 @@ checkblack:
checkeditorconfig:
editorconfig-checker
# The uv env resolves *upstream* lnbits from PyPI, which lacks the aio fork's
# modules (lnbits.core.signers, …). Point PYTHONPATH at a fork checkout so
# `import lnbits` picks it up; override with LNBITS_SRC=/path/to/lnbits.
LNBITS_SRC ?= $(HOME)/dev/lnbits/dev
test:
PYTHONPATH=$(LNBITS_SRC) \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest
uv run --frozen pytest
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with uv run pre-commit uninstall"

View file

@ -23,7 +23,6 @@ Events includes a shareable ticket scanner, which can be used to register attend
1. Create an event\
![create event](https://i.imgur.com/dadK1dp.jpg)
2. Fill out the event information:
- event name
- wallet (normally there's only one)
- event information
@ -34,7 +33,6 @@ Events includes a shareable ticket scanner, which can be used to register attend
3. Share the event registration link\
![event ticket](https://imgur.com/AQWUOBY.jpg)
- ticket example\
![ticket example](https://i.imgur.com/trAVSLd.jpg)
@ -44,6 +42,31 @@ Events includes a shareable ticket scanner, which can be used to register attend
4. Use the built-in ticket scanner to validate registered, and paid, attendees\
![ticket scanner](https://i.imgur.com/zrm9202.jpg)
## Guest checkout, card payments and email delivery (aio fork)
- **Identity.** `POST /events/api/v1/tickets/{event_id}` accepts either an
LNbits `user_id` or a guest `name` + `email`; a `user_id` ticket may also
carry an `email` so logged-in buyers get their ticket mailed.
- **Payment methods.** `extra.payment_methods` (`lightning`, `fiat`) lists the
rails an event accepts; an empty list keeps the legacy rule (Lightning always,
fiat when `allow_fiat`). The effective list is published on the NIP-52 event
as `tickets_payment_methods` and enforced at purchase.
- **Return to the calling app.** A client may send `frontend_url` (its app
root, e.g. `https://app.example/events`). Its origin must be one of
`LNBITS_CORS_ALLOWED_ORIGINS`, the LNbits base URL or
`LNBITS_CUSTOM_FRONTEND_URL`, otherwise the request is refused. Under that
root the extension builds the Stripe `success_url`
(`/events/{event_id}?checkout=success&tickets=<id,id>`), `cancel_url`
(`/events/{event_id}?checkout=cancelled`) and the emailed ticket link
(`/events/ticket/{ticket_id}`). Absent, the LNbits host is used as before.
- **Stripe session.** The buyer's email is passed as `customer_email`
(prefilled and locked on the hosted page); the line item is named after the
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
- **Email.** Multipart text + HTML with the ticket QR embedded from
`GET /events/api/v1/qr/{ticket_id}` (PNG, branded with the instance QR logo).
`POST /events/api/v1/tickets/{ticket_id}/resend-email` returns a
`TicketResendResult` with per-channel outcome.
## Powered by LNbits
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.

View file

@ -6,12 +6,13 @@ from loguru import logger
from .crud import db
from .tasks import wait_for_paid_invoices
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.include_router(events_generic_router)
events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router)
events_ext.include_router(qr_api_router)
events_static_files = [
{

View file

@ -1,6 +1,6 @@
{
"id": "events",
"version": "1.6.1-aio.7",
"version": "1.6.1-aio.8",
"name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets",

14
crud.py
View file

@ -55,14 +55,12 @@ async def create_ticket(
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.
if user_id:
db_name = ""
db_email = ""
else:
db_name = name or ""
db_email = email or ""
# name/email columns are NOT NULL in the schema, so we store "" when a
# value is absent. _parse_ticket_row reverses this on read. A user_id
# ticket may carry an email too — that is how logged-in webapp buyers get
# their ticket emailed.
db_name = name or ""
db_email = email or ""
db_ticket = Ticket(
id=row_id,

View file

@ -0,0 +1,17 @@
# Upstream PR candidates
Running log of fork features that are shaped so they could be offered to
`lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here
in an upstream-compatible form; strike it when the PR merges upstream.
| Feature | Where | Upstream target | Readiness |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |

View file

@ -127,4 +127,3 @@ async def m002_ticket_payment_hash(db):
"UPDATE events.ticket SET payment_hash = id "
"WHERE payment_hash IS NULL OR payment_hash = ''"
)

View file

@ -1,8 +1,11 @@
import json
from datetime import datetime
from urllib.parse import urlsplit
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
PAYMENT_METHODS = ("lightning", "fiat")
class PromoCode(BaseModel):
code: str
@ -28,6 +31,26 @@ class EventExtra(BaseModel):
nostr_notifications: bool = False
notification_subject: str = ""
notification_body: str = ""
# Rails the organizer accepts for this event. Empty = legacy rule
# ("lightning" always, "fiat" when allow_fiat) — see
# `effective_payment_methods`. Same field name/shape as upstream v2 so the
# eventual rebase (#33) merges cleanly.
payment_methods: list[str] = Field(default_factory=list)
@validator("payment_methods", pre=True)
def normalize_payment_methods(cls, v):
if not v:
return []
if isinstance(v, str):
v = v.split(",")
seen: list[str] = []
for method in v:
method = str(method).strip().lower()
if method not in PAYMENT_METHODS:
raise ValueError(f"Unsupported payment method: {method}")
if method not in seen:
seen.append(method)
return seen
class CreateEvent(BaseModel):
@ -107,6 +130,22 @@ class PublicEvent(BaseModel):
return v or []
def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]:
"""Rails a buyer may pick for `event`.
Explicit `extra.payment_methods` wins; an empty list falls back to the
pre-#payment-methods rule so events created before the field existed
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
"""
explicit = list(getattr(event.extra, "payment_methods", []) or [])
if explicit:
return explicit
methods = ["lightning"]
if event.allow_fiat:
methods.append("fiat")
return methods
class EventsSettings(BaseModel):
"""Extension-level settings for the events extension."""
@ -136,16 +175,37 @@ class CreateTicket(BaseModel):
# 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)
# App root of the client that is buying (e.g. https://app.example/events).
# The extension builds the Stripe success/cancel URLs and the emailed
# ticket link under it, so the buyer lands back in the app they came
# from. Origin is allow-listed server-side (see `_resolve_frontend_root`);
# absent = today's behaviour (the LNbits host).
frontend_url: str | None = Field(default=None, max_length=512)
@validator("frontend_url")
def validate_frontend_url(cls, v):
if v is None:
return None
v = v.strip()
if not v:
return None
parts = urlsplit(v)
if parts.scheme not in ("http", "https") or not parts.netloc:
raise ValueError("frontend_url must be an absolute http(s) URL")
if parts.query or parts.fragment or ".." in parts.path:
raise ValueError("frontend_url must not contain a query, fragment or '..'")
return v.rstrip("/")
@root_validator
def validate_identifiers(cls, values):
"""A ticket needs an identity: an LNbits `user_id`, or `name` +
`email` for guests. A logged-in buyer may add `email` (and `name`)
on top of `user_id` so the ticket can be emailed to them."""
name = values.get("name")
email = values.get("email")
user_id = values.get("user_id")
if not user_id and not (name and email):
raise ValueError("Either user_id or both name and email must be provided")
if user_id and (name or email):
raise ValueError("Cannot provide both user_id and name/email")
return values
@ -168,6 +228,22 @@ class Ticket(BaseModel):
payment_hash: str | None = None
class NotificationDeliveryResult(BaseModel):
attempted: bool = False
sent: bool = False
error: str | None = None
class TicketResendResult(BaseModel):
ticket: Ticket
email: NotificationDeliveryResult = Field(
default_factory=NotificationDeliveryResult
)
nostr: NotificationDeliveryResult = Field(
default_factory=NotificationDeliveryResult
)
class PublicTicket(BaseModel):
event: str
name: str | None = None

View file

@ -17,7 +17,7 @@ from datetime import datetime, timezone
from lnbits.core.signers import NostrSigner
from loguru import logger
from .models import Event
from .models import Event, effective_payment_methods
from .nostr.event import NostrEvent
from .nostr_timestamp import monotonic_created_at
@ -110,6 +110,11 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
tags.append(["tickets_allow_fiat", "true"])
if event.fiat_currency:
tags.append(["tickets_fiat_currency", event.fiat_currency])
# Rails the organizer accepts, resolved through the same helper the
# ticket endpoint enforces with, so a client can render exactly the
# buttons that will be accepted (e.g. a card-only event) without a REST
# round-trip. Comma-separated, lowercase.
tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))])
# NIP-52 calendar events are replaceable: this d-tag is republished
# whenever inventory changes (a ticket sells). Use a strictly-monotonic

View file

@ -1,14 +1,16 @@
from __future__ import annotations
import asyncio
import smtplib
from asyncio.tasks import create_task
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from html import escape
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import send_nostr_dm
from lnbits.core.services.notifications import (
send_email_notification,
send_user_notification,
)
from lnbits.core.services.notifications import send_user_notification
from lnbits.helpers import is_valid_email_address
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
from lnurl import execute
@ -21,7 +23,7 @@ from .crud import (
update_event,
update_ticket,
)
from .models import Event, Ticket
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
from .nostr_hooks import publish_or_delete_nostr_event
DEFAULT_NOSTR_RELAYS = [
@ -80,40 +82,13 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
logger.warning(f"Event {ticket.event} not found for ticket notification.")
return
subject, message = _ticket_notification_message(ticket, event)
updated = False
if (
event.extra.email_notifications
and settings.lnbits_email_notifications_enabled
and ticket.email
):
try:
await send_email_notification([ticket.email], message, subject)
ticket.extra.email_notification_sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to email ticket {ticket.id}: {exc}")
if (
event.extra.nostr_notifications
and settings.is_nostr_notifications_configured()
and ticket.extra.nostr_identifier
):
try:
await _send_nostr_ticket_notification(
ticket.extra.nostr_identifier, message
)
ticket.extra.nostr_notification_sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}")
if updated:
await update_ticket(ticket)
await _deliver_ticket_notifications(ticket, event)
async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult:
"""Organizer-triggered re-delivery of the ticket email. Bypasses the
per-event `email_notifications` opt-in (the organizer asked explicitly)
but still needs the instance mailer and an address on the ticket."""
event = await get_event(ticket.event)
if not event:
raise ValueError("Event does not exist.")
@ -122,10 +97,7 @@ async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
if not ticket.email:
raise ValueError("Ticket does not have an email address.")
subject, message = _ticket_notification_message(ticket, event)
await send_email_notification([ticket.email], message, subject)
ticket.extra.email_notification_sent = True
return await update_ticket(ticket)
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
@ -142,6 +114,149 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str
return subject, f"{body}\n\nOpen it here: {ticket_url}"
def _ticket_delivery_message(ticket: Ticket, base_message: str) -> str:
return f"{base_message}\n\nTicket image: {_ticket_image_url(ticket)}"
def _ticket_email_html_message(ticket: Ticket, base_message: str) -> str:
text_message = _ticket_delivery_message(ticket, base_message)
html_message = f"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
image_url = escape(_ticket_image_url(ticket), quote=True)
return (
f"{html_message}"
f'<p><img src="{image_url}" alt="Ticket QR code" '
'style="max-width: 300px; height: auto;" /></p>'
)
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
subject, base_message = _ticket_notification_message(ticket, event)
text_message = _ticket_delivery_message(ticket, base_message)
html_message = _ticket_email_html_message(ticket, base_message)
return subject, text_message, html_message
async def _deliver_ticket_notifications(
ticket: Ticket,
event: Event,
*,
email: bool | None = None,
nostr: bool | None = None,
) -> TicketResendResult:
"""Send the ticket by every configured channel and report per-channel
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
opt-ins when not None; the instance-level prerequisites always apply."""
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
updated = False
email_wanted = event.extra.email_notifications if email is None else email
nostr_wanted = event.extra.nostr_notifications if nostr is None else nostr
result = TicketResendResult(
ticket=ticket,
email=NotificationDeliveryResult(
attempted=bool(
email_wanted
and settings.lnbits_email_notifications_enabled
and ticket.email
)
),
nostr=NotificationDeliveryResult(
attempted=bool(
nostr_wanted
and settings.is_nostr_notifications_configured()
and ticket.extra.nostr_identifier
)
),
)
if result.email.attempted:
try:
assert ticket.email
await _send_ticket_email_notification(
[ticket.email], text_message, subject, html_message
)
ticket.extra.email_notification_sent = True
result.email.sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to email ticket {ticket.id}: {exc}")
result.email.error = str(exc)
if result.nostr.attempted:
try:
identifier = ticket.extra.nostr_identifier
assert identifier
await _send_nostr_ticket_notification(identifier, text_message)
ticket.extra.nostr_notification_sent = True
result.nostr.sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}")
result.nostr.error = str(exc)
if updated:
result.ticket = await update_ticket(ticket)
return result
async def _send_ticket_email_notification(
to_emails: list[str],
message: str,
subject: str,
html_message: str | None = None,
) -> None:
"""Multipart (text + HTML) ticket email through the instance SMTP
settings. Core's `send_email_notification` is plain-text only, which is
why this lives here (ported from upstream v1.6.8). The blocking smtplib
session runs in a worker thread so a slow relay cannot stall the event
loop while a batch of tickets settles."""
if not settings.lnbits_email_notifications_enabled:
raise ValueError("Email notifications are disabled")
from_email = settings.lnbits_email_notifications_email
if not is_valid_email_address(from_email):
raise ValueError(f"Invalid from email address: {from_email}")
if not to_emails:
raise ValueError("No email addresses provided")
for address in to_emails:
if not is_valid_email_address(address):
raise ValueError(f"Invalid email address: {address}")
msg = MIMEMultipart("alternative")
msg["From"] = from_email
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
if html_message:
msg.attach(MIMEText(html_message, "html"))
username = settings.lnbits_email_notifications_username or from_email
await asyncio.to_thread(
_smtp_send,
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
username,
settings.lnbits_email_notifications_password,
from_email,
to_emails,
msg.as_string(),
)
def _smtp_send(
server: str,
port: int,
username: str,
password: str,
from_email: str,
to_emails: list[str],
payload: str,
) -> None:
with smtplib.SMTP(server, port, timeout=30) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, payload)
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
if "@" in identifier:
await send_user_notification(
@ -161,6 +276,14 @@ def _ticket_url(ticket: Ticket) -> str:
return f"{base_url}/events/ticket/{ticket.id}"
def _ticket_image_url(ticket: Ticket) -> str:
"""The QR PNG is served by THIS extension on the LNbits host, so it is
built from `lnbits_baseurl` even when `ticket_base_url` points at a
separate web app (deviation from upstream, which assumes both are the
same host)."""
return f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/qr/{ticket.id}"
async def refund_tickets(event_id: str):
"""
Refund tickets for an event that has not met the minimum ticket requirement.

View file

@ -35,13 +35,29 @@ window.PageEventsDisplay = {
async created() {
this.eventId = this.$route.params.id
this.event = await this.getEvent()
// Default to the first rail the organizer accepts (a card-only event
// must not submit "lightning").
this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning'
},
computed: {
formatDescription() {
return LNbits.utils.convertMarkdown(this.event?.info || '')
},
paymentMethods() {
// Mirrors `effective_payment_methods` on the backend: an explicit
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
const explicit = this.event?.extra?.payment_methods || []
if (explicit.length) return explicit
return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])]
},
allowFiatCheckout() {
return Boolean(this.event?.allow_fiat)
return this.paymentMethods.includes('fiat')
},
paymentMethodOptions() {
return this.paymentMethods.map(method => ({
value: method,
label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning'
}))
},
fiatCheckoutLabel() {
if (!this.allowFiatCheckout) return 'Fiat'
@ -78,7 +94,8 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.payment_method = 'lightning'
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
},
closeReceiveDialog() {
@ -112,7 +129,8 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.payment_method = 'lightning'
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
Quasar.Notify.create({
type: 'positive',
message: 'Sent, thank you!',

View file

@ -90,20 +90,18 @@
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input>
<div class="row q-col-gutter-md q-pt-lg items-center">
<div v-if="allowFiatCheckout" class="col-auto">
<div v-if="paymentMethods.length > 1" class="col-auto">
<q-option-group
v-model="formDialog.data.payment_method"
inline
:options="[
{label: 'Lightning', value: 'lightning'},
{
label: fiatCheckoutLabel,
value: 'fiat'
}
]"
:options="paymentMethodOptions"
></q-option-group>
</div>
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'">
<div
:class="
paymentMethods.length > 1 ? 'col-12 col-md-3' : 'col-12'
"
>
<q-input
filled
dense

View file

@ -156,6 +156,7 @@ window.PageEvents = {
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
payment_methods: ['lightning'],
promo_codes: [],
notification_subject: '',
notification_body: ''
@ -271,12 +272,7 @@ window.PageEvents = {
},
saveSettings() {
LNbits.api
.request(
'PUT',
'/events/api/v1/events/settings',
null,
this.settings
)
.request('PUT', '/events/api/v1/events/settings', null, this.settings)
.then(() => {
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
})
@ -326,7 +322,7 @@ window.PageEvents = {
LNbits.utils
.confirmDialog(
'Re-emit every approved event to Nostr relays? This is safe ' +
'to run multiple times but generates one event per approved row.'
'to run multiple times but generates one event per approved row.'
)
.onOk(() => {
this.republishing = true
@ -351,9 +347,7 @@ window.PageEvents = {
},
republishMyEvents() {
LNbits.utils
.confirmDialog(
'Re-emit your approved events to Nostr relays?'
)
.confirmDialog('Re-emit your approved events to Nostr relays?')
.onOk(() => {
this.republishingMine = true
LNbits.api
@ -420,6 +414,17 @@ window.PageEvents = {
code: code.code.trim().toUpperCase()
}))
}
const methods = data.extra?.payment_methods || []
if (methods.length === 0) {
Quasar.Notify.create({
type: 'warning',
message: 'Select at least one payment method.'
})
return
}
// allow_fiat stays the fiat-currency carrier the backend and the
// NIP-52 tags read; keep it in lockstep with the checkbox list.
data.allow_fiat = methods.includes('fiat')
if (!this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
@ -439,6 +444,15 @@ window.PageEvents = {
const end = this.splitDateTime(data.event_end_date)
this.formDialog.data = {
...data,
extra: {
...(data.extra || {}),
// Events created before extra.payment_methods existed carry an
// empty list; show the rails the backend actually accepts for
// them (Lightning always, fiat when allow_fiat).
payment_methods: data.extra?.payment_methods?.length
? data.extra.payment_methods
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
},
event_start_day: start.day,
event_start_time: start.time,
event_end_day: end.day,
@ -454,6 +468,7 @@ window.PageEvents = {
event_end_day: '',
event_end_time: '',
extra: {
payment_methods: ['lightning'],
conditional: false,
min_tickets: 1,
email_notifications: false,
@ -473,6 +488,7 @@ window.PageEvents = {
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
payment_methods: ['lightning'],
email_notifications: false,
nostr_notifications: false,
promo_codes: [],

View file

@ -20,10 +20,10 @@
<div class="col">
<span class="text-subtitle2">Republish to Nostr</span>
<div class="text-caption text-grey-7" style="color: #aaa">
Re-emit every approved event so connected clients pick
up the latest tag set. Useful after the extension
publisher changes (e.g. new tickets_* tags) so existing
events don't need a per-event edit.
Re-emit every approved event so connected clients pick up the
latest tag set. Useful after the extension publisher changes
(e.g. new tickets_* tags) so existing events don't need a
per-event edit.
</div>
</div>
<div class="col-auto">
@ -56,8 +56,8 @@
></q-btn>
</div>
<div class="text-caption q-mt-sm" style="color: #aaa">
Re-emit your approved events to Nostr relays. Useful after
a publisher upgrade or if a relay dropped your events.
Re-emit your approved events to Nostr relays. Useful after a
publisher upgrade or if a relay dropped your events.
</div>
</q-card-section>
</q-card>
@ -228,7 +228,13 @@
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-badge
v-if="col.name === 'status'"
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
:color="
col.value === 'approved'
? 'green'
: col.value === 'proposed'
? 'orange'
: 'red'
"
:label="col.value"
></q-badge>
<span v-else v-text="col.value"></span>
@ -399,7 +405,13 @@
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-badge
v-if="col.name === 'status'"
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
:color="
col.value === 'approved'
? 'green'
: col.value === 'proposed'
? 'orange'
: 'red'
"
:label="col.value"
></q-badge>
<span v-else v-text="col.value"></span>
@ -587,15 +599,22 @@
></q-input>
</div>
</div>
<q-toggle
v-model="formDialog.data.allow_fiat"
label="Allow fiat checkout"
left-label
hint="Lets attendees pay through a configured fiat provider using the event currency."
></q-toggle>
<div class="q-mt-sm">
<div class="text-caption text-grey-7">Payment methods *</div>
<q-option-group
v-model="formDialog.data.extra.payment_methods"
type="checkbox"
inline
:options="paymentMethodOptions"
></q-option-group>
<div class="text-caption text-grey-7">
Card / fiat checkout goes through the fiat provider configured on
this LNbits instance. Untick Lightning for a card-only event.
</div>
</div>
<q-select
v-if="
formDialog.data.allow_fiat &&
acceptsFiat &&
['sat', 'sats'].includes(
(formDialog.data.currency || '').toLowerCase()
)

View file

@ -0,0 +1,48 @@
from unittest.mock import AsyncMock
import pytest
from .. import crud
@pytest.mark.asyncio
async def test_create_ticket_keeps_email_alongside_user_id(monkeypatch):
inserted = {}
async def fake_insert(table, model):
inserted["table"] = table
inserted["model"] = model
monkeypatch.setattr(crud.db, "insert", AsyncMock(side_effect=fake_insert))
ticket = await crud.create_ticket(
payment_hash="hash",
wallet="w",
event="e",
name="Ada",
email="ada@example.com",
user_id="u1",
ticket_id="t1",
)
assert inserted["table"] == "events.ticket"
assert inserted["model"].user_id == "u1"
assert inserted["model"].email == "ada@example.com"
assert inserted["model"].name == "Ada"
assert ticket.email == "ada@example.com"
@pytest.mark.asyncio
async def test_create_ticket_stores_empty_string_sentinels(monkeypatch):
inserted = {}
async def fake_insert(table, model):
inserted["model"] = model
monkeypatch.setattr(crud.db, "insert", AsyncMock(side_effect=fake_insert))
await crud.create_ticket(
payment_hash="hash", wallet="w", event="e", user_id="u1", ticket_id="t2"
)
assert inserted["model"].email == ""
assert inserted["model"].name == ""

View 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

110
tests/test_ticket_models.py Normal file
View file

@ -0,0 +1,110 @@
import pytest
from pydantic import ValidationError
from ..models import (
CreateEvent,
CreateTicket,
EventExtra,
effective_payment_methods,
)
def _ticket(**kwargs) -> CreateTicket:
return CreateTicket(**kwargs)
def test_user_id_only_is_a_valid_identity():
assert _ticket(user_id="u1").user_id == "u1"
def test_name_and_email_is_a_valid_guest_identity():
ticket = _ticket(name="Guest", email="guest@example.com")
assert ticket.user_id is None
assert ticket.email == "guest@example.com"
def test_user_id_may_carry_an_email_for_delivery():
ticket = _ticket(user_id="u1", email="me@example.com")
assert ticket.user_id == "u1"
assert ticket.email == "me@example.com"
@pytest.mark.parametrize(
"kwargs",
[
{},
{"name": "Guest"},
{"email": "guest@example.com"},
],
)
def test_missing_identity_is_rejected(kwargs):
with pytest.raises(ValidationError):
_ticket(**kwargs)
@pytest.mark.parametrize(
"url,expected",
[
("https://app.example/events", "https://app.example/events"),
("https://app.example/events/", "https://app.example/events"),
("http://localhost:5173/", "http://localhost:5173"),
(" ", None),
],
)
def test_frontend_url_is_normalised(url, expected):
assert _ticket(user_id="u1", frontend_url=url).frontend_url == expected
@pytest.mark.parametrize(
"url",
[
"/events", # relative
"ftp://app.example/events",
"https://app.example/events?x=1",
"https://app.example/events#top",
"https://app.example/../events",
"https://" + "a" * 520,
],
)
def test_frontend_url_rejects_unsafe_values(url):
with pytest.raises(ValidationError):
_ticket(user_id="u1", frontend_url=url)
def _event(**overrides) -> CreateEvent:
data = {
"wallet": "w",
"name": "Test",
"info": "",
"closing_date": "2030-01-01",
"event_start_date": "2030-01-01",
"event_end_date": "2030-01-02",
"amount_tickets": 10,
"price_per_ticket": 5,
}
data.update(overrides)
return CreateEvent(**data)
def test_effective_payment_methods_legacy_rule():
assert effective_payment_methods(_event()) == ["lightning"]
assert effective_payment_methods(_event(allow_fiat=True)) == ["lightning", "fiat"]
def test_effective_payment_methods_explicit_list_wins():
event = _event(allow_fiat=True, extra=EventExtra(payment_methods=["fiat"]))
assert effective_payment_methods(event) == ["fiat"]
def test_payment_methods_are_normalised_and_deduplicated():
extra = EventExtra(payment_methods=["Fiat", " lightning ", "fiat"])
assert extra.payment_methods == ["fiat", "lightning"]
assert EventExtra(payment_methods="lightning,fiat").payment_methods == [
"lightning",
"fiat",
]
def test_unknown_payment_method_is_rejected():
with pytest.raises(ValidationError):
EventExtra(payment_methods=["cash"])

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")

View file

@ -1,8 +1,13 @@
import asyncio
from datetime import datetime, timezone
from http import HTTPStatus
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import httpx
import pyqrcode # type: ignore[import-untyped]
from fastapi import (
APIRouter,
Depends,
@ -12,18 +17,19 @@ from fastapi import (
WebSocket,
WebSocketDisconnect,
)
from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Account, User, WalletTypeInfo
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.helpers import urlsafe_short_hash
from lnbits.decorators import (
check_admin,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
@ -31,6 +37,8 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat,
)
from lnbits.utils.nostr import normalize_public_key
from loguru import logger
from PIL import Image, ImageDraw
from .crud import (
create_event,
@ -64,6 +72,8 @@ from .models import (
PublicTicket,
Ticket,
TicketPaymentRequest,
TicketResendResult,
effective_payment_methods,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .services import (
@ -76,6 +86,7 @@ from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
qr_api_router = APIRouter(prefix="/api/v1")
def _is_fiat_currency(currency: str | None) -> bool:
@ -513,6 +524,136 @@ async def api_get_ticket(ticket_id: str) -> 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(
*,
event: Event,
@ -522,7 +663,7 @@ async def _issue_free_tickets(
user_id: str | None,
promo_code: str | None,
nostr_identifier: str | None,
request: Request,
frontend_root: str,
) -> TicketPaymentRequest:
"""Issue `quantity` free tickets without minting an invoice.
@ -552,7 +693,7 @@ async def _issue_free_tickets(
extra={
"applied_promo_code": promo_code,
"nostr_identifier": nostr_identifier,
"ticket_base_url": str(request.base_url).rstrip("/"),
"ticket_base_url": frontend_root,
"sats_paid": 0,
},
)
@ -588,7 +729,9 @@ async def api_ticket_create(
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(
@ -618,6 +761,7 @@ async def api_ticket_create(
) from exc
unit_price = event.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
frontend_root = _resolve_frontend_root(data, request)
if promo_code:
# check if promo_code exists in event.extra.promo_codes
@ -645,13 +789,16 @@ async def api_ticket_create(
user_id=user_id,
promo_code=promo_code,
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(
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):
@ -692,6 +839,36 @@ async def api_ticket_create(
else:
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(
wallet_id=event.wallet,
invoice_data=CreateInvoice(
@ -703,15 +880,8 @@ async def api_ticket_create(
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
for _ in range(quantity):
row_id = urlsafe_short_hash()
for row_id in ticket_ids:
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
@ -724,11 +894,10 @@ async def api_ticket_create(
"applied_promo_code": promo_code,
"refund_address": refund_address,
"nostr_identifier": nostr_identifier,
"ticket_base_url": str(request.base_url).rstrip("/"),
"ticket_base_url": frontend_root,
"sats_paid": sats_per_ticket,
},
)
ticket_ids.append(row_id)
return TicketPaymentRequest(
payment_hash=payment.payment_hash,
@ -824,10 +993,10 @@ async def api_ticket_delete(
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(
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Ticket:
) -> TicketResendResult:
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
@ -959,3 +1128,31 @@ async def api_event_ticket_stats(
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",
},
)