Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
1cbf601
feat: rewrite for ticket types, baskets, enhanced promos, SatsPay-onl…
dadofsambonzuki Jun 27, 2026
cad26c4
fix: display page empty, ticket type ID null, sold out with unlimited…
dadofsambonzuki Jun 27, 2026
aaa6222
fix: auto-load ticket types on admin page load
dadofsambonzuki Jun 27, 2026
6a337cb
fix: add to basket only on button click, not on quantity input
dadofsambonzuki Jun 27, 2026
7e890a0
refactor: remove per-ticket-type currency — currency is event-level only
dadofsambonzuki Jun 27, 2026
64fa124
feat: add Apply button for promo codes with real-time validation
dadofsambonzuki Jun 27, 2026
5b443e7
fix: promo code max_uses=0 treated as exhausted, align Apply button l…
dadofsambonzuki Jun 27, 2026
d95b4e5
fix: promo fixed discount shows event currency instead of hardcoded sats
dadofsambonzuki Jun 27, 2026
a9e4a0b
feat: promo codes use exclusive discount type (percent OR fixed), not…
dadofsambonzuki Jun 27, 2026
4829048
fix: root_validator auto-nullifies duplicate discount type instead of…
dadofsambonzuki Jun 27, 2026
47f97b3
feat: move attendee fields into basket section on display page
dadofsambonzuki Jun 27, 2026
95e11ae
fix: basket time field default, always create satspay charge, add lnb…
dadofsambonzuki Jun 27, 2026
38ece02
feat: send currency/currency_amount to satspay for non-satoshi events
dadofsambonzuki Jun 27, 2026
7b99639
fix: use 127.0.0.1 for internal satspay calls when host is 0.0.0.0
dadofsambonzuki Jun 27, 2026
dcf57a3
feat: event-level payment methods (LN, onchain, fiat) with per-wallet…
dadofsambonzuki Jun 27, 2026
6bee55d
fix: include SatsPay error detail in exception message for diagnostics
dadofsambonzuki Jun 27, 2026
3417d7a
fix: fetch WatchOnly wallets for onchain selector, disable when not a…
dadofsambonzuki Jun 27, 2026
8253c05
refactor: redesign promo codes dialog with better layout
dadofsambonzuki Jul 7, 2026
9baa5bd
refactor: remove allow_fiat from TicketType, managed at event level
dadofsambonzuki Jul 7, 2026
8c27272
refactor: match ticket type and promo code chip layouts
dadofsambonzuki Jul 7, 2026
bd1f818
refactor: remove wallet dropdown, collapse advanced section, reorder …
dadofsambonzuki Jul 7, 2026
958620c
feat: add fiat provider config hint below fiat payment checkbox
dadofsambonzuki Jul 7, 2026
25469a6
refactor: remove closing_date from event, remove wave i18n keys
dadofsambonzuki Jul 7, 2026
399541d
refactor: simplify basket to single name/email fields, batch email no…
dadofsambonzuki Jul 7, 2026
5c6b328
refactor: align payment method labels and fields vertically with fixe…
dadofsambonzuki Jul 7, 2026
da3eedf
feat: add fiat provider dropdown to event payment methods, pass to sa…
dadofsambonzuki Jul 8, 2026
eb8e49a
Link satspay charge ID to tickets: store in ticket extra, show in adm…
dadofsambonzuki Jul 8, 2026
0e60b3d
feat: add ticket deactivation toggle and promo code inline editing
dadofsambonzuki Jul 8, 2026
e40379a
refactor: replace inline promo code input with + button that opens ad…
dadofsambonzuki Jul 8, 2026
3dd29e3
style: clean up promo code dialog discount labels and save button
dadofsambonzuki Jul 8, 2026
eda53d9
refactor: consistent save/cancel/delete buttons between ticket and pr…
dadofsambonzuki Jul 8, 2026
e23bc89
fix: align price field with other full-width fields in ticket type di…
dadofsambonzuki Jul 8, 2026
dfec4a3
fix: align form fields in ticket type and promo code dialogs
dadofsambonzuki Jul 8, 2026
b31a0d5
feat: update discount labels and add currency suffix to fixed field
dadofsambonzuki Jul 8, 2026
7a228c9
fix: save/cancel always on right in ticket and promo modals
dadofsambonzuki Jul 8, 2026
9752b29
feat: add confirmation dialog before resending all ticket emails
dadofsambonzuki Jul 8, 2026
923e3b6
chore: bump to v2.0.0, update docs for ticket types, baskets, promo c…
dadofsambonzuki Jul 8, 2026
71e23bc
feat: add active/inactive toggle for ticket types, strikethrough for …
dadofsambonzuki Jul 8, 2026
d017e90
chore: lower min_lnbits_version to 1.5.4 for testing
dadofsambonzuki Jul 9, 2026
3edaf35
Move promo below nostr field, update nostr/email labels, fix spacing,…
dadofsambonzuki Jul 9, 2026
602a208
feat: UI check for email/nostr config, notification body shared, subj…
dadofsambonzuki Jul 9, 2026
9f7039c
Make email optional in label and checkout validation
dadofsambonzuki Jul 9, 2026
f256bae
Allow empty email in backend models and send null from frontend
dadofsambonzuki Jul 9, 2026
150dc7f
Fix m009 migration: use IF NOT EXISTS, named placeholders, try/except…
dadofsambonzuki Jul 9, 2026
6ea2240
Fix event page crash: init attendeeFields/copyDetailsToAll, use :img-…
dadofsambonzuki Jul 9, 2026
7e07d9c
Fix create event button disabled: auto-set wallet to first wallet
dadofsambonzuki Jul 9, 2026
5a2f982
Remove wallet default, disable button based on payment method require…
dadofsambonzuki Jul 9, 2026
d30aa20
Fix basket email None error: coerce to empty string in service
dadofsambonzuki Jul 9, 2026
36beb16
Fix Basket email None error at the actual Basket construction site
dadofsambonzuki Jul 9, 2026
883b623
Add missing closing_date field to models and frontend
dadofsambonzuki Jul 9, 2026
85d2a48
Drop closing_date column, amend m009 to DROP COLUMN
dadofsambonzuki Jul 9, 2026
f8b3ffd
Fix tickets table header/body column count mismatch
dadofsambonzuki Jul 9, 2026
62587b3
Fix CSV export: use computed columns instead of undefined ticketsTabl…
dadofsambonzuki Jul 9, 2026
9bf24fd
Move delete icon to left with other action icons in tickets table
dadofsambonzuki Jul 9, 2026
bf7bc57
Combine duplicate satspay columns: remove from data cols, label link …
dadofsambonzuki Jul 9, 2026
a5bf959
Fix basket checkout and fiat totals
dadofsambonzuki Jul 9, 2026
b374550
Fix cannot edit: only override wallet when creating, not when editing
dadofsambonzuki Jul 9, 2026
cdee3ad
Fix basket page layout and UX improvements
dadofsambonzuki Jul 10, 2026
3d2143d
Redesign ticket page and add basket improvements
dadofsambonzuki Jul 10, 2026
b5fcd7c
Fix registration scanning flow and ticket page UX
dadofsambonzuki Jul 10, 2026
f5eff1d
Remove local i18n workaround IIFEs from JS files
dadofsambonzuki Jul 10, 2026
1d5796c
fix: handle double-encoded JSON body from satspay webhook
dadofsambonzuki Jul 10, 2026
7f84b92
fix: add 9 missing i18n keys and simplify satspay webhook JSON parsing
dadofsambonzuki Jul 10, 2026
c4e2e28
feat: show promo code usage count (used/max) in chip and edit dialog
dadofsambonzuki Jul 13, 2026
97c93c5
feat: redesign ticket emails and add email preview endpoints
dadofsambonzuki Jul 14, 2026
6c6e795
fix: add get_tickets_by_satspay_charge_id CRUD and /tickets/by-charge…
dadofsambonzuki Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,21 @@

