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
5 changed files with 242 additions and 11 deletions
Showing only changes of commit db708cf0da - Show all commits

feat: let a user_id ticket carry an email, accept frontend_url

`CreateTicket` no longer rejects `user_id` together with `name`/`email`
(the exclusion was a fork-only dispatch convenience from dfabcb8; nothing
needed it). `crud.create_ticket` stops blanking name/email when a user_id
is present, so logged-in webapp buyers can have their ticket emailed —
until now `_send_ticket_notification` short-circuited on the empty
address for every app purchase.

New optional `frontend_url` (absolute http(s) root, no query/fragment/..,
trailing slash stripped) lets a buyer-side client name the app the buyer
should be returned to and linked into from the ticket email; the origin
allow-list lives in views_api.

Also folds in the pending black reflow of migrations_fork.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Padreug 2026-09-06 19:53:05 +02:00

10
crud.py
View file

@ -55,12 +55,10 @@ async def create_ticket(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
row_id = ticket_id or payment_hash row_id = ticket_id or payment_hash
# name/email columns are NOT NULL in the schema, so we store "" when only # name/email columns are NOT NULL in the schema, so we store "" when a
# user_id is supplied. _parse_ticket_row reverses this on read. # value is absent. _parse_ticket_row reverses this on read. A user_id
if user_id: # ticket may carry an email too — that is how logged-in webapp buyers get
db_name = "" # their ticket emailed.
db_email = ""
else:
db_name = name or "" db_name = name or ""
db_email = email or "" db_email = email or ""

View file

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

View file

@ -1,8 +1,11 @@
import json import json
from datetime import datetime from datetime import datetime
from urllib.parse import urlsplit
from pydantic import BaseModel, EmailStr, Field, root_validator, validator from pydantic import BaseModel, EmailStr, Field, root_validator, validator
PAYMENT_METHODS = ("lightning", "fiat")
class PromoCode(BaseModel): class PromoCode(BaseModel):
code: str code: str
@ -28,6 +31,26 @@ class EventExtra(BaseModel):
nostr_notifications: bool = False nostr_notifications: bool = False
notification_subject: str = "" notification_subject: str = ""
notification_body: 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): class CreateEvent(BaseModel):
@ -107,6 +130,22 @@ class PublicEvent(BaseModel):
return v or [] 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): class EventsSettings(BaseModel):
"""Extension-level settings for the events extension.""" """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 # Number of tickets to buy on this single invoice. Bounded so a
# bad client can't run away with the organizer's capacity. # bad client can't run away with the organizer's capacity.
quantity: int = Field(default=1, ge=1, le=10) 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 @root_validator
def validate_identifiers(cls, values): 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") name = values.get("name")
email = values.get("email") email = values.get("email")
user_id = values.get("user_id") user_id = values.get("user_id")
if not user_id and not (name and email): if not user_id and not (name and email):
raise ValueError("Either user_id or both name and email must be provided") 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 return values
@ -168,6 +228,22 @@ class Ticket(BaseModel):
payment_hash: str | None = None 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): class PublicTicket(BaseModel):
event: str event: str
name: str | None = None name: str | None = None

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

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