Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 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
d136f29
fix: parse and pass filter query params in tickets/paginated endpoint
dadofsambonzuki Aug 15, 2026
4206336
fix: default new ticket type sales window from today, not event start
dadofsambonzuki Aug 15, 2026
22e3f67
refactor: remove automatic ticket creation from event endpoint
dadofsambonzuki Aug 24, 2026
2f8ef69
feat: add show_remaining toggle and threshold to ticket types
dadofsambonzuki Aug 24, 2026
9900cf2
fix: only show remaining chip when below threshold
dadofsambonzuki Aug 24, 2026
8958004
feat: add OG meta tags for event shared links
dadofsambonzuki Aug 24, 2026
6cf5be3
fix: store applied_promo_code in ticket extra on creation
dadofsambonzuki Aug 27, 2026
082878c
fix: Lightning payment option not showing on event page when payment_…
dadofsambonzuki Aug 30, 2026
815675e
fix: remove default payment method fallback — only show explicitly se…
dadofsambonzuki Aug 30, 2026
b7c55c0
fix: remove raw # page-events-display text from template
dadofsambonzuki Aug 30, 2026
5daed62
fix: strip promo codes from public event API response
dadofsambonzuki Sep 14, 2026
3e0dc12
fix: remove legacy price_per_ticket from public event API
dadofsambonzuki Sep 14, 2026
8866acc
feat: add purchase date column to tickets table and basket screen
dadofsambonzuki Sep 15, 2026
931bf20
fix: use yyyy-mm-dd date format for purchase dates
dadofsambonzuki Sep 15, 2026
57e3b80
feat: add ticket-ID vouchers (type=ticket promo codes)
dadofsambonzuki Sep 22, 2026
95138d4
feat: richer public ticket page (type, name, email, purchase date, st…
dadofsambonzuki Sep 22, 2026
6ba1791
style: format public ticket page like the basket page
dadofsambonzuki Sep 22, 2026
90e8009
feat: scope promo codes to specific ticket types
dadofsambonzuki Sep 29, 2026
ffcc559
feat: ticket-ID voucher applicability (max tickets + type scoping)
dadofsambonzuki Sep 29, 2026
d5e5bf1
feat: offer codes for ticket-scoped promos (upgrades + extra tickets)
dadofsambonzuki Sep 29, 2026
a58d94a
test: port promo-code suites to pytest
dadofsambonzuki Sep 29, 2026
909fe66
feat: add-on ticket grants (gated offer codes that grant, not discount)
Oct 9, 2026
379cdfa
feat: keep the public basket in view and confirm anonymous checkouts
dadofsambonzuki Oct 12, 2026
ad81fb3
fix: resolve a cross-event source ticket's type so its admits applies
Oct 13, 2026
e55c695
feat: shared add-on cap across events (extra.cap_group)
Oct 13, 2026
e3f66e7
fix: derive ticket_types.sold from the ticket rows
Oct 13, 2026
8dc6d71
feat(admin): show what a grant rule does
Oct 13, 2026
7165d0b
feat(admin): author grant rules, offer names and ticket-type facts in…
Oct 13, 2026
f402bb5
fix(checkout): say WHERE to enter a ticket ID
Oct 13, 2026
6a33f34
refactor(admin): five-section promo rule dialog; delete the dead bulk…
Oct 13, 2026
da67593
fix(admin): restore the editPromoCodeDialog state block deleted by mi…
Oct 13, 2026
9ad6a6e
feat(admin): show the offer NAME on the promo chip, falling back to t…
Oct 13, 2026
9c25a6d
fix(admin): toggling an option must not destroy the fields you typed
Oct 13, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ __pycache__
node_modules
.mypy_cache
.venv
data/
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"]
}
137 changes: 131 additions & 6 deletions crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,29 @@
from lnbits.helpers import urlsafe_short_hash

from .models import (
Basket,
sold_counts,
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 +40,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 +120,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 +135,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 +149,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 +166,111 @@ async def get_event_tickets(event_id: str) -> list[Ticket]:
{"event": event_id},
Ticket,
)


async def get_ticket_types(event_id: str) -> list[TicketType]:
ticket_types = await db.fetchall(
"SELECT * FROM events.ticket_types WHERE event_id = :event_id"
" ORDER BY sort_order ASC",
{"event_id": event_id},
TicketType,
)
# `sold` is DERIVED on every read (see models.sold_counts): the stored
# column under-counts, and the checkout's availability, its `remaining`
# check and the public display filter all trust this value.
counts = sold_counts(await get_event_tickets(event_id))
for ticket_type in ticket_types:
ticket_type.sold = counts.get(ticket_type.id, 0)
return ticket_types


async def get_all_ticket_types() -> list[TicketType]:
"""Every ticket type across all events.

The table is tiny, and a shared cap (``extra.cap_group``) has to be counted
across events: one screening has one set of seats, sold from more than one
event's checkout.
"""
return await db.fetchall("SELECT * FROM events.ticket_types", {}, TicketType)


async def count_tickets_by_type(ticket_type_id: str) -> int:
"""Committed seats for one ticket type -- same definition as ``sold``."""
ticket_type = await get_ticket_type(ticket_type_id)
if not ticket_type:
return 0
counts = sold_counts(await get_event_tickets(ticket_type.event_id))
return counts.get(ticket_type_id, 0)


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.
Loading