## Sell tickets for events and use the built-in scanner for registering attendees

Events allows you to create tickets for an event. Each ticket is in the form of a unique QR code. After registering and paying, the user gets a QR code to present at registration/entrance.
Events allows you to create events with multiple ticket types and sell tickets. Each ticket is in the form of a unique QR code. After paying, the user gets a QR code to present at registration/entrance.

Events includes a shareable ticket scanner, which can be used to register attendees.

### Features

- **Multi-ticket basket checkout** — buy multiple tickets of different types in one transaction
- **Promo codes** — percentage or fixed discounts, combinable or exclusive
- **Payment methods** — Lightning, on-chain, and fiat (Stripe, PayPal, Square, Revolut) configured per event
- **Ticket types** — multiple ticket tiers with different pricing, availability windows, and max limits
- **Email & Nostr delivery** — automatic ticket link delivery via email or Nostr DM
- **Bulk actions** — resend all ticket emails, send custom messages to all attendees
- **Ticket management** — deactivation, refunds, registration tracking
- **Fiat provider** — credentials configured at LNbits server level, authorized per user

## Usage

1. Create an event\
Expand Down
9 changes: 2 additions & 7 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,12 @@
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, qr_api_router, tickets_api_router
from .views_api import events_api_router, qr_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 = [
Expand All @@ -33,10 +31,7 @@ def events_stop():


def events_start():
from lnbits.tasks import create_permanent_unique_task

task = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task)
pass


__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]
12 changes: 9 additions & 3 deletions config.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"id": "events",
"version": "1.6.9",
"version": "2.0.0",
"name": "Events",
"repo": "https://github.com/lnbits/events",
"short_description": "Sell and register event tickets",
"description": "",
"tile": "/events/static/image/events.png",
"min_lnbits_version": "1.5.5",
"min_lnbits_version": "1.5.4",
"contributors": [
{
"name": "talvasconcelos",
Expand All @@ -32,6 +32,11 @@
"name": "motorina0",
"uri": "https://github.com/motorina0",
"role": "Developer"
},
{
"name": "Nathan Day",
"uri": "https://github.com/dadofsambonzuki",
"role": "Developer"
}
],
"images": [
Expand All @@ -58,5 +63,6 @@
"paid_features": "",
"tags": ["Fun & Social", "Ticketing"],
"donate": "",
"hidden": false
"hidden": false,
"dependencies": ["satspay"]
}
110 changes: 104 additions & 6 deletions crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,28 @@
from lnbits.helpers import urlsafe_short_hash

from .models import (
Basket,
CreateEvent,
Event,
Ticket,
TicketExtra,
TicketFilters,
sync_event_ticket_waves,
TicketType,
sync_event_from_ticket_types,
)

db = Database("ext_events")


async def create_ticket(
payment_hash: str, wallet: str, event: str, name: str, email: str, extra: dict
payment_hash: str,
wallet: str,
event: str,
name: str,
email: str,
extra: dict,
ticket_type_id: str | None = None,
basket_id: str | None = None,
) -> Ticket:
now = datetime.now(timezone.utc)
ticket = Ticket(
Expand All @@ -30,6 +39,8 @@ async def create_ticket(
paid=False,
reg_timestamp=now,
time=now,
ticket_type_id=ticket_type_id,
basket_id=basket_id,
extra=TicketExtra(**extra) if extra else TicketExtra(),
)
await db.insert("events.ticket", ticket)
Expand Down Expand Up @@ -108,13 +119,11 @@ async def purge_unpaid_tickets(event_id: str) -> None:
async def create_event(data: CreateEvent) -> Event:
event_id = urlsafe_short_hash()
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
event = cast(Event, sync_event_ticket_waves(event))
await db.insert("events.events", event)
return event


async def update_event(event: Event) -> Event:
event = cast(Event, sync_event_ticket_waves(event))
await db.update("events.events", event)
return event

Expand All @@ -125,7 +134,10 @@ async def get_event(event_id: str) -> Event | None:
{"id": event_id},
Event,
)
return cast(Event, sync_event_ticket_waves(event)) if event else None
if event:
ticket_types = await get_ticket_types(event_id)
event = cast(Event, sync_event_from_ticket_types(event, ticket_types))
return event


async def get_events(wallet_ids: str | list[str]) -> list[Event]:
Expand All @@ -136,7 +148,11 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]:
f"SELECT * FROM events.events WHERE wallet IN ({q})",
model=Event,
)
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
result = []
for event in events:
ticket_types = await get_ticket_types(event.id)
result.append(cast(Event, sync_event_from_ticket_types(event, ticket_types)))
return result


async def delete_event(event_id: str) -> None:
Expand All @@ -149,3 +165,85 @@ async def get_event_tickets(event_id: str) -> list[Ticket]:
{"event": event_id},
Ticket,
)


async def get_ticket_types(event_id: str) -> list[TicketType]:
return await db.fetchall(
"SELECT * FROM events.ticket_types WHERE event_id = :event_id"
" ORDER BY sort_order ASC",
{"event_id": event_id},
TicketType,
)


async def get_ticket_type(ticket_type_id: str) -> TicketType | None:
return await db.fetchone(
"SELECT * FROM events.ticket_types WHERE id = :id",
{"id": ticket_type_id},
TicketType,
)


async def create_ticket_type(data: TicketType) -> TicketType:
await db.insert("events.ticket_types", data)
return data


async def update_ticket_type(data: TicketType) -> TicketType:
await db.update("events.ticket_types", data)
return data


async def delete_ticket_type(ticket_type_id: str) -> None:
await db.execute(
"DELETE FROM events.ticket_types WHERE id = :id", {"id": ticket_type_id}
)


async def delete_event_ticket_types(event_id: str) -> None:
await db.execute(
"DELETE FROM events.ticket_types WHERE event_id = :event_id",
{"event_id": event_id},
)


async def create_basket(data: Basket) -> Basket:
await db.insert("events.baskets", data)
return data


async def get_basket(basket_id: str) -> Basket | None:
return await db.fetchone(
"SELECT * FROM events.baskets WHERE id = :id",
{"id": basket_id},
Basket,
)


async def update_basket(basket: Basket) -> Basket:
await db.update("events.baskets", basket)
return basket


async def get_event_baskets(event_id: str) -> list[Basket]:
return await db.fetchall(
"SELECT * FROM events.baskets WHERE event_id = :event_id",
{"event_id": event_id},
Basket,
)


async def get_basket_tickets(basket_id: str) -> list[Ticket]:
return await db.fetchall(
"SELECT * FROM events.ticket WHERE basket_id = :basket_id",
{"basket_id": basket_id},
Ticket,
)


async def get_tickets_by_satspay_charge_id(charge_id: str) -> list[Ticket]:
return await db.fetchall(
"SELECT * FROM events.ticket WHERE json_extract(extra, '$.satspay_charge_id') = :charge_id",
{"charge_id": charge_id},
Ticket,
)
7 changes: 6 additions & 1 deletion description.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ Sell tickets for events and manage attendee registration with a built-in QR scan

Its features include:

- Creating events with ticket pricing
- Creating events with multiple ticket types and pricing
- Generating unique QR code tickets after payment
- Providing a shareable ticket scanner for check-in
- Tracking registered and checked-in attendees
- Multi-ticket basket checkout with promo codes (percentage or fixed discount)
- Payment via Lightning, on-chain, or fiat (Stripe, PayPal, Square, Revolut)
- Email and Nostr notification delivery
- Bulk email actions (resend tickets, custom messages to all attendees)
- Ticket deactivation and refund management

A complete ticketing solution for event organizers, meetup hosts, and conference planners who want to sell tickets and manage attendance with Bitcoin.
101 changes: 101 additions & 0 deletions migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,104 @@ async def m008_add_fiat_currency(db):
ALTER TABLE events.events
ADD COLUMN fiat_currency TEXT NOT NULL DEFAULT 'GBP';
""")


async def m009_ticket_types_and_baskets(db):
from sqlalchemy.exc import OperationalError

await db.execute("""
CREATE TABLE IF NOT EXISTS events.ticket_types (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
image_url TEXT,
price REAL NOT NULL DEFAULT 0,
max_tickets INTEGER NOT NULL DEFAULT 0,
sold INTEGER NOT NULL DEFAULT 0,
available_from TEXT NOT NULL,
available_to TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
extra TEXT
);
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS events.baskets (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
wallet TEXT NOT NULL,
email TEXT NOT NULL,
name TEXT NOT NULL,
promo_codes TEXT,
payment_method TEXT,
fiat_provider TEXT,
nostr_identifier TEXT,
refund_address TEXT,
satspay_charge_id TEXT,
paid BOOLEAN NOT NULL DEFAULT FALSE,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
""")
for alter in [
"ALTER TABLE events.events ADD COLUMN admin_email TEXT;",
"ALTER TABLE events.events DROP COLUMN closing_date;",
"ALTER TABLE events.ticket ADD COLUMN ticket_type_id TEXT;",
"ALTER TABLE events.ticket ADD COLUMN basket_id TEXT;",
]:
try:
await db.execute(alter)
except OperationalError:
pass

import json

events_rows = await db.fetchall("SELECT id, extra FROM events.events")
for row in events_rows:
event_id = row["id"]
extra_str = row["extra"]
if not extra_str:
continue
try:
extra = json.loads(extra_str)
except (json.JSONDecodeError, TypeError):
continue
ticket_waves = extra.get("ticket_waves", []) or []
sort_order = 0
for wave in ticket_waves:
await db.execute(
"""
INSERT INTO events.ticket_types (
id, event_id, name, description,
image_url, price, max_tickets, sold,
available_from, available_to,
sort_order, extra
)
VALUES (:id, :event_id, :name, :description,
:image_url, :price, :max_tickets, :sold,
:available_from, :available_to,
:sort_order, :extra)
""",
{
"id": wave.get("id", ""),
"event_id": event_id,
"name": wave.get("title", "General Admission"),
"description": "",
"image_url": wave.get("ticket_image_id"),
"price": wave.get("price_per_ticket", 0),
"max_tickets": wave.get("amount_tickets", 0),
"sold": 0,
"available_from": wave.get("opening_date", ""),
"available_to": wave.get("closing_date", ""),
"sort_order": sort_order,
"extra": "{}",
},
)
sort_order += 1

extra.pop("ticket_waves", None)
await db.execute(
"UPDATE events.events SET extra = :extra WHERE id = :id",
{"extra": json.dumps(extra), "id": event_id},
)
Loading