Compare commits

..

No commits in common. "main" and "ticket-scanner-nostr" have entirely different histories.

44 changed files with 437 additions and 6044 deletions

3
.gitignore vendored
View file

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

View file

@ -30,15 +30,10 @@ 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 --frozen pytest
uv run 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,6 +23,7 @@ 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
@ -33,6 +34,7 @@ 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)
@ -42,41 +44,6 @@ 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.
- **Promo codes.** `extra.promo_codes` (`code`, `discount_percent`, `active`,
`max_uses`; `used_count` is derived from paid tickets) are organizer-only: they are
never part of public responses. Buyers preview a code with
`POST /events/api/v1/promo/validate/{event_id}` (`{codes, quantity}` → v2-shaped
`BasketTotals` + `currency`); purchase enforces `active` and `max_uses` (each ticket
of a multi-ticket purchase consumes one use) and rejects bad codes with a distinct
`detail`. Updates that omit `extra.promo_codes` keep the stored list.
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
attached — a self-describing PNG (site, event, when, where, QR with the
instance logo, name on ticket, ticket id) also served at
`GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at
`GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a
From display name (site title). `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,19 +6,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,
promo_api_router,
qr_api_router,
tickets_api_router,
)
from .views_api import events_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_ext.include_router(promo_api_router)
events_static_files = [
{
@ -34,15 +27,6 @@ scheduled_tasks: list[asyncio.Task] = []
# from nostr_hooks.publish_or_delete_nostr_event.
nostr_client = None
# Reconciliation sweep for NIP-52 publishes that never reached a relay
# (aiolabs/events#35). Five minutes is well under the window in which a
# stale ticket count matters to a buyer, and the query costs nothing
# when there is no drift — the normal case returns zero rows.
REPUBLISH_SWEEP_INTERVAL = 300
# Long enough for _start_nostr_client's own 10s wait plus the relay
# handshake, so the first pass isn't guaranteed to fail on a cold boot.
REPUBLISH_SWEEP_FIRST_DELAY = 60
def events_stop():
for task in scheduled_tasks:
@ -126,55 +110,5 @@ def events_start():
task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events)
scheduled_tasks.append(task3)
async def _republish_pending_sweep():
"""Retry NIP-52 publishes that never landed.
Inventory reaches clients only through the republished calendar
event, and a publish can fail (signer outage) or be skipped
entirely (no signer, no NostrClient) without anything noticing.
Both leave `nostr_publish_pending` set, so this sweep retries
from the DB rather than from an in-memory queue — it survives a
restart, which the previous behaviour did not.
Quiet by design: on a healthy instance the query returns nothing
and this logs nothing. It only speaks up when there is drift.
"""
from .crud import flag_wave_transitions, get_events_pending_republish
from .nostr_hooks import publish_or_delete_nostr_event
await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY)
while True:
try:
# Wave boundaries are time-driven, so nothing else flags
# them. Done here rather than on a timer of its own: the
# boundary is day-granular, so one sweep interval of
# staleness is immaterial (aiolabs/events#61).
moved = await flag_wave_transitions()
if moved:
logger.info(
f"[EVENTS] Republish sweep: {moved} event(s) changed "
f"ticket wave"
)
pending = await get_events_pending_republish()
if pending:
total = len(pending)
logger.info(f"[EVENTS] Republish sweep: {total} event(s) pending")
recovered = 0
for event in pending:
take_down = event.canceled or event.status != "approved"
if await publish_or_delete_nostr_event(event, delete=take_down):
recovered += 1
logger.info(
f"[EVENTS] Republish sweep: {recovered}/{total} recovered"
)
except Exception as exc:
logger.error(f"[EVENTS] Republish sweep failed: {exc}")
await asyncio.sleep(REPUBLISH_SWEEP_INTERVAL)
task4 = create_permanent_unique_task(
"ext_events_republish_sweep", _republish_pending_sweep
)
scheduled_tasks.append(task4)
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]

View file

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

153
crud.py
View file

@ -1,20 +1,10 @@
import json
from datetime import datetime, timedelta, timezone
from typing import cast
from lnbits.db import Database, Filters, Page
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import (
CreateEvent,
Event,
EventsSettings,
Ticket,
TicketExtra,
TicketFilters,
advertised_wave_key,
sync_event_ticket_waves,
)
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
db = Database("ext_events")
@ -65,10 +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 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.
# 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 ""
@ -165,31 +157,6 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_paginated(
wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None
) -> Page[Ticket]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
wallet_where = []
values = {}
for idx, wallet_id in enumerate(wallet_ids):
key = f"wallet_id_{idx}"
wallet_where.append(f":{key}")
values[key] = wallet_id
where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"]
return await db.fetch_page(
"SELECT * FROM events.ticket",
where=where,
values=values,
filters=filters,
model=Ticket,
table_name="events.ticket",
)
async def delete_ticket(payment_hash: str) -> None:
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
@ -219,55 +186,44 @@ async def create_event(data: CreateEvent) -> Event:
if not data.closing_date:
data.closing_date = data.event_end_date
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
async def get_event(event_id: str) -> Event | None:
event = await db.fetchone(
return await db.fetchone(
"SELECT * FROM events.events WHERE id = :id",
{"id": event_id},
Event,
)
return cast(Event, sync_event_ticket_waves(event)) if event else None
async def get_events(wallet_ids: str | list[str]) -> list[Event]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
events = await db.fetchall(
return await db.fetchall(
f"SELECT * FROM events.events WHERE wallet IN ({q})",
model=Event,
)
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
async def get_all_events() -> list[Event]:
"""All events, no wallet filter. Admin-only callers."""
events = await db.fetchall(
return await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC",
model=Event,
)
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def get_public_events() -> list[Event]:
"""Approved, non-canceled events for the public listing."""
events = await db.fetchall(
return await db.fetchall(
"""
SELECT * FROM events.events
WHERE status = 'approved' AND canceled = FALSE
@ -275,97 +231,14 @@ async def get_public_events() -> list[Event]:
""",
model=Event,
)
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def get_pending_events() -> list[Event]:
"""Proposed events awaiting admin approval."""
events = await db.fetchall(
return await db.fetchall(
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
model=Event,
)
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def flag_wave_transitions() -> int:
"""Flag events whose advertised ticket wave has moved on.
Every republish this extension performs is *sale*-driven. A wave
boundary is a *date* boundary, so when early bird closes at midnight
nothing fires and the relay keeps serving the closed wave's price until
the next ticket happens to sell (aiolabs/events#61).
Rather than add a scheduler and a second publish path, this compares the
wave a row would advertise now against the one its last successful
publish did (`nostr_published_wave_id`) and sets `nostr_publish_pending`
on a mismatch — handing the work to the existing reconciliation sweep,
which already retries, survives restarts and logs.
Rows with NULL `nostr_published_wave_id` are skipped: that means "never
published, or published before the column existed", which is no evidence
of drift. Flagging them would republish the whole table on first boot
after the upgrade.
Returns the number of rows newly flagged.
"""
events = await db.fetchall(
"""
SELECT * FROM events.events
WHERE nostr_published_wave_id IS NOT NULL
AND nostr_publish_pending = FALSE
AND canceled = FALSE
AND status = 'approved'
""",
model=Event,
)
flagged = 0
for event in events:
event = cast(Event, sync_event_ticket_waves(event))
if advertised_wave_key(event) == event.nostr_published_wave_id:
continue
event.nostr_publish_pending = True
await update_event(event)
flagged += 1
return flagged
async def get_events_pending_republish() -> list[Event]:
"""Events whose relay copy may be behind this row.
`nostr_publish_pending` is set before every publish attempt and
cleared only on a confirmed success, so a row still flagged here
either failed to publish or never got the chance. Drives the
reconciliation sweep in `events_start`.
Ordered oldest-first so a backlog drains in the order it accrued.
"""
events = await db.fetchall(
"""
SELECT * FROM events.events
WHERE nostr_publish_pending = TRUE
ORDER BY time ASC
""",
model=Event,
)
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def get_settings() -> EventsSettings:

View file

@ -1,173 +0,0 @@
# Rebase playbook
How to merge an upstream release into this fork without shipping the
failures a clean `git merge` cannot see.
Written during the v1.6.1 → v1.6.8 rebase (#33) after the first two
instances showed up within minutes of each other. Both were textually
clean merges that would have been semantically wrong in production.
Modes C and D were added later in the same rebase, from `services.py`.
## The four failure modes
Git resolves _text_. Neither of these produces a conflict marker.
### A. Missed application
Upstream establishes an invariant and applies it at every call site **it
knows about**. The fork has additional call sites upstream cannot see,
so the invariant silently does not hold there.
> **Worked example.** v1.6.8 made `event.amount_tickets` a per-wave
> roll-up recomputed by `sync_event_ticket_waves`, and added that call to
> `get_event` and `get_events` in `crud.py`. Both merged cleanly. But the
> fork has four getters upstream never had — `get_all_events`,
> `get_public_events`, `get_pending_events`,
> `get_events_pending_republish` — and two of them _publish_
> (`/republish-all` and the #55 sweep). Without the same call they emit
> the stale roll-up, so waves would have been wrong on exactly the paths
> that push to relays, and nowhere else.
### B. Changed meaning
Upstream redefines what an existing field _means_. Fork code that reads
it is untouched by the diff and keeps compiling, while now saying
something false.
> **Worked example.** After `sync_event_ticket_waves`,
> `event.price_per_ticket` is the **primary (first)** wave's price and
> `event.amount_tickets` is the **sum across all waves**. Our
> `nostr_publisher.build_nip52_event` reads both. Merged untouched, it
> would advertise the early-bird price after early-bird closed, and count
> stock in waves that have not opened. See #61.
### C. Misplaced conflict boundary
Git anchors a conflict on whatever lines happen to match. When both sides
rewrote the same region, an _incidental_ shared line inside it can become
the anchor — and everything past that line lands **outside** the markers,
where it reads as cleanly merged.
Resolving only what sits between the markers then leaves **both**
implementations in the file. Python does not complain: the later `def`
silently wins. Since the merge appends upstream after ours, the survivor
is upstream's — the fork's version is shadowed without a single warning.
> **Worked example.** In `services.py` the notification stack conflicted.
> Ours (220 lines: multipart HTML mail, the QR-card attachment, the
> Date/Message-ID headers that keep SpamAssassin quiet, npub DM support)
> appeared between the markers; upstream's showed as 4 lines. But both
> sides define the _same nine functions_, and git had anchored on a
> shared `_send_nostr_ticket_notification` line — so upstream's entire
> parallel stack sat below `>>>>>>>`, looking merged. Taking "ours" and
> moving on would have left upstream's definitions last in the file and
> therefore live, quietly reverting every one of those features.
**Do not trust the marker as the edit's boundary.** Before resolving a
hunk, list the function names on each side and compare them to the names
already in the file:
```sh
grep -o '^\(async \)\?def \w*' <file>.py | sed 's/.*def //' | sort | uniq -d
```
Run that per file **as you resolve**, not at the end. `ruff` does not
flag redefinition (verified: F ruleset passes on a duplicated `def`).
Only `mypy` does, via `no-redef` — and mypy refuses to run at all while
any file in the package still has conflict markers, so the one tool that
catches mode C is unavailable for the whole merge. The `uniq -d` line
above is the substitute.
### D. Collided names
Ours and upstream independently grew a function with the **same name for
a different feature**. Every resolution that reads as sane — take ours,
take theirs, take "the newer one" — silently deletes a feature, and the
diff looks like an ordinary reconciliation of one function.
> **Worked example.** Both sides had `_ticket_image_url`. Ours: the
> rendered QR ticket-card PNG, always attached, served by this extension
> off `lnbits_baseurl`. Upstream's: an organiser-uploaded template, opt-in
> per wave via `use_ticket_image`, served off `ticket_base_url` and
> returning `None` when the wave has not enabled it. Same name, different
> arity, different return type, unrelated features. Resolved by renaming
> ours to `_ticket_card_url` and keeping both — upstream's ticket-image
> upload UI had already merged into `index.vue`, so dropping their backend
> would have orphaned live UI.
Signals worth stopping on: the two versions differ in **arity**, in
**return type** (`str` vs `str | None`), or in which setting they build a
URL from. Any of those means it is probably not one function with two
histories.
## The procedure
Run this _after_ the merge resolves and _before_ the release.
### 1. Enumerate what upstream introduced
```sh
MB=$(git merge-base HEAD upstream/main)
# new public names
git diff $MB..<tag> -- '*.py' | grep -E "^\+(def |class |async def )"
# fields whose meaning was redefined
git show <tag>:models.py | sed -n '/^def sync_event_ticket_waves/,/return event/p'
```
Split the result into **new symbols** (mode A candidates) and
**redefined fields** (mode B candidates).
### 2. For each new symbol upstream _calls_, find the fork-only siblings
Ask what category of place the call belongs to — "every function that
returns an `Event` from the DB", "every path that prices a ticket" — then
enumerate that whole category in the merged tree and check coverage.
```sh
grep -n "sync_event_ticket_waves" crud.py # where upstream put it
grep -n "^async def get_.*-> \(list\[\)\?Event" crud.py # where it belongs
```
The gap between those two lists is the work.
### 3. For each redefined field, grep the fork-only files
The 30-odd files upstream has never seen are where mode B hides, because
nothing in the diff touches them:
```sh
git diff --name-only $MB..HEAD > /tmp/fork.txt
git diff --name-only $MB..<tag> > /tmp/up.txt
comm -23 <(sort /tmp/fork.txt) <(sort /tmp/up.txt) # fork-only files
grep -n "price_per_ticket\|amount_tickets" $(comm -23 ...)
```
### 4. Prove each finding before fixing it
Both examples above were confirmed by reading the code path end to end,
not inferred from the diff. A wrong theory costs more than the check:
during this rebase an inference that `amount_tickets` "goes stale on
sale" was wrong — `sync_event_ticket_waves` also runs on _reads_, which
only the call-site list showed.
### 5. Write the reason at the site
Every fix from this procedure gets a comment saying **why upstream's diff
missed it**. That is what stops the next rebase re-dropping it, and it is
the only durable record that the omission was considered rather than
overlooked.
## Checklist
- [ ] `migrations.py` still byte-identical to upstream
- [ ] New upstream symbols enumerated; each call-site category audited
- [ ] Redefined fields enumerated; every fork-only reader checked
- [ ] Fork-only files listed and grepped for both modes
- [ ] Every resolved file checked for duplicate `def`s (mode C) — as it is
resolved, since mypy cannot run until the whole package is clean
- [ ] Same-named functions on both sides compared by arity and return type
before being treated as one function (mode D)
- [ ] Publishing paths specifically audited — they fail silently and
externally, so they are the worst place for either mode to land
- [ ] Every fix carries a comment explaining the omission
- [ ] Deviations recorded in `docs/upstream-candidates.md`

View file

@ -1,21 +0,0 @@
# 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 |
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 |
| NIP-52 tags describe the **active** ticket wave (cheapest open wave; `tickets_available: 0` when none is open), plus `nostr_published_wave_id` so the reconciliation sweep republishes at wave boundaries | `nostr_publisher.py`, `crud.py` `flag_wave_transitions`, `migrations_fork.py` m004 | lnbits/events #46 (rides with the NIP-52 publishing PR) | **deviation, deliberate** — upstream has waves but publishes no calendar event, so there is nothing upstream to match. Several waves can be open at once and a publisher cannot ask which one the buyer wants (upstream's purchase path errors with "Please select a ticket wave"), so it advertises the cheapest — the price a buyer can actually obtain. Rationale and the rejected alternatives: aiolabs/events#61 |

View file

@ -128,60 +128,3 @@ async def m002_ticket_payment_hash(db):
"WHERE payment_hash IS NULL OR payment_hash = ''"
)
async def m003_event_nostr_publish_pending(db):
"""
Add `events.nostr_publish_pending` — the marker that makes NIP-52
publish drift queryable instead of invisible.
Inventory reaches clients only through the republished calendar
event. When that publish doesn't land, the relay keeps serving the
counts it last saw and nothing anywhere records the divergence; it
has twice been caught only by a human reading a public page
(aiolabs/events#35, #51).
The flag is set before each publish attempt and cleared only on a
confirmed success, so it covers *both* observed failure shapes:
an attempt that raised (a signer outage) and an attempt that was
never made at all (no signer resolved, no NostrClient). A periodic
sweep republishes whatever is still marked.
Existing rows default to FALSE rather than TRUE: on upgrade we have
no evidence they're stale, and marking the whole table pending would
stampede the signer with a full-table republish on first boot.
`/republish-all` is the deliberate way to force that.
"""
await _alter_add_column_safe(
db,
"ALTER TABLE events.events "
"ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE",
)
async def m004_event_nostr_published_wave(db):
"""
Add `events.nostr_published_wave_id` — which ticket wave the last
successful NIP-52 publish advertised.
Since upstream v1.6.8 price and inventory live on time-boxed waves, so
what a calendar event should advertise changes at a *date* boundary.
Every republish we have is sale-driven, and no sale happens at
midnight when early bird ends — so without this the relay keeps
serving the closed wave's price until the next ticket sells
(aiolabs/events#61).
Recording the advertised wave makes that drift detectable with the
machinery already in place: the reconciliation sweep compares this
against the wave that would be advertised now and sets
`nostr_publish_pending`, reusing the existing retry path rather than
adding a scheduler.
NULL on existing rows means "never published, or published before this
column existed". The sweep treats NULL as "no evidence of drift" and
leaves it alone, so an upgrade does not stampede the signer with a
full-table republish; the first ordinary publish fills it in.
"""
await _alter_add_column_safe(
db,
"ALTER TABLE events.events ADD COLUMN nostr_published_wave_id TEXT",
)

349
models.py
View file

@ -1,114 +1,33 @@
import json
from datetime import date, datetime
from urllib.parse import urlsplit
from uuid import uuid4
from datetime import datetime
from lnbits.db import FilterModel
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
PAYMENT_METHODS = ("lightning", "fiat")
class PromoCode(BaseModel):
code: str
discount_percent: float = 0.0
active: bool = True
# Redemption cap; None / 0 = unlimited. Field names follow upstream v2.
max_uses: int | None = None
# Derived on read from PAID tickets whose extra.applied_promo_code matches
# (see promo.promo_usage / services.hydrate_promo_usage). Whatever a
# client sends back here is ignored — it is never the source of truth.
used_count: int = 0
# stored form: stripped + upper-case, never empty
# make the promo code uppercase
@validator("code")
def uppercase_code(cls, v):
v = (v or "").strip().upper()
if not v:
raise ValueError("Promo code cannot be empty.")
return v
return v.upper()
@validator("discount_percent")
def validate_discount_percent(cls, v):
assert 0 <= v <= 100, "Discount must be between 0 and 100."
return v
@validator("max_uses", pre=True)
def normalize_max_uses(cls, v):
if v in (None, "", 0, "0"):
return None
v = int(v)
if v < 1:
raise ValueError("max_uses must be at least 1.")
return v
class TicketWave(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex[:8])
title: str = "Primary wave"
opening_date: str
closing_date: str
currency: str = "sat"
use_ticket_image: bool = False
ticket_image_id: str | None = None
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = Field(default=0, ge=0)
price_per_ticket: float = Field(default=0, ge=0)
class EventExtraBase(BaseModel):
"""Everything in `extra` that is safe to show anyone — ticket waves
included, since a buyer needs a wave id to choose one. `EventExtra` adds
the organizer-only promo codes on top; `PublicEventExtra` is this base,
so anonymous responses can never carry them."""
class EventExtra(BaseModel):
promo_codes: list[PromoCode] = Field(default_factory=list)
conditional: bool = False
min_tickets: int = 1
email_notifications: bool = False
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)
# Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The
# event-level `currency` / `allow_fiat` / `amount_tickets` /
# `price_per_ticket` fields become derived values (see
# `sync_event_ticket_waves`), which is why fork code that reads them
# needs auditing — aiolabs/events#61.
#
# Public, not organizer-only: a buyer cannot choose a wave without its
# id, and neither the public event response nor the NIP-52 tags carried
# one before. A wave holds price, dates and remaining stock — the sales
# information a buyer needs — so the only thing exposing it reveals is
# the upcoming price schedule, which is what #61 regretted giving up
# when it settled on flat Nostr tags.
ticket_waves: list[TicketWave] = 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 EventExtra(EventExtraBase):
promo_codes: list[PromoCode] = Field(default_factory=list)
PublicEventExtra = EventExtraBase
class CreateEvent(BaseModel):
@ -123,12 +42,7 @@ class CreateEvent(BaseModel):
currency: str = "sat"
allow_fiat: bool = False
fiat_currency: str = "GBP"
# Capacity is always required and there is no unlimited (#34): a zero
# here means sold out / not sellable, which `api_get_event` and
# `api_ticket_create` both enforce with a 410. Under v1.6.8 waves this
# is only the seed for the primary wave — `sync_event_ticket_waves`
# recomputes it as the sum of every wave's remaining stock.
amount_tickets: int = 0
amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = 0 # 0 = free
banner: str | None = None
location: str | None = None # venue/address (NIP-52 'location' tag)
@ -160,16 +74,6 @@ class Event(BaseModel):
status: str = "approved"
nostr_event_id: str | None = None
nostr_event_created_at: int | None = None
# Set before every publish attempt, cleared on confirmed success.
# True means the relay's copy may be behind this row — see
# migrations_fork.m003 and the sweep in __init__.events_start.
nostr_publish_pending: bool = False
# Which wave the last successful publish advertised (see
# `advertised_wave_key`). NULL = never published; "" = published while
# nothing was on sale. The sweep compares this against the current key
# to catch wave boundaries, which are time-driven and so fire no
# sale-triggered republish (aiolabs/events#61).
nostr_published_wave_id: str | None = None
@validator("categories", pre=True)
def parse_categories(cls, v):
@ -193,9 +97,7 @@ class PublicEvent(BaseModel):
banner: str | None
location: str | None = None
categories: list[str] = Field(default_factory=list)
# PublicEventExtra: promo codes are organizer-only (a buyer who can read
# every code can mint every discount).
extra: PublicEventExtra = Field(default_factory=PublicEventExtra)
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
@validator("categories", pre=True)
@ -205,69 +107,6 @@ class PublicEvent(BaseModel):
return v or []
def effective_payment_methods(
event: "Event | PublicEvent | CreateEvent",
wave: "TicketWave | None" = None,
) -> 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`).
Pass `wave` when the answer is about one specific ticket wave. Fiat is a
per-wave opt-in since v1.6.8 and `event.allow_fiat` is only the PRIMARY
wave's, so without it a publisher can advertise a fiat rail for an
advertised wave that does not accept fiat — which the purchase endpoint
then rejects (aiolabs/events#61). Callers asking the event-level
question ("which rails did the organiser enable at all") leave it unset.
"""
explicit = list(getattr(event.extra, "payment_methods", []) or [])
if explicit:
# The organiser's rail list is event-level, but fiat is a per-wave
# opt-in. Asking about a specific wave means asking what a buyer can
# actually use for it, so drop a rail that wave cannot honour —
# otherwise the NIP-52 tag advertises fiat and the checkout offers a
# card button that `api_ticket_create` then refuses with "Fiat
# payments are not enabled for this ticket wave."
if wave is not None and not wave.allow_fiat:
return [method for method in explicit if method != "fiat"]
return explicit
methods = ["lightning"]
if wave.allow_fiat if wave is not None else event.allow_fiat:
methods.append("fiat")
return methods
class PromoValidateRequest(BaseModel):
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork prices a
plain `quantity` against one ticket wave.
`ticket_wave_id` may be omitted when exactly one wave is open, matching
how the purchase endpoint resolves it — the preview has to price the same
wave the invoice will, and since v1.6.8 price and currency are per-wave.
"""
codes: list[str] = Field(default_factory=list)
quantity: int = Field(default=1, ge=1, le=10)
ticket_wave_id: str | None = None
class BasketDiscount(BaseModel):
code: str
discount_percent: float | None = None
discount_fixed: int | None = None # always None here (percent-only); v2 shape
amount_saved: float = 0
class BasketTotals(BaseModel):
subtotal: float = 0
discount: float = 0
total: float = 0
discounts_applied: list[BasketDiscount] = Field(default_factory=list)
currency: str = "sat" # fork addition so a client can format the numbers
class EventsSettings(BaseModel):
"""Extension-level settings for the events extension."""
@ -276,8 +115,6 @@ class EventsSettings(BaseModel):
class TicketExtra(BaseModel):
applied_promo_code: str | None = None
ticket_wave_id: str | None = None
ticket_wave_title: str | None = None
sats_paid: int | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
@ -285,18 +122,12 @@ class TicketExtra(BaseModel):
email_notification_sent: bool = False
nostr_notification_sent: bool = False
refunded: bool = False
# On-chain vocabulary, populated once native lnbits on-chain lands
# (aiolabs/events#41). Upstream's field names, minus the SatsPay charge
# id — SatsPay is the implementation we are not adopting.
onchain: bool = False
onchain_address: str | None = None
class CreateTicket(BaseModel):
name: str | None = None
email: EmailStr | None = None
user_id: str | None = None # LNbits user id (alternative to name+email)
ticket_wave_id: str | None = None
promo_code: str | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
@ -305,37 +136,16 @@ 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
@ -358,22 +168,6 @@ 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
@ -389,133 +183,8 @@ class TicketPaymentRequest(BaseModel):
fiat_payment_request: str | None = None
fiat_provider: str | None = None
is_fiat: bool = False
# True when the tickets are already issued + paid with no invoice to
# settle — free events (price 0) or a 100%-off promo. The client skips
# the QR / payment-poll step and goes straight to the ticket QRs.
paid: bool = False
# Row ids created on this invoice — one for single-ticket
# purchases, N for multi-ticket (each independently scannable at
# the door). Buyers fetch these after payment to render N QRs in
# My Tickets.
ticket_ids: list[str] = Field(default_factory=list)
onchain_amount_sat: int | None = None
class TicketFilters(FilterModel):
__search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012
__sort_fields__ = [ # noqa: RUF012
"time",
"event",
"name",
"email",
"registered",
"id",
]
event: str | None = None
name: str | None = None
email: str | None = None
registered: bool | None = None
paid: bool | None = None
id: str | None = None
def _parse_date(value: str) -> date:
"""Date component of `value`.
Upstream only ever produces bare `YYYY-MM-DD` here, so its version is a
plain `strptime(value, "%Y-%m-%d")`. In this fork `event_end_date` may
carry a time (start/end times, v1.3.0-aio.3) and `create_event` defaults
`closing_date` to it, so a wave derived from an event inherits the full
ISO datetime and upstream's parser raises
`ValueError: unconverted data remains: T18:00:00` — on the purchase path,
the public event gate, and the promo preview.
"""
return date.fromisoformat(value[:10])
def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]:
ticket_waves = list(getattr(event.extra, "ticket_waves", []) or [])
if ticket_waves:
return ticket_waves
# `TicketWave` requires both dates; `Event.closing_date` is Optional in
# this fork (it defaults from event_end_date at create time), so fall
# back the same way `create_event` does rather than handing None to a
# required field.
closing_date = event.closing_date or event.event_end_date or event.event_start_date
fallback_opening_date = None
event_time = getattr(event, "time", None)
if event_time:
fallback_opening_date = event_time.date().isoformat()
if not fallback_opening_date:
fallback_opening_date = closing_date
return [
TicketWave(
id="primary",
title="Primary wave",
opening_date=fallback_opening_date,
closing_date=closing_date,
currency=event.currency,
allow_fiat=event.allow_fiat,
fiat_currency=event.fiat_currency,
amount_tickets=getattr(event, "amount_tickets", 0),
price_per_ticket=event.price_per_ticket,
)
]
def advertised_ticket_wave(event: "Event | PublicEvent") -> "TicketWave | None":
"""The wave a public listing should describe, or None when nothing is
on sale (sold out, between waves, or not yet open).
Several waves can be open at once. The purchase endpoint refuses to
guess; a publisher has no one to ask, so it advertises the CHEAPEST
open wave — the price a buyer is actually able to obtain.
Shared by the NIP-52 publisher and the wave-transition detector so the
two cannot disagree about which wave is currently being advertised.
"""
active = get_active_ticket_waves(event)
if not active:
return None
return min(active, key=lambda wave: wave.price_per_ticket)
def advertised_wave_key(event: "Event | PublicEvent") -> str:
"""Stable key for what a publish advertised. Empty string means "nothing
on sale", which is a real published state and distinct from NULL in
`nostr_published_wave_id` (never published)."""
wave = advertised_ticket_wave(event)
return wave.id if wave else ""
def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent:
ticket_waves = ensure_ticket_waves(event)
event.extra.ticket_waves = ticket_waves
primary_wave = ticket_waves[0]
event.closing_date = max(wave.closing_date for wave in ticket_waves)
event.currency = primary_wave.currency
event.allow_fiat = primary_wave.allow_fiat
event.fiat_currency = primary_wave.fiat_currency
event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves)
event.price_per_ticket = primary_wave.price_per_ticket
return event
def get_active_ticket_waves(
event: Event | PublicEvent, today: date | None = None
) -> list[TicketWave]:
current_day = today or datetime.utcnow().date()
return [
wave
for wave in ensure_ticket_waves(event)
if _parse_date(wave.opening_date)
<= current_day
<= _parse_date(wave.closing_date)
and wave.amount_tickets > 0
]

View file

@ -19,14 +19,6 @@ from websocket import WebSocketApp
from .event import NostrEvent
MAX_SEEN_EVENTS = 500
# How many times a dequeued req is retried before it is dropped. Bounded
# so one unsendable message can't block every later publish behind it.
MAX_SEND_ATTEMPTS = 3
# How long to wait for the relay's `OK` before treating a publish as
# unconfirmed. nostrclient's router answers within its own
# PUBLISH_TIMEOUT_SECONDS (10s) even when every relay stays silent, so
# this only needs headroom over that.
PUBLISH_OK_TIMEOUT_SECONDS = 12
class NostrClient:
@ -37,10 +29,6 @@ class NostrClient:
self.subscription_id = "events-" + urlsafe_short_hash()[:32]
self.running = False
self._seen_events: OrderedDict[str, None] = OrderedDict()
# event id -> future awaiting that publish's `OK`. Resolved in
# `get_event`, which is the single point where relay messages
# cross into the event loop.
self._pending_oks: dict[str, asyncio.Future] = {}
@property
def is_websocket_connected(self):
@ -90,37 +78,17 @@ class NostrClient:
async def run_forever(self):
self.running = True
# A req that was dequeued but whose send raised. It is already
# off the queue, so dropping it loses the publish outright and
# the caller has long since been told it succeeded (the queue
# put returns immediately). Hold it across the reconnect and
# retry instead.
held_req: list | None = None
held_attempts = 0
while self.running:
try:
if not self.is_websocket_connected:
self.ws = await self.connect()
await asyncio.sleep(5)
if held_req is not None:
req = held_req
else:
req = await self.send_req_queue.get()
held_req, held_attempts = req, held_attempts + 1
assert self.ws
self.ws.send(json.dumps(req))
held_req, held_attempts = None, 0
except Exception as ex:
logger.warning(f"[EVENTS] NostrClient error: {ex}")
if held_req is not None and held_attempts >= MAX_SEND_ATTEMPTS:
# Bounded: a req the relay or the socket will never
# accept must not wedge the queue behind it forever.
logger.error(
f"[EVENTS] Dropping req after {held_attempts} "
f"failed sends: {held_req[0]}"
)
held_req, held_attempts = None, 0
await asyncio.sleep(60)
def is_duplicate_event(self, event_id: str) -> bool:
@ -133,82 +101,14 @@ class NostrClient:
return False
async def get_event(self):
"""Get the next relay message, consuming `OK` frames on the way.
This is the only place relay messages cross from the websocket
thread into the event loop, which makes it the natural place to
settle publish confirmations — no cross-thread future juggling.
`OK` frames are swallowed rather than forwarded; the sync loop
never handled them.
"""
while True:
"""Get next event from the receive queue."""
value = await self.receive_event_queue.get()
if isinstance(value, ValueError):
self._fail_pending_oks("connection closed")
raise value
if self._settle_ok(value):
continue
return value
def _settle_ok(self, message) -> bool:
"""Resolve the future for an `["OK", <id>, <bool>, <msg>]` frame.
Returns True when `message` was an OK frame (and so should not
be forwarded), False otherwise. An OK for a publish we are not
waiting on — a retry whose original already timed out, say — is
still consumed; it has nowhere useful to go.
"""
try:
data = json.loads(message)
except (json.JSONDecodeError, TypeError):
return False
if not isinstance(data, list) or len(data) < 3 or data[0] != "OK":
return False
event_id = data[1]
accepted = bool(data[2])
detail = data[3] if len(data) > 3 and isinstance(data[3], str) else ""
future = self._pending_oks.get(event_id)
if future and not future.done():
future.set_result((accepted, detail))
return True
def _fail_pending_oks(self, reason: str) -> None:
"""Settle every in-flight publish as unconfirmed.
Without this a disconnect leaves callers waiting the full
timeout for an `OK` that can no longer arrive.
"""
for future in self._pending_oks.values():
if not future.done():
future.set_result((False, f"error: {reason}"))
async def publish_nostr_event(self, e: NostrEvent) -> bool:
"""Publish and wait for the relay's `OK`. True only when accepted.
Queueing is not delivery: nostrclient drops an EVENT outright
when no relay is connected, answering `OK false`. Reporting
success on the queue put let a stale ticket count survive a
republish that never left the building (aiolabs/events#56).
"""
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending_oks[e.id] = future
try:
async def publish_nostr_event(self, e: NostrEvent):
await self.send_req_queue.put(["EVENT", e.dict()])
accepted, detail = await asyncio.wait_for(
future, PUBLISH_OK_TIMEOUT_SECONDS
)
if not accepted:
logger.warning(f"[EVENTS] Relay rejected event {e.id[:12]}…: {detail}")
return accepted
except asyncio.TimeoutError:
logger.warning(
f"[EVENTS] No OK for event {e.id[:12]}… within "
f"{PUBLISH_OK_TIMEOUT_SECONDS}s — treating as unconfirmed"
)
return False
finally:
self._pending_oks.pop(e.id, None)
async def subscribe(self, filters: list[dict]):
"""Subscribe to events matching the given filters."""

View file

@ -8,79 +8,36 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models).
from loguru import logger
from .crud import update_event
from .models import Event, advertised_wave_key
from .models import Event
from .nostr_publisher import publish_event_to_nostr
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> bool:
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None:
"""Publish or delete the NIP-52 calendar event for `event`.
Resolves a `NostrSigner` for the wallet owner — backend-agnostic
(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The
signer abstraction handles the actual key material; this hook
only needs `signer.pubkey` for event construction and
`await signer.sign_event(...)` for signing. Failures are logged
and swallowed so a Nostr outage doesn't break the HTTP flow that
triggered the publish.
Returns True when the event was signed and handed to the client,
False on any skip or failure. Callers are free to ignore it — the
`nostr_publish_pending` flag is the durable record, and the sweep
retries from that rather than from a return value.
Pulls the wallet owner's pubkey/prvkey to sign with the user's identity.
Failures are logged and swallowed so a Nostr outage doesn't break the
HTTP flow that triggered the publish.
"""
# Mark before attempting, clear only on confirmed success. Doing it
# in this order is what makes "the attempt was never made" — no
# signer, no NostrClient, process died mid-flight — as visible as
# "the attempt raised". Cheap guard so a re-publish of an already
# pending row doesn't write twice; `set_ticket_paid` sets the flag
# inside its own update so the sale path adds no extra write.
if not event.nostr_publish_pending:
event.nostr_publish_pending = True
await update_event(event)
try:
from lnbits.core.signers import resolve_for_wallet
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import get_wallet
from . import nostr_client
signer = await resolve_for_wallet(event.wallet)
if signer is None:
# Wallet missing, account missing, unclassified row, or
# ClientSideOnlySigner account (server can't sign for them).
# Soft-fail: the HTTP / payment flow that triggered this must
# not break. The user can still publish kind-31922/31923
# events client-side once we have that path.
#
# Logged at WARNING, not debug: skipping the publish means the
# relay keeps serving whatever inventory it last saw, so the
# public ticket count silently stops tracking the DB. That has
# twice been discovered only by a human noticing a wrong number
# on a public page (aiolabs/events#35, #51).
logger.warning(
f"[EVENTS] No signer for wallet {event.wallet}, skipping "
f"NIP-52 {'delete' if delete else 'publish'} for event {event.id}"
)
return False
wallet_obj = await get_wallet(event.wallet)
if not wallet_obj:
return
account = await get_account(wallet_obj.user)
if not account or not account.pubkey or not account.prvkey:
return
nostr_event = await publish_event_to_nostr(
nostr_client, event, signer, delete=delete
nostr_client, event, account.pubkey, account.prvkey, delete=delete
)
if nostr_event is None:
return False
event.nostr_publish_pending = False
if not delete:
if nostr_event and not delete:
event.nostr_event_id = nostr_event.id
event.nostr_event_created_at = nostr_event.created_at
# Record which wave this publish advertised so the sweep can
# notice a wave boundary later (aiolabs/events#61). Written in
# the same update as the cleared flag, so the two can never
# disagree about what is on the relay.
event.nostr_published_wave_id = advertised_wave_key(event)
await update_event(event)
return True
except Exception as exc:
# ERROR, not warning: the row stays flagged and its published
# counts stay behind until the sweep or a later edit succeeds.
logger.error(f"[EVENTS] Nostr publish failed for event {event.id}: {exc}")
return False
logger.warning(f"[EVENTS] Nostr publish failed: {exc}")

View file

@ -1,9 +1,8 @@
"""
NIP-52 calendar event publishing for the events extension.
Builds NIP-52 calendar events from the Event model, signs them via the
core `NostrSigner` abstraction (backend-agnostic: LocalSigner,
RemoteBunkerSigner, etc.), and publishes via the NostrClient.
Builds NIP-52 calendar events from the Event model, signs them with the
creator's Account keypair, and publishes via the NostrClient.
Kind 31922 is used for date-only events; kind 31923 (time-based) is used
when event_start_date / event_end_date include a time component.
@ -14,17 +13,11 @@ Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
import time
from datetime import datetime, timezone
from lnbits.core.signers import NostrSigner
import coincurve
from loguru import logger
from .models import (
Event,
advertised_ticket_wave,
effective_payment_methods,
ensure_ticket_waves,
)
from .models import Event
from .nostr.event import NostrEvent
from .nostr_timestamp import monotonic_created_at
def _has_time(value: str | None) -> bool:
@ -51,13 +44,11 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
start - unix timestamp (31923) or YYYY-MM-DD (31922)
end - same encoding (optional)
image, location, t (categories) - optional
tickets_available - remaining capacity of the advertised wave
(always emitted; 0 = nothing on sale now)
tickets_sold - running paid-count across all waves (always
emitted)
tickets_price - the advertised wave's price (always emitted;
0 means free)
tickets_currency - the advertised wave's currency
tickets_available - current remaining capacity (omitted when unlimited)
tickets_sold - running paid-count (always emitted; clients can
derive original_capacity = available + sold)
tickets_price - price_per_ticket (always emitted; 0 means free)
tickets_currency - the currency string
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
Content: event.info
@ -103,69 +94,24 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
for cat in event.categories or []:
tags.append(["t", cat])
# Always emitted, including zero. Omitting it used to mean "unlimited",
# which contradicted every other reader: `api_get_event` and
# `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a
# zero-capacity event advertised "Unlimited tickets" on the card while
# the detail page and the purchase both returned 410 (aiolabs/events#34).
# Clients that must still handle an absent tag — a NIP-52 event from
# some other publisher — are unaffected; we simply never omit it.
#
# Since v1.6.8 price, currency and inventory belong to a ticket WAVE.
# The event-level fields `sync_event_ticket_waves` derives are the
# PRIMARY wave's price/currency and the SUM of every wave's stock, so
# publishing them would keep advertising the early-bird price after
# early bird closed, and count stock in waves that have not opened yet
# (aiolabs/events#61). Advertise the wave a buyer can actually buy from.
#
# Several waves can be open at once. The purchase endpoint refuses to
# guess ("Please select a ticket wave"); a publisher has no one to ask,
# so it advertises the CHEAPEST open wave — the price a buyer is able to
# obtain right now. Deviation recorded in docs/upstream-candidates.md.
open_wave = advertised_ticket_wave(event)
# Nothing open: sold out, between waves, or not yet on sale. Fall back
# to the primary wave so price/currency still describe the event,
# paired with the zero availability below.
advertised = open_wave or ensure_ticket_waves(event)[0]
# Always emitted, including zero — see #34 above. With no open wave
# there is nothing to sell regardless of what the waves hold, so this
# is 0 rather than the wave's stock.
available = advertised.amount_tickets if open_wave else 0
tags.append(["tickets_available", str(available)])
# Event-level: total paid across every wave, which is what "sold" means
# to a reader of the card.
# `amount_tickets == 0` means unlimited capacity in this extension's
# schema. Omitting the tag is how clients distinguish unlimited from
# "0 left" (sold out).
if event.amount_tickets > 0:
tags.append(["tickets_available", str(event.amount_tickets)])
tags.append(["tickets_sold", str(event.sold)])
tags.append(["tickets_price", str(advertised.price_per_ticket)])
tags.append(["tickets_currency", advertised.currency])
tags.append(["tickets_price", str(event.price_per_ticket)])
tags.append(["tickets_currency", event.currency])
# Fiat-checkout config — only emitted when allow_fiat is on so
# clients can branch the buy UI without re-reading the schema.
if advertised.allow_fiat:
if event.allow_fiat:
tags.append(["tickets_allow_fiat", "true"])
if advertised.fiat_currency:
tags.append(["tickets_fiat_currency", advertised.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",
# Scoped to the advertised wave so this cannot contradict
# `tickets_allow_fiat` above — they are the same fact.
",".join(effective_payment_methods(event, advertised)),
]
)
if event.fiat_currency:
tags.append(["tickets_fiat_currency", event.fiat_currency])
# NIP-52 calendar events are replaceable: this d-tag is republished
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
# created_at anchored on the last published value so a same-second
# republish still outranks the prior version and relays push it to open
# subscriptions — a bare int(time.time()) can tie and be silently
# dropped, stalling clients' live "tickets remaining" badge.
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=monotonic_created_at(event.nostr_event_created_at),
created_at=int(time.time()),
kind=kind,
tags=tags,
content=event.info or "",
@ -196,64 +142,37 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
return nostr_event
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
"""Sign a NostrEvent in-place using Schnorr signature."""
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id))
nostr_event.sig = sig.hex()
async def publish_event_to_nostr(
nostr_client,
event: Event,
signer: NostrSigner,
account_pubkey: str,
account_prvkey: str,
delete: bool = False,
) -> NostrEvent | None:
"""
Build, sign, and publish a NIP-52 calendar event (or delete event).
Signing routes through the core `NostrSigner` abstraction —
`signer.pubkey` for the event identity, `await signer.sign_event(...)`
for the Schnorr signature. The signer backend (LocalSigner /
RemoteBunkerSigner) is transparent to this function.
Returns the published NostrEvent for metadata storage, or None on failure.
"""
if not nostr_client:
# WARNING, not debug: with no client the event is never queued, so
# the relay keeps serving stale inventory and nothing downstream
# can tell. At debug this skip is invisible at the INFO level
# instances actually run at (aiolabs/events#35, #51).
logger.warning(
"[EVENTS] No NostrClient, skipping NIP-52 "
f"{'delete' if delete else 'publish'} for event {event.id}"
)
logger.debug("[EVENTS] No NostrClient available, skipping publish")
return None
try:
if delete:
nostr_event = build_nip52_delete_event(event, signer.pubkey)
nostr_event = build_nip52_delete_event(event, account_pubkey)
else:
nostr_event = build_nip52_event(event, signer.pubkey)
nostr_event = build_nip52_event(event, account_pubkey)
# Hand the unsigned event to the signer — it fills in `id`,
# `pubkey`, and `sig`. The signer's serialization rules match
# NIP-01 (same as the local `event_id` property uses), so the
# returned id matches what we'd have computed locally.
unsigned = {
"kind": nostr_event.kind,
"created_at": nostr_event.created_at,
"tags": nostr_event.tags,
"content": nostr_event.content,
}
signed = await signer.sign_event(unsigned)
nostr_event.id = signed["id"]
nostr_event.pubkey = signed["pubkey"]
nostr_event.sig = signed["sig"]
accepted = await nostr_client.publish_nostr_event(nostr_event)
if not accepted:
# Returning None keeps `nostr_publish_pending` set, so the
# sweep retries instead of recording a delivery that never
# happened (aiolabs/events#56).
logger.warning(
f"[EVENTS] Relay did not confirm NIP-52 "
f"{'delete' if delete else 'calendar'} event for {event.id}"
)
return None
sign_nostr_event(nostr_event, account_prvkey)
await nostr_client.publish_nostr_event(nostr_event)
logger.info(
f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} "
@ -262,8 +181,5 @@ async def publish_event_to_nostr(
return nostr_event
except Exception as e:
# ERROR, not warning: this is the signer-outage shape of
# aiolabs/events#35 — the calendar event never reaches the relay
# and the published ticket counts stop tracking the DB.
logger.error(f"[EVENTS] Failed to publish event {event.id} to Nostr: {e}")
logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}")
return None

View file

@ -1,34 +0,0 @@
"""Monotonic ``created_at`` for replaceable / addressable Nostr events.
Relays only push a replaceable update to OPEN subscriptions when its
``created_at`` is strictly newer than the version they already hold.
``created_at`` is integer seconds, so a publisher that stamps
``int(time.time())`` can emit two versions within the same wall-clock
second (e.g. two ticket sales republishing the NIP-52 calendar event) —
the relay treats the second as not-newer and never propagates it to live
subscribers (it only surfaces on a reload / fresh REQ).
Returning ``max(now, last_created_at + 1)`` guarantees a strictly
increasing timestamp across successive publishes of the same replaceable
event. When enough real seconds have elapsed it tracks wall-clock; only
same-second (or clock-skewed) republishes get nudged forward.
Mirrors the webapp's ``monotonicCreatedAt`` (src/lib/nostr/timestamp.ts)
and ``docs/nostr-patterns/replaceable-events.md``.
"""
import time
def monotonic_created_at(last_created_at: int | None, now: int | None = None) -> int:
"""Strictly-newer ``created_at`` for the next publish of a coord.
:param last_created_at: ``created_at`` of the previously published
version (seconds), or ``None`` if none has been published yet.
:param now: Current time in seconds — injectable for tests; defaults
to ``int(time.time())``.
"""
base = int(time.time()) if now is None else now
if last_created_at is None:
return base
return max(base, last_created_at + 1)

117
promo.py
View file

@ -1,117 +0,0 @@
"""Promo-code arithmetic shared by the validate endpoint and the purchase path.
Pure functions (no DB, no settings) so the number a buyer sees in the
"Apply" preview is exactly the number the invoice / Stripe session charges.
Field names and the `BasketTotals` shape follow upstream lnbits/events v2
(PR #64) so the eventual rebase (#33) merges cleanly; deviations are noted
inline.
"""
from __future__ import annotations
from collections import Counter
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket, TicketWave
SAT_UNITS = ("sat", "sats")
def normalize_code(raw: str | None) -> str | None:
"""Buyer input → stored form (stripped, upper-cased); empty → None."""
if raw is None:
return None
code = raw.strip().upper()
return code or None
def find_promo(event: Event, code: str) -> PromoCode | None:
return next((pc for pc in event.extra.promo_codes if pc.code == code), None)
def promo_usage(tickets: list[Ticket]) -> dict[str, int]:
"""Redemptions per code = PAID tickets carrying it in
`extra.applied_promo_code`. Every row counts, so a multi-ticket
purchase consumes `quantity` uses (upstream v2 counts one per basket).
Paid only: pending rows live up to 24 h (`purge_unpaid_tickets`), so
counting them would let an abandoned Stripe session lock out the last
uses of a limited code for a day. The cost is a bounded overshoot when
several buyers pass the check before any of them pays — accepted.
"""
counter: Counter[str] = Counter()
for ticket in tickets:
code = ticket.extra.applied_promo_code
if ticket.paid and code:
counter[code] += 1
return dict(counter)
def remaining_uses(promo: PromoCode, used: int) -> int | None:
"""None = unlimited (`max_uses` unset / 0)."""
if not promo.max_uses:
return None
return max(promo.max_uses - used, 0)
def round_amount(amount: float, currency: str | None) -> float:
"""Sats are integers; fiat is 2 dp. Applied once, at the end, so
subtotal - total == discount holds for what is actually charged."""
if (currency or "sat").lower() in SAT_UNITS:
return float(int(amount))
return round(amount, 2)
def basket_totals(
event: Event,
codes: list[str],
quantity: int,
usage: dict[str, int],
wave: TicketWave,
) -> BasketTotals:
"""Price `quantity` tickets from `wave` with the first applicable code.
A code is applicable when it exists, is active, has enough uses left
for the whole quantity, and actually saves something. Anything else is
simply absent from `discounts_applied` (upstream v2 semantics — the
purchase endpoint is where hard errors are raised). Only one code is
applied; v2's `combinable` stacking is out of scope here.
`wave` is the pricing authority, not `event`. Since v1.6.8 a ticket's
price and currency belong to the wave it is bought from, and the
event-level fields are a derived roll-up of the PRIMARY wave
(`sync_event_ticket_waves`) — pricing off `event` would quote and charge
the first wave's price to a buyer who picked a later one. It is a
required argument rather than an optional override precisely because
that failure is silent: both call sites have to name the wave.
"""
currency = wave.currency or "sat"
subtotal = round_amount(wave.price_per_ticket * quantity, currency)
totals = BasketTotals(
subtotal=subtotal, discount=0, total=subtotal, currency=currency
)
for raw in codes:
code = normalize_code(raw)
if not code:
continue
promo = find_promo(event, code)
if not promo or not promo.active:
continue
remaining = remaining_uses(promo, usage.get(promo.code, 0))
if remaining is not None and remaining < quantity:
continue
total = round_amount(subtotal * (1 - promo.discount_percent / 100), currency)
saved = round_amount(subtotal - total, currency)
if saved <= 0:
continue
totals.total = total
totals.discount = saved
totals.discounts_applied = [
BasketDiscount(
code=promo.code,
discount_percent=promo.discount_percent,
discount_fixed=None,
amount_saved=saved,
)
]
break
return totals

254
qr.py
View file

@ -1,254 +0,0 @@
"""QR + ticket-card rendering shared by the API and the mailer.
`make_qr_png` is upstream v1.6.8's helper (pyqrcode + Pillow) with the
instance QR logo pasted in the centre; `render_ticket_card` wraps it in a
self-describing card (event, when, where, name, ticket id) so the PNG a
buyer saves from the email still says what it is for.
"""
from __future__ import annotations
import re
from datetime import datetime
from io import BytesIO
from pathlib import Path
import httpx
import pyqrcode # type: ignore[import-untyped]
from lnbits.settings import settings
from loguru import logger
from PIL import Image, ImageDraw, ImageFont
from .models import Event, Ticket
_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. 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
def _parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
try:
return datetime.strptime(value[:10], "%Y-%m-%d")
except ValueError:
return None
def format_event_when(event: Event) -> str:
"""'Fri 19 Feb 2027, 16:00 - 20:00' (same day) or a full range; the raw
strings when they do not parse."""
start = _parse_iso(event.event_start_date)
end = _parse_iso(event.event_end_date)
if not start:
return event.event_start_date or ""
has_time = "T" in (event.event_start_date or "")
day = start.strftime("%a %d %b %Y")
if not end or end == start:
return f"{day}, {start.strftime('%H:%M')}" if has_time else day
if end.date() == start.date():
if has_time:
return f"{day}, {start.strftime('%H:%M')} - {end.strftime('%H:%M')}"
return day
end_day = end.strftime("%a %d %b %Y")
if has_time:
return f"{day} {start.strftime('%H:%M')} - {end_day} {end.strftime('%H:%M')}"
return f"{day} - {end_day}"
def ticket_card_filename(ticket: Ticket, event: Event) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", event.name.lower()).strip("-")[:40] or "event"
return f"ticket-{slug}-{ticket.id[:8]}.png"
_FONT_DIR = Path(__file__).resolve().parent / "static" / "fonts"
def _font(
size: int, bold: bool = False
) -> ImageFont.ImageFont | ImageFont.FreeTypeFont:
"""DejaVu Sans shipped with the extension (full Latin coverage — the
Pillow-bundled default lacks accented glyphs, so 'Château' would render
as tofu); Pillow's default is the fallback."""
path = _FONT_DIR / ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf")
try:
return ImageFont.truetype(str(path), size)
except OSError:
try:
return ImageFont.load_default(size=size)
except Exception: # very old Pillow: bitmap default only
return ImageFont.load_default()
def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
lines: list[str] = []
for paragraph in text.split("\n"):
words = paragraph.split()
line = ""
for word in words:
candidate = f"{line} {word}".strip()
if draw.textlength(candidate, font=font) <= max_width or not line:
line = candidate
else:
lines.append(line)
line = word
lines.append(line)
return lines
def render_ticket_card(
ticket: Ticket,
event: Event,
*,
logo: Image.Image | None = None,
site_title: str | None = None,
width: int = 800,
) -> Image.Image:
"""A self-describing ticket: header (site), event name, when/where, the
QR, then name on ticket + ticket id + door instruction."""
pad = 48
inner = width - 2 * pad
title_font = _font(40, bold=True)
body_font = _font(28)
small_font = _font(22)
mono_font = _font(24)
# Measure first: the card grows with the wrapped title.
probe = ImageDraw.Draw(Image.new("RGB", (width, 10), "white"))
title_lines = _wrap(probe, event.name, title_font, inner)
when = format_event_when(event)
meta_lines = [when] if when else []
if event.location:
meta_lines += _wrap(probe, event.location, body_font, inner)
qr_size = min(inner, 560)
detail_lines = []
if ticket.name:
detail_lines.append(f"Name: {ticket.name}")
detail_lines.append(f"Ticket {ticket.id}")
y = pad
y += 30 + 16 # site title line
y += len(title_lines) * 52 + 12
y += len(meta_lines) * 36 + 28
qr_y = y
y += qr_size + 28
y += len(detail_lines) * 36 + 12
y += 30 + pad # footer
height = y
img = Image.new("RGB", (width, height), "white")
draw = ImageDraw.Draw(img)
grey = (110, 110, 110)
black = (20, 20, 20)
y = pad
draw.text((pad, y), (site_title or "Ticket").upper(), fill=grey, font=small_font)
y += 30 + 16
for line in title_lines:
draw.text((pad, y), line, fill=black, font=title_font)
y += 52
y += 12
for line in meta_lines:
draw.text((pad, y), line, fill=black, font=body_font)
y += 36
y += 28
qr = make_qr_png(f"ticket://{ticket.id}", size=qr_size, logo=logo).convert("RGB")
img.paste(qr, ((width - qr_size) // 2, qr_y))
y = qr_y + qr_size + 28
for line in detail_lines:
font = mono_font if line.startswith("Ticket ") else body_font
draw.text((pad, y), line, fill=black, font=font)
y += 36
y += 12
draw.text((pad, y), "Show this QR code at the door.", fill=grey, font=small_font)
return img
def image_png_bytes(img: Image.Image) -> bytes:
out = BytesIO()
img.save(out, format="PNG")
return out.getvalue()

View file

@ -1,19 +1,14 @@
from __future__ import annotations
import asyncio
import re
import smtplib
from asyncio.tasks import create_task
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, make_msgid
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_user_notification
from lnbits.helpers import is_valid_email_address
from lnbits.core.services.notifications import (
send_email_notification,
send_user_notification,
)
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
from lnurl import execute
@ -26,22 +21,8 @@ from .crud import (
update_event,
update_ticket,
)
from .models import (
Event,
NotificationDeliveryResult,
Ticket,
TicketResendResult,
ensure_ticket_waves,
)
from .models import Event, Ticket
from .nostr_hooks import publish_or_delete_nostr_event
from .promo import promo_usage
from .qr import (
format_event_when,
image_png_bytes,
load_qr_logo,
render_ticket_card,
ticket_card_filename,
)
DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io",
@ -76,29 +57,7 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid"
event.sold += 1
# Debit the wave the buyer actually bought from. v1.6.8 moved
# inventory onto waves; the event-level counter is only the
# fallback for events that predate them, and is itself a derived
# roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are
# kept — ours decremented unconditionally and could go negative.
ticket_waves = event.extra.ticket_waves or []
if ticket_waves:
selected_wave = next(
(
wave
for wave in ticket_waves
if wave.id == ticket.extra.ticket_wave_id
),
ticket_waves[0],
)
if selected_wave.amount_tickets > 0:
selected_wave.amount_tickets -= 1
elif event.amount_tickets > 0:
event.amount_tickets -= 1
# Flag inside this same write: the counters and "the relay does
# not know about them yet" land atomically, so a crash between
# here and the publish still leaves the drift discoverable.
event.nostr_publish_pending = True
await update_event(event)
# Republish the NIP-52 calendar event so connected clients see
@ -111,22 +70,6 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
return ticket
async def event_promo_usage(event_id: str) -> dict[str, int]:
"""Paid redemptions per promo code for one event (see promo.promo_usage)."""
return promo_usage(await get_event_tickets(event_id))
async def hydrate_promo_usage(event: Event) -> Event:
"""Fill `used_count` on each of the event's promo codes. No query when
the event has no codes, so listing stays cheap."""
if not event.extra.promo_codes:
return event
usage = await event_promo_usage(event.id)
for promo in event.extra.promo_codes:
promo.used_count = usage.get(promo.code, 0)
return event
def send_ticket_notification_in_background(ticket: Ticket) -> None:
create_task(_send_ticket_notification(ticket))
@ -137,20 +80,40 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
logger.warning(f"Event {ticket.event} not found for ticket notification.")
return
await _deliver_ticket_notifications(ticket, event)
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)
async def resend_ticket_email_notification(
ticket: Ticket, base_url: str | None = None
) -> 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.
`base_url` is upstream v1.6.8's: it re-points the ticket link at the
caller's frontend, which matters for us specifically because our
`ticket_base_url` can differ from `lnbits_baseurl` (separate web app).
"""
async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event)
if not event:
raise ValueError("Event does not exist.")
@ -158,13 +121,11 @@ async def resend_ticket_email_notification(
raise ValueError("Email notifications are not enabled.")
if not ticket.email:
raise ValueError("Ticket does not have an email address.")
if base_url:
ticket.extra.ticket_base_url = base_url.rstrip("/")
# email-only: this is the *email* resend endpoint. Upstream calls
# `_deliver_ticket_notifications(ticket, event)` unqualified, which in
# our fork would also fire a Nostr DM the organiser did not ask for.
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
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)
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
@ -181,255 +142,6 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str
return subject, f"{body}\n\nOpen it here: {ticket_url}"
def _ticket_details(ticket: Ticket, event: Event) -> str:
"""Human-readable ticket facts for the email body. Also what keeps the
mail from being an image with no words (SpamAssassin HTML_IMAGE_ONLY)."""
lines = [f"Event: {event.name}", f"When: {format_event_when(event)}"]
if event.location:
lines.append(f"Where: {event.location}")
if ticket.name:
lines.append(f"Name on ticket: {ticket.name}")
lines.append(f"Ticket ID: {ticket.id}")
lines.append(
"Your ticket (with its QR code) is attached to this email — save it "
"or open the link above on your phone, and show the QR code at the "
"door to be scanned in."
)
return "\n".join(lines)
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
"""Text part of the ticket mail.
Carries up to two images, which are NOT the same thing (see the note on
`_ticket_card_url` vs `_ticket_image_url`): our rendered QR card, always
present, and the organiser's uploaded template, only when the buyer's
wave opted into it. They had the same label before the v1.6.8 merge
because only one of them existed; now that both can appear the labels
have to distinguish them.
"""
message = (
f"{base_message}\n\n{_ticket_details(ticket, event)}"
f"\n\nTicket card: {_ticket_card_url(ticket)}"
)
ticket_image_url = _ticket_image_url(ticket, event)
if ticket_image_url:
message = f"{message}\n\nTicket image: {ticket_image_url}"
return message
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
"""HTML twin of the text part.
Deliberately no <img> for our own ticket card: it travels as an
attachment (renders inline in most clients, works offline, and keeps
SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link.
The organiser's uploaded ticket image is a remote URL with no attachment
to fall back on, so that one does get upstream's <img> — the surrounding
ticket details keep the mail from being image-only either way.
"""
text_message = _ticket_delivery_message(ticket, event, base_message)
html = escape(text_message)
html = re.sub(
r"(https?://[^\s<]+)",
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
html,
)
html_message = f"<p>{html.replace(chr(10), '<br />')}</p>"
ticket_image_url = _ticket_image_url(ticket, event)
if not ticket_image_url:
return html_message
return (
f"{html_message}"
f'<p><img src="{escape(ticket_image_url, quote=True)}" alt="Ticket image" '
'style="max-width: 200px; 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, event, base_message)
html_message = _ticket_email_html_message(ticket, event, 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.
Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery`
check that errors with "Only NIP-05 Nostr identifiers are supported."
That guard is NOT taken here: it encodes upstream's limitation, not ours.
`_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to
core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard
would silently refuse identifiers we deliver to today — and say so with
a message that would be false for this fork.
"""
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
card = render_ticket_card(
ticket,
event,
logo=await load_qr_logo(),
site_title=settings.lnbits_site_title,
)
await _send_ticket_email_notification(
[ticket.email],
text_message,
subject,
html_message,
attachments=[
(ticket_card_filename(ticket, event), image_png_bytes(card))
],
)
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,
attachments: list[tuple[str, bytes]] | 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 = build_ticket_email(
from_email, to_emails, subject, message, html_message, attachments
)
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 build_ticket_email(
from_email: str,
to_emails: list[str],
subject: str,
message: str,
html_message: str | None = None,
attachments: list[tuple[str, bytes]] | None = None,
) -> MIMEMultipart:
"""Assemble the ticket email: text + HTML alternatives, PNG attachments
(the ticket card), and the headers receivers score on — a Date and a
Message-ID (their absence is what SpamAssassin's MISSING_DATE /
MISSING_MID flag, and what Gmail/Outlook read as machine-generated) and
a display name on From so the sender is not a bare address."""
body = MIMEMultipart("alternative")
body.attach(MIMEText(message, "plain"))
if html_message:
body.attach(MIMEText(html_message, "html"))
if attachments:
msg = MIMEMultipart("mixed")
msg.attach(body)
for filename, data in attachments:
part = MIMEImage(data, _subtype="png")
part.add_header("Content-Disposition", "attachment", filename=filename)
msg.attach(part)
else:
msg = body
sender_name = (settings.lnbits_site_title or "").strip() or "Tickets"
msg["From"] = formataddr((sender_name, from_email))
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])
return msg
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(
@ -449,36 +161,6 @@ def _ticket_url(ticket: Ticket) -> str:
return f"{base_url}/events/ticket/{ticket.id}"
def _ticket_card_url(ticket: Ticket) -> str:
"""Our rendered ticket-card PNG — always available, and 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/ticket-card/{ticket.id}"
)
def _ticket_image_url(ticket: Ticket, event: Event) -> str | None:
"""Upstream's organiser-uploaded ticket template, opted into per wave.
Distinct from `_ticket_card_url`: that is our own rendered card and is
always attached, this is a template the organiser uploads and enables
on a specific wave. They shared a name before the v1.6.8 merge, which
made them look like one feature.
"""
waves = ensure_ticket_waves(event)
wave = next(
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
waves[0],
)
if not wave.use_ticket_image:
return None
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
return f"{base_url}/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.

Binary file not shown.

Binary file not shown.

View file

@ -1,187 +0,0 @@
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
Bitstream Vera Fonts Copyright
------------------------------
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Arev Fonts Copyright
------------------------------
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the fonts accompanying this license ("Fonts") and
associated documentation files (the "Font Software"), to reproduce
and distribute the modifications to the Bitstream Vera Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to
the following conditions:
The above copyright and trademark notices and this permission notice
shall be included in all copies of one or more of the Font Software
typefaces.
The Font Software may be modified, altered, or added to, and in
particular the designs of glyphs or characters in the Fonts may be
modified and additional glyphs or characters may be added to the
Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but
no copy of one or more of the Font Software typefaces may be sold by
itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the name of Tavmjong Bah shall not
be used in advertising or otherwise to promote the sale, use or other
dealings in this Font Software without prior written authorization
from Tavmjong Bah. For further information, contact: tavmjong @ free
. fr.
TeX Gyre DJV Math
-----------------
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
(on behalf of TeX users groups) are in public domain.
Letters imported from Euler Fraktur from AMSfonts are (c) American
Mathematical Society (see below).
Bitstream Vera Fonts Copyright
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license (“Fonts”) and associated
documentation
files (the “Font Software”), to reproduce and distribute the Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute,
and/or sell copies of the Font Software, and to permit persons to whom
the Font Software is furnished to do so, subject to the following
conditions:
The above copyright and trademark notices and this permission notice
shall be
included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional
glyphs or characters may be added to the Fonts, only if the fonts are
renamed
to names not containing either the words “Bitstream” or the word “Vera”.
This License becomes null and void to the extent applicable to Fonts or
Font Software
that has been modified and is distributed under the “Bitstream Vera”
names.
The Font Software may be sold as part of a larger software package but
no copy
of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
INABILITY TO USE
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of GNOME, the GNOME
Foundation,
and Bitstream Inc., shall not be used in advertising or otherwise to promote
the sale, use or other dealings in this Font Software without prior written
authorization from the GNOME Foundation or Bitstream Inc., respectively.
For further information, contact: fonts at gnome dot org.
AMSFonts (v. 2.2) copyright
The PostScript Type 1 implementation of the AMSFonts produced by and
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
available for general use. This has been accomplished through the
cooperation
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
Members of this consortium include:
Elsevier Science IBM Corporation Society for Industrial and Applied
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
In order to assure the authenticity of these fonts, copyright will be
held by
the American Mathematical Society. This is not meant to restrict in any way
the legitimate use of the fonts, such as (but not limited to) electronic
distribution of documents containing these fonts, inclusion of these fonts
into other public domain or commercial font collections or computer
applications, use of the outline data to create derivative fonts and/or
faces, etc. However, the AMS does require that the AMS copyright notice be
removed from any derivative versions of the fonts which have been altered in
any way. In addition, to ensure the fidelity of TeX documents using Computer
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
has requested that any alterations which yield different font metrics be
given a different name.
$Id$

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

View file

@ -13,7 +13,6 @@ window.PageEventsDisplay = {
email: '',
refund: '',
nostr_identifier: '',
ticket_wave_id: null,
payment_method: 'lightning'
}
},
@ -36,73 +35,21 @@ 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.
// Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat`
// is only the PRIMARY wave's, so prefer the active wave when there is
// one — otherwise a later fiat-enabled wave would not offer fiat.
const explicit = this.event?.extra?.payment_methods || []
if (explicit.length) return explicit
const allowFiat = this.selectedTicketWave
? this.selectedTicketWave.allow_fiat
: this.event?.allow_fiat
return ['lightning', ...(allowFiat ? ['fiat'] : [])]
},
activeTicketWaves() {
const today = new Date().toISOString().slice(0, 10)
return (this.event?.extra?.ticket_waves || []).filter(
wave =>
wave.amount_tickets > 0 &&
wave.opening_date <= today &&
wave.closing_date >= today
)
},
selectedTicketWave() {
return (
this.activeTicketWaves.find(
wave => wave.id === this.formDialog.data.ticket_wave_id
) ||
this.activeTicketWaves[0] ||
null
)
},
showTicketWaveSelector() {
return this.activeTicketWaves.length > 1
},
allowFiatCheckout() {
return this.paymentMethods.includes('fiat')
},
paymentMethodOptions() {
// Options come from `paymentMethods` — the same list the backend
// validates against in `effective_payment_methods` — rather than
// being re-derived from per-method booleans, so a rail the server
// would reject can never be offered. The `|| method` fallback covers
// a method with no label yet (on-chain, once #41 lands).
const labels = {
lightning: 'Lightning',
fiat: this.fiatCheckoutLabel
}
return this.paymentMethods.map(method => ({
value: method,
label: labels[method] || method
}))
return Boolean(this.event?.allow_fiat)
},
fiatCheckoutLabel() {
if (!this.allowFiatCheckout) return 'Fiat'
const unit = ['sat', 'sats'].includes(
(this.selectedTicketWave?.currency || '').toLowerCase()
(this.event?.currency || '').toLowerCase()
)
? this.selectedTicketWave?.fiat_currency
: this.selectedTicketWave?.currency
? this.event?.fiat_currency
: this.event?.currency
return `Fiat (${(unit || 'GBP').toUpperCase()})`
},
allowEmailNotifications() {
@ -119,16 +66,6 @@ window.PageEventsDisplay = {
'GET',
`/events/api/v1/events/${this.eventId}`
)
const activeWaves = (data.extra?.ticket_waves || []).filter(wave => {
const today = new Date().toISOString().slice(0, 10)
return (
wave.amount_tickets > 0 &&
wave.opening_date <= today &&
wave.closing_date >= today
)
})
this.formDialog.data.ticket_wave_id =
activeWaves.length === 1 ? activeWaves[0].id : null
return data
} catch (error) {
this.eventErrorLabel = 'Event unavailable.'
@ -141,13 +78,7 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.ticket_wave_id =
this.activeTicketWaves.length === 1
? this.activeTicketWaves[0].id
: null
this.formDialog.data.promo_code = ''
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
this.formDialog.data.payment_method = 'lightning'
},
closeReceiveDialog() {
@ -159,13 +90,6 @@ window.PageEventsDisplay = {
this.paymentWebsocket.close()
this.paymentWebsocket = null
}
this.paymentReq = null
this.receive = {
show: false,
status: 'pending',
paymentReq: null,
isFiat: false
}
},
nameValidation(val) {
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
@ -188,12 +112,7 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.ticket_wave_id =
this.activeTicketWaves.length === 1
? this.activeTicketWaves[0].id
: null
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
this.formDialog.data.payment_method = 'lightning'
Quasar.Notify.create({
type: 'positive',
message: 'Sent, thank you!',
@ -222,19 +141,10 @@ window.PageEventsDisplay = {
{
name: this.formDialog.data.name,
email: this.formDialog.data.email,
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
promo_code: this.formDialog.data.promo_code || null,
refund_address: this.formDialog.data.refund || null,
nostr_identifier: this.formDialog.data.nostr_identifier || null,
// Gate matches the template's (`paymentMethods.length > 1`).
// v1.6.8 gated on `showPaymentMethodSelector`
// (`allowFiatCheckout || allowOnchain`), a different condition:
// where the two disagreed the buyer's visible choice was
// silently replaced with 'lightning'.
payment_method:
this.paymentMethods.length > 1
? this.formDialog.data.payment_method
: this.paymentMethods[0] || 'lightning'
payment_method: this.formDialog.data.payment_method
}
)
const isFiat = Boolean(data.is_fiat)
@ -268,7 +178,7 @@ window.PageEventsDisplay = {
const url = new URL(window.location)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
url.pathname = `/api/v1/ws/${paymentHash}`
url.search = ''
url.hash = ''
@ -277,7 +187,7 @@ window.PageEventsDisplay = {
ws.onmessage = event => {
const data = JSON.parse(event.data)
if (data.paid === true) {
if (data.pending === false) {
this.paymentSuccess(paymentHash)
ws.close()
}
@ -286,12 +196,8 @@ window.PageEventsDisplay = {
console.error('WebSocket error:', error)
}
ws.onclose = () => {
if (this.paymentWebsocket !== ws) return
if (this.paymentWebsocket === ws) {
this.paymentWebsocket = null
if (this.receive.show) {
setTimeout(() => {
if (this.receive.show) this.paymentWatcher(paymentHash)
}, 3000)
}
}
}

View file

@ -74,7 +74,7 @@
filled
dense
v-model.trim="formDialog.data.nostr_identifier"
label="(optional) Nostr NIP-05"
label="(optional) Nostr NIP-05 or npub"
hint="If provided, we'll DM your ticket link after payment."
></q-input>
</div>
@ -89,34 +89,21 @@
lazy-rules
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input>
<q-select
v-if="showTicketWaveSelector"
filled
dense
v-model="formDialog.data.ticket_wave_id"
emit-value
map-options
label="Ticket wave"
:options="
activeTicketWaves.map(wave => ({
label: `${wave.title} - ${wave.price_per_ticket} ${wave.currency}`,
value: wave.id
}))
"
></q-select>
<div class="row q-col-gutter-md q-pt-lg items-center">
<div v-if="paymentMethods.length > 1" class="col-auto">
<div v-if="allowFiatCheckout" class="col-auto">
<q-option-group
v-model="formDialog.data.payment_method"
inline
:options="paymentMethodOptions"
:options="[
{label: 'Lightning', value: 'lightning'},
{
label: fiatCheckoutLabel,
value: 'fiat'
}
]"
></q-option-group>
</div>
<div
:class="
paymentMethods.length > 1 ? 'col-12 col-md-3' : 'col-12'
"
>
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'">
<q-input
filled
dense
@ -132,8 +119,7 @@
:disable="
formDialog.data.name == '' ||
formDialog.data.email == '' ||
(showTicketWaveSelector && !formDialog.data.ticket_wave_id) ||
(receive.show && Boolean(paymentReq))
Boolean(paymentReq)
"
type="submit"
>Submit</q-btn

View file

@ -4,10 +4,7 @@ window.PageEvents = {
return {
events: [],
tickets: [],
allPaidTickets: [],
resendingTicketEmails: [],
isUploadingTicketTemplate: false,
ticketImageUploadTarget: null,
currencies: [],
pendingEvents: [],
allUserEvents: [],
@ -28,12 +25,7 @@ window.PageEvents = {
label: 'Owner',
field: 'wallet_user_id'
},
{
name: 'id',
align: 'left',
label: 'ID',
field: row => this.shortenId(row.id)
},
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'event_start_date',
@ -47,6 +39,12 @@ window.PageEvents = {
label: 'End date',
field: 'event_end_date'
},
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{
name: 'canceled',
align: 'left',
@ -69,15 +67,13 @@ window.PageEvents = {
name: 'event_start_date',
align: 'left',
label: 'Start date',
field: 'event_start_date',
format: val => this.formatEventDate(val)
field: 'event_start_date'
},
{
name: 'event_end_date',
align: 'left',
label: 'End date',
field: 'event_end_date',
format: val => this.formatEventDate(val)
field: 'event_end_date'
},
{
name: 'closing_date',
@ -131,41 +127,16 @@ window.PageEvents = {
}
},
ticketsTable: {
loading: false,
columns: [
{
name: 'event',
align: 'left',
label: 'Event',
field: row => this.shortenId(row.event)
},
{name: 'event', align: 'left', label: 'Event', field: 'event'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{
name: 'email_sent',
align: 'left',
label: 'Email sent',
field: row =>
!row.email
? 'no email'
: row.extra?.email_notification_sent
? '\u2713 sent'
: row.paid
? 'not sent'
: 'unpaid'
},
{
name: 'registered',
align: 'left',
label: 'Registered',
field: 'registered'
},
{
name: 'nostr',
align: 'left',
label: 'Nostr',
field: row => row.extra?.nostr_identifier || ''
},
{
name: 'promo_code',
align: 'left',
@ -175,302 +146,38 @@ window.PageEvents = {
{name: 'id', align: 'left', label: 'ID', field: 'id'}
],
pagination: {
sortBy: 'time',
descending: true,
page: 1,
rowsPerPage: 10,
rowsNumber: 10
rowsPerPage: 10
}
},
// Rails an organizer can enable per event. Mirrors the webapp's
// CreateEventDialog; `fiat` is rendered disabled when the LNbits user
// has no fiat provider (see `hasFiatProvider`).
paymentMethodOptions: [
{
value: 'lightning',
label: 'Lightning',
hint: 'Pay with any Lightning wallet'
},
{
value: 'fiat',
label: 'Card',
hint: 'Card or bank through your configured fiat provider'
}
],
// Same list the webapp offers (src/modules/events/types/category.ts);
// published as NIP-52 `t` tags so both clients filter on one vocabulary.
categoryOptions: [
'concert',
'workshop',
'market',
'festival',
'exhibition',
'sport',
'theater',
'cinema',
'party',
'talk',
'conference',
'meetup',
'food',
'outdoor',
'kids',
'wellness',
'technology',
'art',
'music',
'dance',
'literature',
'comedy',
'charity',
'tradition',
'other'
].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})),
formDialog: {
show: false,
data: {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
location: '',
categories: [],
extra: {
payment_methods: ['lightning'],
ticket_waves: [],
promo_codes: [],
notification_subject: '',
notification_body: ''
}
}
},
ticketWaveDialog: {
show: false,
eventId: null,
wallet: null,
editingWaveId: null,
data: {
id: null,
title: '',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
},
promoCodesDialog: {
show: false,
data: {
id: null,
wallet: null,
name: '',
extra: {
promo_codes: []
}
}
}
}
},
computed: {
hasFiatProvider() {
return (this.g.user?.fiat_providers || []).length > 0
},
fiatProviderNames() {
return (this.g.user?.fiat_providers || [])
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
.join(', ')
},
acceptsFiat() {
return (this.formDialog.data.extra?.payment_methods || []).includes(
'fiat'
)
},
isSatPrice() {
return !this.isFiatCurrency(this.formDialog.data.currency)
},
fiatCurrencyOptions() {
return this.currencies.filter(c => this.isFiatCurrency(c))
}
},
methods: {
shortenId(value) {
if (!value) return ''
return value.length > 4 ? `${value.slice(0, 4)}...` : value
},
primaryTicketWave(data = this.formDialog.data) {
if (!data.extra) data.extra = {}
if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
data.extra.ticket_waves = [
{
id: 'primary',
title: 'Primary wave',
opening_date: data.closing_date || '',
closing_date: data.closing_date || '',
currency: data.currency || 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: Boolean(data.allow_fiat),
fiat_currency: data.fiat_currency || 'GBP',
amount_tickets: data.amount_tickets || 0,
price_per_ticket: data.price_per_ticket || 0
}
]
}
return data.extra.ticket_waves[0]
},
syncPrimaryWaveFromForm(data = this.formDialog.data) {
const primaryWave = this.primaryTicketWave(data)
primaryWave.title = primaryWave.title || 'Primary wave'
primaryWave.opening_date = primaryWave.opening_date || ''
primaryWave.closing_date = data.closing_date || ''
primaryWave.currency = data.currency || 'sats'
primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image)
primaryWave.ticket_image_id = primaryWave.ticket_image_id || null
primaryWave.allow_fiat = Boolean(data.allow_fiat)
primaryWave.fiat_currency = data.fiat_currency || 'GBP'
primaryWave.amount_tickets = Number(data.amount_tickets || 0)
primaryWave.price_per_ticket = Number(data.price_per_ticket || 0)
return primaryWave
},
hydrateEventForm(data) {
const formData = {
...data,
extra: {
...(data.extra || {}),
ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])]
}
}
const primaryWave = this.primaryTicketWave(formData)
formData.currency = primaryWave.currency || formData.currency || 'sats'
formData.allow_fiat = Boolean(primaryWave.allow_fiat)
formData.fiat_currency = primaryWave.fiat_currency || 'GBP'
formData.amount_tickets = primaryWave.amount_tickets
formData.price_per_ticket = primaryWave.price_per_ticket
formData.closing_date =
primaryWave.closing_date || formData.closing_date || ''
return formData
},
isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
},
normalizePromoCodes(promoCodes = []) {
return promoCodes
.filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
code: code.code.trim().toUpperCase(),
// fork-only: blank / 0 = unlimited; used_count is derived server-side
max_uses: code.max_uses ? Number(code.max_uses) : null
}))
},
templateDownloadUrl() {
return '/events/static/image/ticket.jpg'
},
async uploadAssetFile(file) {
const form = new FormData()
form.append('file', file)
form.append('public_asset', 'true')
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
form
)
return data.id
},
triggerTicketImageUpload(target) {
this.ticketImageUploadTarget = target
this.$refs.ticketImageUpload.value = null
this.$refs.ticketImageUpload.click()
},
async handleTicketImageSelected(event) {
const file = event.target.files?.[0]
if (!file || !this.ticketImageUploadTarget) return
this.isUploadingTicketTemplate = true
try {
const assetId = await this.uploadAssetFile(file)
if (this.ticketImageUploadTarget === 'primary') {
const wave = this.primaryTicketWave()
wave.use_ticket_image = true
wave.ticket_image_id = assetId
} else if (this.ticketImageUploadTarget === 'dialog') {
this.ticketWaveDialog.data.use_ticket_image = true
this.ticketWaveDialog.data.ticket_image_id = assetId
}
Quasar.Notify.create({
type: 'positive',
message: 'Ticket template uploaded.',
icon: null
})
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.isUploadingTicketTemplate = false
this.ticketImageUploadTarget = null
}
},
waveSummary(eventId, wave) {
// Built here, not in the template. Vue resolves template expressions
// against the component instance, where the `LNbits` global is NOT
// in scope — upstream's v1.6.8 chip called `LNbits.utils` inline and
// threw "Cannot read properties of undefined (reading 'utils')",
// which killed the whole v-for and left the wave list looking empty.
// No other template in this extension touches `LNbits` directly.
const price = this.isFiatCurrency(wave.currency)
? LNbits.utils.formatCurrency(
Number(wave.price_per_ticket || 0).toFixed(2),
wave.currency
)
: `${wave.price_per_ticket} sats`
// Wave dates can carry a time (closing_date defaults from
// event_end_date); show the day only.
const opens = String(wave.opening_date || '').slice(0, 10)
const closes = String(wave.closing_date || '').slice(0, 10)
const sold = this.soldTicketsForWave(eventId, wave.id)
return `${wave.title} - ${opens} to ${closes} - ${price} - ${wave.amount_tickets} tickets - ${sold} sold`
},
soldTicketsForWave(eventId, waveId) {
return this.allPaidTickets.filter(
ticket =>
ticket.event === eventId &&
ticket.paid &&
(ticket.extra?.ticket_wave_id === waveId ||
(!ticket.extra?.ticket_wave_id && waveId === 'primary'))
).length
},
async getAllTickets() {
try {
const {data} = await LNbits.api.request(
getTickets() {
LNbits.api
.request(
'GET',
'/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].adminkey
)
this.allPaidTickets = data.filter(ticket => ticket.paid)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async getTickets(props) {
try {
this.ticketsTable.loading = true
const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props)
const {data} = await LNbits.api.request(
'GET',
`/events/api/v1/tickets/paginated?all_wallets=true&${params}`,
this.g.user.wallets[0].adminkey
)
this.tickets = data.data
this.ticketsTable.pagination.rowsNumber = data.total
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.ticketsTable.loading = false
}
.then(response => {
this.tickets = response.data.filter(e => e.paid)
})
},
deleteTicket(ticketId) {
const tickets = _.findWhere(this.tickets, {id: ticketId})
@ -485,9 +192,10 @@ window.PageEvents = {
'/events/api/v1/tickets/' + ticketId,
wallet.adminkey
)
.then(async () => {
await this.getTickets()
await this.getAllTickets()
.then(response => {
this.tickets = _.reject(this.tickets, function (obj) {
return obj.id == ticketId
})
})
.catch(LNbits.utils.notifyApiError)
})
@ -505,30 +213,14 @@ window.PageEvents = {
wallet.adminkey
)
.then(response => {
const result = response.data
this.tickets = this.tickets.map(obj =>
obj.id === ticket.id ? result.ticket : obj
obj.id === ticket.id ? response.data : obj
)
if (result.email?.attempted) {
Quasar.Notify.create({
type: result.email.sent ? 'positive' : 'negative',
message: result.email.sent
? 'Ticket email resent.'
: `Ticket email failed: ${result.email.error || 'Unknown error.'}`,
type: 'positive',
message: 'Ticket email resent.',
icon: null
})
}
if (result.nostr?.attempted) {
Quasar.Notify.create({
type: result.nostr.sent ? 'positive' : 'negative',
message: result.nostr.sent
? 'Ticket Nostr DM resent.'
: `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`,
icon: null
})
}
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
@ -538,7 +230,7 @@ window.PageEvents = {
})
},
exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
},
getEvents() {
LNbits.api
@ -579,7 +271,12 @@ 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'})
})
@ -654,7 +351,9 @@ 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
@ -689,90 +388,43 @@ window.PageEvents = {
},
splitDateTime(value) {
// Inverse of foldDateTime: split a stored string back into the
// day/time pieces the form inputs bind to. Slicing to HH:MM also
// drops the seconds + offset suffix withLocalTzOffset stamps on
// submit, so the organizer sees the wall-clock they entered.
// day/time pieces the form inputs bind to.
if (!value) return {day: '', time: ''}
const [day, time = ''] = value.split('T')
// Time inputs only accept HH:MM, drop any seconds we stored.
return {day, time: time.slice(0, 5)}
},
withLocalTzOffset(value) {
// Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value.
// The publisher's `_to_unix` treats a naive datetime as UTC, so an
// event entered as 18:00 in CEST would otherwise go out on Nostr
// as 18:00 UTC. Same transform the webapp applies; date-only
// values pass through unchanged (they map to NIP-52 kind 31922).
if (!value || !value.includes('T')) return value
const offMin = -new Date(value).getTimezoneOffset()
const sign = offMin >= 0 ? '+' : '-'
const abs = Math.abs(offMin)
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
const mm = String(abs % 60).padStart(2, '0')
return `${value}:00${sign}${hh}:${mm}`
},
formatEventDate(value) {
// Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM".
if (!value) return ''
const {day, time} = this.splitDateTime(value)
return time ? `${day} ${time}` : day
},
validateEndDate() {
// Cross-field rule for the end-day input: end >= start, compared
// on the folded date+time so an equal-day earlier time is caught.
const d = this.formDialog.data
const start = this.foldDateTime(d.event_start_day, d.event_start_time)
const end = this.foldDateTime(d.event_end_day, d.event_end_time)
if (!start || !end) return true
return end >= start || 'End must be on or after start'
},
sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
const data = {...this.formDialog.data}
data.event_start_date = this.withLocalTzOffset(
this.foldDateTime(data.event_start_day, data.event_start_time)
data.event_start_date = this.foldDateTime(
data.event_start_day,
data.event_start_time
)
data.event_end_date = this.withLocalTzOffset(
this.foldDateTime(data.event_end_day, data.event_end_time)
data.event_end_date = this.foldDateTime(
data.event_end_day,
data.event_end_time
)
delete data.event_start_day
delete data.event_start_time
delete data.event_end_day
delete data.event_end_time
// Optional NIP-52 fields: blank location is "unset", not "".
data.location = (data.location || '').trim() || null
data.categories = data.categories || []
data.closing_date = data.closing_date || null
// Fold the form's day+time pairs first, then let upstream's helper
// mirror the form fields onto the primary wave — order matters, the
// sync reads closing_date/currency/amount_tickets off `data`.
this.syncPrimaryWaveFromForm(data)
if (data.extra?.promo_codes) {
data.extra.promo_codes = this.normalizePromoCodes(
data.extra.promo_codes
)
data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
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)) {
// A fiat-priced event settles in its price currency; mirror it so
// the payload (and the tickets_fiat_currency tag) stay coherent.
data.fiat_currency = data.currency
} else if (!data.allow_fiat) {
if (!this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
}
this.syncPrimaryWaveFromForm(data)
}
if (data.id) {
this.updateEvent(wallet, data)
@ -785,23 +437,8 @@ window.PageEvents = {
if (data && data.id) {
const start = this.splitDateTime(data.event_start_date)
const end = this.splitDateTime(data.event_end_date)
// Seed from upstream's hydrator (it materialises ticket_waves and
// mirrors the primary wave onto the flat form fields), then layer
// our fork-only fields on top.
const hydrated = this.hydrateEventForm(data)
this.formDialog.data = {
...hydrated,
extra: {
...(hydrated.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'] : [])]
},
location: data.location || '',
categories: [...(data.categories || [])],
...data,
event_start_day: start.day,
event_start_time: start.time,
event_end_day: end.day,
@ -812,33 +449,15 @@ window.PageEvents = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
location: '',
categories: [],
event_start_day: '',
event_start_time: '',
event_end_day: '',
event_end_time: '',
extra: {
payment_methods: ['lightning'],
conditional: false,
min_tickets: 1,
email_notifications: false,
nostr_notifications: false,
ticket_waves: [
{
id: 'primary',
title: 'Primary wave',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
],
promo_codes: [],
notification_subject: '',
notification_body: ''
@ -853,29 +472,9 @@ window.PageEvents = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
location: '',
categories: [],
extra: {
payment_methods: ['lightning'],
conditional: false,
min_tickets: 1,
email_notifications: false,
nostr_notifications: false,
ticket_waves: [
{
id: 'primary',
title: 'Primary wave',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
],
promo_codes: [],
notification_subject: '',
notification_body: ''
@ -896,183 +495,6 @@ window.PageEvents = {
const link = _.findWhere(this.events, {id: formId})
this.openEventDialog(link)
},
openTicketWaveDialog(event, wave = null) {
const primaryWave = (event.extra?.ticket_waves || [])[0] || {}
const isEditing = Boolean(wave)
this.ticketWaveDialog = {
show: true,
eventId: event.id,
wallet: event.wallet,
editingWaveId: wave?.id || null,
data: {
id: wave?.id || null,
title: wave?.title || '',
opening_date: wave?.opening_date || '',
closing_date: wave?.closing_date || '',
currency:
wave?.currency || primaryWave.currency || event.currency || 'sats',
use_ticket_image: Boolean(wave?.use_ticket_image),
ticket_image_id: wave?.ticket_image_id || null,
allow_fiat: isEditing
? Boolean(wave?.allow_fiat)
: Boolean(primaryWave.allow_fiat ?? event.allow_fiat),
fiat_currency:
wave?.fiat_currency ||
primaryWave.fiat_currency ||
event.fiat_currency ||
'GBP',
// `??` not `||`: a sold-out wave legitimately holds 0 and must
// show it rather than silently regaining stock on save. A NEW
// wave opens at 1, the smallest capacity the backend accepts —
// there is no unlimited (#34).
amount_tickets: isEditing ? (wave?.amount_tickets ?? 0) : 1,
price_per_ticket:
wave?.price_per_ticket ||
primaryWave.price_per_ticket ||
event.price_per_ticket ||
0
}
}
},
resetTicketWaveDialog() {
this.ticketWaveDialog = {
show: false,
eventId: null,
wallet: null,
editingWaveId: null,
data: {
id: null,
title: '',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
}
},
saveTicketWave() {
const event = _.findWhere(this.events, {
id: this.ticketWaveDialog.eventId
})
const wallet = _.findWhere(this.g.user.wallets, {
id: this.ticketWaveDialog.wallet
})
if (!event || !wallet) return
const payload = {
...event,
extra: {
...event.extra,
ticket_waves: (event.extra?.ticket_waves || []).map(existingWave =>
existingWave.id === this.ticketWaveDialog.editingWaveId
? {...this.ticketWaveDialog.data}
: existingWave
)
}
}
if (!this.ticketWaveDialog.editingWaveId) {
payload.extra.ticket_waves.push({...this.ticketWaveDialog.data})
}
if (payload.extra?.promo_codes) {
payload.extra.promo_codes = this.normalizePromoCodes(
payload.extra.promo_codes
)
}
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + payload.id,
wallet.adminkey,
payload
)
.then(response => {
this.events = this.events.map(item =>
item.id === payload.id ? response.data : item
)
Quasar.Notify.create({
type: 'positive',
message: this.ticketWaveDialog.editingWaveId
? 'Ticket wave updated.'
: 'Ticket wave added.',
icon: null
})
this.resetTicketWaveDialog()
})
.catch(LNbits.utils.notifyApiError)
},
openPromoCodesDialog(event) {
this.promoCodesDialog.data = {
...event,
extra: {
...event.extra,
promo_codes: [...(event.extra?.promo_codes || [])]
}
}
this.promoCodesDialog.show = true
},
resetPromoCodesDialog() {
this.promoCodesDialog.show = false
this.promoCodesDialog.data = {
id: null,
wallet: null,
name: '',
extra: {
promo_codes: []
}
}
},
addPromoCodeToDialog() {
this.promoCodesDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true,
// fork-only: null = unlimited uses
max_uses: null
})
},
savePromoCodes() {
const data = this.promoCodesDialog.data
const wallet = _.findWhere(this.g.user.wallets, {
id: data.wallet
})
if (!wallet) return
const payload = {
...data,
extra: {
...data.extra,
promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || [])
}
}
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + data.id,
wallet.adminkey,
payload
)
.then(response => {
this.events = this.events.map(event =>
event.id === data.id ? response.data : event
)
Quasar.Notify.create({
type: 'positive',
message: 'Promo codes updated.',
icon: null
})
this.resetPromoCodesDialog()
})
.catch(LNbits.utils.notifyApiError)
},
updateEvent(wallet, data) {
LNbits.api
.request(
@ -1139,7 +561,6 @@ window.PageEvents = {
async created() {
if (this.g.user.wallets.length) {
this.getTickets()
this.getAllTickets()
this.getEvents()
this.getSettings()
this.getPendingEvents()

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,13 +228,7 @@
<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>
@ -243,86 +237,45 @@
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<div class="q-pa-md">
<div class="row items-center q-mb-md">
<div class="text-subtitle1">Ticket waves</div>
<q-btn
round
dense
unelevated
color="primary"
icon="add"
class="q-ml-sm"
@click="openTicketWaveDialog(props.row)"
></q-btn>
</div>
<div class="column q-mb-lg">
<div class="text-subtitle1 q-mb-md">Promo codes</div>
<div class="column">
<div
v-for="(wave, index) in props.row.extra.ticket_waves"
:key="wave.id || index"
class="q-mb-sm"
>
<q-chip
square
clickable
class="q-py-xs"
style="height: auto"
@click="openTicketWaveDialog(props.row, wave)"
>
<span
style="white-space: normal; line-height: 1.3"
v-text="waveSummary(props.row.id, wave)"
></span>
</q-chip>
</div>
</div>
</div>
<div class="row items-center q-mb-md">
<div class="text-subtitle1">Promo codes</div>
<q-btn
round
dense
unelevated
color="primary"
icon="edit"
class="q-ml-sm"
@click="openPromoCodesDialog(props.row)"
></q-btn>
</div>
<div class="column">
<div
v-if="
props.row.extra.promo_codes.filter(
code => code.active
).length == 0
"
v-if="props.row.extra.promo_codes.length == 0"
class="text-caption"
>
No active promo codes for this event.
No promo codes for this event.
</div>
<div class="row q-col-gutter-sm">
<div
v-for="(
code, index
) in props.row.extra.promo_codes.filter(
code => code.active
)"
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="col-auto q-mb-sm"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-chip
square
size="md"
clickable
@click="utils.copyText(code.code.toUpperCase())"
>
<span
v-text="
`${code.code.toUpperCase()} - ${code.discount_percent}%`
"
></span>
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip>
</div>
<div class="col-auto">
Discount:
<span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span
:class="code.active ? 'text-green' : 'text-grey'"
v-text="code.active ? 'Active' : 'Inactive'"
></span>
</div>
</div>
</div>
</div>
@ -349,11 +302,9 @@
dense
flat
:rows="tickets"
:loading="ticketsTable.loading"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
@request="getTickets"
>
<template v-slot:header="props">
<q-tr :props="props">
@ -448,13 +399,7 @@
<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>
@ -522,10 +467,8 @@
filled
dense
v-model.trim="formDialog.data.name"
type="text"
label="Title of event *"
lazy-rules
:rules="[val => !!val || 'Title is required']"
type="name"
label="Title of event "
></q-input>
</div>
<div class="col q-pl-sm">
@ -549,30 +492,6 @@
label="Info about the event"
hint="Markdown supported"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.location"
type="text"
label="Location"
hint="Venue or address, e.g. Salle des fêtes, Foix. Published as the NIP-52 location tag."
>
<template v-slot:prepend>
<q-icon name="place"></q-icon>
</template>
</q-input>
<q-select
filled
dense
multiple
use-chips
emit-value
map-options
v-model="formDialog.data.categories"
:options="categoryOptions"
label="Categories"
hint="Published as NIP-52 hashtags so clients can filter the feed."
></q-select>
<q-input
filled
dense
@ -581,16 +500,25 @@
label="Image URL"
hint="Optional banner image to display on the event page"
></q-input>
<div class="row q-mt-lg">
<div class="col-4">Ticket closing date</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-4">Event begins *</div>
<div class="col-4">Event begins</div>
<div class="col-5">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_day"
type="date"
lazy-rules
:rules="[val => !!val || 'Start date is required']"
></q-input>
</div>
<div class="col-3">
@ -612,10 +540,6 @@
dense
v-model.trim="formDialog.data.event_end_day"
type="date"
hint="Defaults to the start date"
lazy-rules
reactive-rules
:rules="[validateEndDate]"
></q-input>
</div>
<div class="col-3">
@ -628,51 +552,6 @@
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-4">Ticket sales close</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
hint="Optional. Defaults to the event end date."
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mt-lg q-mb-md">
Primary ticket wave (you can add other waves later)
</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="primaryTicketWave().title"
type="text"
label="Wave title"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="primaryTicketWave().opening_date"
type="date"
label="Ticket opening date"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
label="Ticket closing date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
@ -680,7 +559,7 @@
dense
v-model="formDialog.data.currency"
type="text"
label="Price currency"
label="Unit"
:options="currencies"
></q-select>
</div>
@ -690,9 +569,7 @@
dense
v-model.number="formDialog.data.amount_tickets"
type="number"
min="1"
label="Amount of tickets"
hint="Total tickets on sale"
label="Amount of tickets "
></q-input>
</div>
<div class="col">
@ -710,83 +587,6 @@
></q-input>
</div>
</div>
<div class="q-mt-sm">
<div class="text-subtitle2">Payment methods *</div>
<div class="text-caption text-grey-7">
Pick the rails buyers can pay with. Untick Lightning for a
card-only sale.
</div>
<div class="row q-col-gutter-md q-mt-xs">
<div
v-for="opt in paymentMethodOptions"
:key="opt.value"
class="col-12 col-sm-6"
>
<!-- The span carries the tooltip: a disabled checkbox
swallows pointer events, the wrapper still hovers. -->
<span class="inline-block full-width">
<q-checkbox
v-model="formDialog.data.extra.payment_methods"
:val="opt.value"
:label="opt.label"
:disable="opt.value === 'fiat' && !hasFiatProvider"
></q-checkbox>
<div class="text-caption text-grey-7 q-pl-lg">
<span v-text="opt.hint"></span>
<span
v-if="opt.value === 'fiat' && hasFiatProvider"
v-text="' (' + fiatProviderNames + ')'"
></span>
</div>
<q-tooltip
v-if="opt.value === 'fiat' && !hasFiatProvider"
max-width="280px"
>
Your LNbits user has no fiat provider configured. Ask the
instance admin to enable Stripe, PayPal or Square (Admin →
Fiat providers) to accept card payments.
</q-tooltip>
</span>
</div>
</div>
</div>
<q-toggle
v-model="primaryTicketWave().use_ticket_image"
label="Use ticket image"
left-label
></q-toggle>
<div
v-if="primaryTicketWave().use_ticket_image"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-btn
unelevated
color="primary"
type="a"
:href="templateDownloadUrl()"
download="ticket.jpg"
>
Download template
<q-tooltip>400/733 jpg</q-tooltip>
</q-btn>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
:loading="isUploadingTicketTemplate"
@click="triggerTicketImageUpload('primary')"
>Replace</q-btn
>
</div>
<div
v-if="primaryTicketWave().ticket_image_id"
class="col-12 text-caption"
>
Custom ticket template uploaded.
</div>
</div>
<q-toggle
v-model="formDialog.data.allow_fiat"
label="Allow fiat checkout"
@ -794,17 +594,21 @@
hint="Lets attendees pay through a configured fiat provider using the event currency."
></q-toggle>
<q-select
v-if="acceptsFiat && isSatPrice"
v-if="
formDialog.data.allow_fiat &&
['sat', 'sats'].includes(
(formDialog.data.currency || '').toLowerCase()
)
"
filled
dense
v-model="formDialog.data.fiat_currency"
label="Fiat currency"
hint="Currency card buyers are charged in."
:options="fiatCurrencyOptions"
lazy-rules
:rules="[
val => !!val || 'Pick a fiat currency for buyers paying by card'
]"
label="Fiat checkout currency"
:options="
currencies.filter(
c => !['sat', 'sats'].includes((c || '').toLowerCase())
)
"
></q-select>
<q-expansion-item
group="advanced"
@ -837,30 +641,95 @@
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
formDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="
formDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true
})
"
>Add Promo Code</q-btn
>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
<div class="text-caption">
Send the paid ticket link automatically by email or Nostr DM.
</div>
<div class="row items-center q-col-gutter-md">
<div class="col-auto">
<q-toggle
v-model="formDialog.data.extra.email_notifications"
label="Email notifications"
left-label
></q-toggle>
</div>
<div class="col-auto">
<q-toggle
v-model="formDialog.data.extra.nostr_notifications"
label="Nostr notifications"
left-label
></q-toggle>
</div>
</div>
<div
v-if="formDialog.data.extra.email_notifications"
class="q-mt-md"
>
</q-expansion-item>
<q-separator class="q-my-md"></q-separator>
<q-input
filled
dense
@ -870,7 +739,6 @@
hint="Used as the email subject when sending paid ticket links."
></q-input>
<q-input
class="q-mt-md"
filled
dense
v-model.trim="formDialog.data.extra.notification_body"
@ -878,8 +746,6 @@
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
</div>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
@ -894,11 +760,14 @@
unelevated
color="primary"
:disable="
!formDialog.data.wallet ||
!formDialog.data.name ||
!formDialog.data.event_start_day ||
primaryTicketWave().title == null ||
primaryTicketWave().opening_date == null
formDialog.data.wallet == null ||
formDialog.data.name == null ||
formDialog.data.info == null ||
formDialog.data.closing_date == null ||
formDialog.data.event_start_day == null ||
formDialog.data.event_end_day == null ||
formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null
"
type="submit"
>Create Event</q-btn
@ -910,277 +779,5 @@
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="promoCodesDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="savePromoCodes" class="q-gutter-md">
<div class="text-subtitle1">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in promoCodesDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="promoCodesDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="
promoCodesDialog.data.extra.promo_codes[index].active
"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
promoCodesDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-2"
filled
dense
v-model.number="
promoCodesDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
:hint="
promoCodesDialog.data.extra.promo_codes[index].used_count
? 'Used ' +
promoCodesDialog.data.extra.promo_codes[index].used_count +
(promoCodesDialog.data.extra.promo_codes[index].max_uses
? ' / ' +
promoCodesDialog.data.extra.promo_codes[index].max_uses
: '')
: ''
"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="
promoCodesDialog.data.extra.promo_codes.splice(index, 1)
"
></q-btn>
</template>
</q-input>
<q-input
class="col-2"
filled
dense
v-model.number="
promoCodesDialog.data.extra.promo_codes[index].max_uses
"
type="number"
label="Max uses"
placeholder="∞"
min="1"
hint="Blank = unlimited"
></q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn @click="addPromoCodeToDialog">Add Promo Code</q-btn>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
>Save Promo Codes</q-btn
>
<q-btn
flat
color="grey"
class="q-ml-auto"
@click="resetPromoCodesDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="ticketWaveDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="saveTicketWave" class="q-gutter-md">
<div
class="text-subtitle1"
v-text="
ticketWaveDialog.editingWaveId
? 'Edit Ticket Wave'
: 'Add Ticket Wave'
"
></div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.title"
type="text"
label="Wave title"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.opening_date"
type="date"
label="Ticket opening date"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.closing_date"
type="date"
label="Ticket closing date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
filled
dense
v-model="ticketWaveDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="ticketWaveDialog.data.amount_tickets"
type="number"
min="1"
label="Amount of tickets"
hint="Tickets on sale in this wave"
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="ticketWaveDialog.data.price_per_ticket"
type="number"
:label="'Price (' + ticketWaveDialog.data.currency + ') *'"
:step="ticketWaveDialog.data.currency != 'sats' ? '0.01' : '1'"
:mask="ticketWaveDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="ticketWaveDialog.data.currency == null"
></q-input>
</div>
</div>
<q-toggle
v-model="ticketWaveDialog.data.use_ticket_image"
label="Use ticket image"
left-label
></q-toggle>
<div
v-if="ticketWaveDialog.data.use_ticket_image"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-btn
unelevated
color="primary"
type="a"
:href="templateDownloadUrl()"
download="ticket.jpg"
>
Download template
<q-tooltip>400/733 jpg</q-tooltip>
</q-btn>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
:loading="isUploadingTicketTemplate"
@click="triggerTicketImageUpload('dialog')"
>Replace</q-btn
>
</div>
<div
v-if="ticketWaveDialog.data.ticket_image_id"
class="col-12 text-caption"
>
Custom ticket template uploaded.
</div>
</div>
<q-toggle
v-model="ticketWaveDialog.data.allow_fiat"
label="Allow fiat checkout"
left-label
hint="Lets attendees pay through a configured fiat provider using this wave currency."
></q-toggle>
<q-select
v-if="
ticketWaveDialog.data.allow_fiat &&
['sat', 'sats'].includes(
(ticketWaveDialog.data.currency || '').toLowerCase()
)
"
filled
dense
v-model="ticketWaveDialog.data.fiat_currency"
label="Fiat checkout currency"
:options="
currencies.filter(
c => !['sat', 'sats'].includes((c || '').toLowerCase())
)
"
></q-select>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">{{
ticketWaveDialog.editingWaveId
? 'Update Ticket Wave'
: 'Save Ticket Wave'
}}</q-btn>
<q-btn
flat
color="grey"
class="q-ml-auto"
@click="resetTicketWaveDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<input
ref="ticketImageUpload"
type="file"
accept="image/png,image/jpeg,image/webp"
style="display: none"
@change="handleTicketImageSelected"
/>
</div>
</template>

View file

@ -3,36 +3,16 @@ window.PageEventsTicket = {
data() {
return {
ticketId: null,
ticket: null,
printMode: false,
qrSrc: ''
ticket: null
}
},
methods: {
async printWindow() {
this.printMode = true
await this.$nextTick()
await this.waitForPrintAssets()
setTimeout(() => window.print(), 50)
},
async waitForPrintAssets() {
await this.$nextTick()
const img = document.querySelector('.ticket-print-qr')
if (!img) return
if (img.complete && img.naturalWidth > 0) return
await new Promise(resolve => {
const done = () => resolve()
img.addEventListener('load', done, {once: true})
img.addEventListener('error', done, {once: true})
setTimeout(done, 500)
})
printWindow() {
window.print()
}
},
async created() {
this.ticketId = this.$route.params.id
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
`ticket://${this.ticketId}`
)}`
try {
const {data} = await LNbits.api.request(
'GET',
@ -42,8 +22,5 @@ window.PageEventsTicket = {
} catch (error) {
LNbits.utils.notifyApiError(error)
}
window.addEventListener('afterprint', () => {
this.printMode = false
})
}
}

View file

@ -36,53 +36,4 @@
</q-card>
</div>
</div>
<Teleport to="body">
<div class="ticket-print-sheet" v-if="printMode">
<img class="ticket-print-qr" :src="qrSrc" alt="Ticket QR" v-if="qrSrc" />
</div>
</Teleport>
</template>
<style>
@media print {
@page {
size: auto;
margin: 0;
}
html {
font-size: 12px !important;
}
* {
color: black !important;
background: white !important;
box-shadow: none !important;
}
body > * {
display: none !important;
}
.ticket-print-sheet {
display: flex !important;
align-items: center;
justify-content: center;
width: 100vw;
min-height: 100vh;
margin: 0 !important;
padding: 0 !important;
background: white !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.ticket-print-qr {
display: block;
width: 320px;
height: 320px;
object-fit: contain;
}
}
</style>

View file

@ -1,112 +0,0 @@
"""`amount_tickets` is the remaining count (aiolabs/events#34).
`set_ticket_paid` decrements it on every sale and `sold` increments, so
subtracting `sold` from it removes each sale twice. That made the buyer
cap under-report and, once an event passed half its capacity, turned
`sold >= amount_tickets` true and declared it sold out with stock left.
Measured on aio-demo before the fix: 16 of 24 live events affected,
3 already refusing sales while tickets remained.
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from .. import views_api
from ..models import CreateTicket, Event
SENTINEL = object()
def _event(amount_tickets: int, sold: int) -> Event:
return Event(
id="evt",
wallet="w",
name="Capacity",
info="",
closing_date="2030-01-01",
event_start_date="2030-01-01",
event_end_date="2030-01-02",
currency="sat",
price_per_ticket=0, # free path, so the guard is all that gates us
amount_tickets=amount_tickets,
sold=sold,
time=datetime.now(timezone.utc),
status="approved",
)
@pytest.fixture
def issued(monkeypatch):
"""Past the capacity guard the free path short-circuits to a sentinel."""
monkeypatch.setattr(
views_api, "_issue_free_tickets", AsyncMock(return_value=SENTINEL)
)
monkeypatch.setattr(
views_api, "_resolve_frontend_root", lambda data, req: "http://x"
)
return SENTINEL
async def _buy(amount_tickets: int, sold: int, quantity: int, monkeypatch):
monkeypatch.setattr(
views_api, "get_event", AsyncMock(return_value=_event(amount_tickets, sold))
)
return await views_api.api_ticket_create(
"evt", CreateTicket(user_id="u1", quantity=quantity), SimpleNamespace()
)
@pytest.mark.asyncio
async def test_last_ticket_still_sells(monkeypatch, issued):
"""One left, one wanted. Previously refused once sold >= remaining."""
assert await _buy(1, 99, 1, monkeypatch) is issued
@pytest.mark.asyncio
async def test_sold_out_only_when_actually_empty(monkeypatch, issued):
with pytest.raises(HTTPException) as exc:
await _buy(0, 100, 1, monkeypatch)
assert exc.value.detail == "Event is sold out."
@pytest.mark.asyncio
@pytest.mark.parametrize("sold", [0, 49, 50, 51, 500])
async def test_remaining_alone_decides_availability(monkeypatch, issued, sold):
"""The regression proper: with 50 left the event sells, whatever
`sold` says. Because remaining + sold is the original capacity,
`sold >= amount_tickets` first flips true at the halfway point —
sold=50 here — so an event locked itself once half its seats went.
The boundary cases (49/50/51) are the ones that matter."""
assert await _buy(50, sold, 1, monkeypatch) is issued
@pytest.mark.asyncio
async def test_bulk_order_may_take_everything_left(monkeypatch, issued):
assert await _buy(10, 40, 10, monkeypatch) is issued
@pytest.mark.asyncio
async def test_bulk_order_over_capacity_reports_the_true_remainder(monkeypatch, issued):
"""3 left, 5 wanted. The message must name the real remainder — it
used to say `3 - 40 = -37`. (`CreateTicket.quantity` is capped at 10
by the model, so the overshoot is tested within that bound.)"""
with pytest.raises(HTTPException) as exc:
await _buy(3, 40, 5, monkeypatch)
assert exc.value.detail == "Only 3 ticket(s) remaining for this event."
@pytest.mark.asyncio
async def test_walk_an_event_to_capacity(monkeypatch, issued):
"""50-seat event, one sale at a time, mirroring set_ticket_paid."""
remaining, sold = 50, 0
for _ in range(50):
assert await _buy(remaining, sold, 1, monkeypatch) is issued
remaining, sold = remaining - 1, sold + 1
assert (remaining, sold) == (0, 50)
with pytest.raises(HTTPException) as exc:
await _buy(remaining, sold, 1, monkeypatch)
assert exc.value.detail == "Event is sold out."

View file

@ -1,48 +0,0 @@
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

@ -1,52 +0,0 @@
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

View file

@ -1,171 +0,0 @@
"""The `nostr_publish_pending` marker and its lifecycle.
Inventory reaches clients only through the republished NIP-52 calendar
event. These tests pin the invariant that makes drift recoverable: the
flag goes up before every attempt and comes down only on a confirmed
success, so every shape of failure — raised, skipped, never attempted —
leaves the row queryable by the sweep.
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from .. import nostr_hooks, services
from ..models import Event, Ticket
def _event(**kwargs) -> Event:
defaults = {
"id": "evt",
"wallet": "w",
"name": "Test",
"info": "",
"closing_date": "2030-01-01",
"event_start_date": "2030-01-01",
"event_end_date": "2030-01-02",
"currency": "sat",
"price_per_ticket": 1000,
"amount_tickets": 10,
"time": datetime.now(timezone.utc),
"status": "approved",
}
defaults.update(kwargs)
return Event(**defaults)
@pytest.fixture
def saved(monkeypatch):
"""Capture every update_event write so ordering can be asserted."""
writes: list[bool] = []
async def _update(event):
writes.append(event.nostr_publish_pending)
return event
monkeypatch.setattr(nostr_hooks, "update_event", _update)
return writes
def _signer(monkeypatch, signer):
monkeypatch.setattr(
"lnbits.core.signers.resolve_for_wallet", AsyncMock(return_value=signer)
)
def _publisher(monkeypatch, result):
monkeypatch.setattr(
nostr_hooks, "publish_event_to_nostr", AsyncMock(return_value=result)
)
@pytest.mark.asyncio
async def test_success_raises_then_clears_the_flag(monkeypatch, saved):
event = _event()
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123))
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
# Flagged before the attempt, cleared after it — in that order.
assert saved == [True, False]
assert event.nostr_publish_pending is False
assert event.nostr_event_id == "nid"
assert event.nostr_event_created_at == 123
@pytest.mark.asyncio
async def test_missing_signer_leaves_the_flag_up(monkeypatch, saved):
event = _event()
_signer(monkeypatch, None)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
assert saved == [True]
assert event.nostr_publish_pending is True
@pytest.mark.asyncio
async def test_publisher_returning_none_leaves_the_flag_up(monkeypatch, saved):
"""The no-NostrClient shape: nothing raised, nothing published."""
event = _event()
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, None)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
assert saved == [True]
assert event.nostr_publish_pending is True
@pytest.mark.asyncio
async def test_raised_publish_leaves_the_flag_up(monkeypatch, saved):
event = _event()
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
monkeypatch.setattr(
nostr_hooks,
"publish_event_to_nostr",
AsyncMock(side_effect=RuntimeError("signer timeout")),
)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
assert saved == [True]
assert event.nostr_publish_pending is True
@pytest.mark.asyncio
async def test_already_pending_row_is_not_re_flagged(monkeypatch, saved):
"""The sweep re-publishing a flagged row writes once, not twice."""
event = _event(nostr_publish_pending=True)
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123))
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
assert saved == [False]
@pytest.mark.asyncio
async def test_delete_clears_the_flag_without_touching_the_coordinate(
monkeypatch, saved
):
"""A take-down must not overwrite the id/created_at of the event it
just deleted — the kind-5 has its own."""
event = _event(nostr_event_id="old", nostr_event_created_at=100)
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, SimpleNamespace(id="del", created_at=999))
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
assert event.nostr_publish_pending is False
assert event.nostr_event_id == "old"
assert event.nostr_event_created_at == 100
@pytest.mark.asyncio
async def test_sale_flags_the_event_in_the_same_write(monkeypatch):
"""`set_ticket_paid` must flag inside its own update, so the counters
and "the relay doesn't know yet" land atomically."""
event = _event(sold=4, amount_tickets=6)
seen: list[tuple[int, int, bool]] = []
async def _update_event(ev):
seen.append((ev.sold, ev.amount_tickets, ev.nostr_publish_pending))
return ev
monkeypatch.setattr(services, "update_ticket", AsyncMock())
monkeypatch.setattr(services, "get_event", AsyncMock(return_value=event))
monkeypatch.setattr(services, "update_event", _update_event)
monkeypatch.setattr(services, "publish_or_delete_nostr_event", AsyncMock())
ticket = Ticket(
id="t1",
wallet="w",
event="evt",
name="A",
email="a@example.com",
registered=False,
paid=False,
time=datetime.now(timezone.utc),
reg_timestamp=datetime.now(timezone.utc),
)
await services.set_ticket_paid(ticket)
assert seen == [(5, 5, True)]

View file

@ -1,32 +0,0 @@
from itertools import pairwise
from ..nostr_timestamp import monotonic_created_at
def test_no_prior_uses_now():
assert monotonic_created_at(None, now=1000) == 1000
def test_same_second_bumps_past_prior():
# now == last: a naive int(time.time()) would tie and the relay would
# drop the update; we must produce a strictly newer stamp.
assert monotonic_created_at(1000, now=1000) == 1001
def test_tracks_wallclock_once_seconds_elapse():
assert monotonic_created_at(1000, now=1005) == 1005
def test_steps_past_future_dated_prior():
# clock skew / rapid bursts left the stored value ahead of now
assert monotonic_created_at(2000, now=1000) == 2001
def test_strictly_increasing_same_second_burst():
last = None
stamps = []
for _ in range(5):
last = monotonic_created_at(last, now=1000) # clock frozen at 1000
stamps.append(last)
assert stamps == [1000, 1001, 1002, 1003, 1004]
assert all(b > a for a, b in pairwise(stamps))

View file

@ -1,218 +0,0 @@
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from ..models import (
Event,
EventExtra,
PromoCode,
PublicEvent,
Ticket,
TicketWave,
ensure_ticket_waves,
)
from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses
def _event(currency="sat", price=1000.0, codes=None) -> Event:
return Event(
id="evt",
wallet="w",
name="Test",
info="",
closing_date="2030-01-01",
event_start_date="2030-01-01",
event_end_date="2030-01-02",
currency=currency,
price_per_ticket=price,
amount_tickets=10,
time=datetime.now(timezone.utc),
extra=EventExtra(promo_codes=codes or []),
)
def _wave(event: Event) -> TicketWave:
"""Primary wave synthesized from the event's own price/currency.
These tests exercise promo arithmetic rather than wave selection, so
pricing against the primary wave keeps their original meaning.
"""
return ensure_ticket_waves(event)[0]
def _ticket(code, paid=True) -> Ticket:
now = datetime.now(timezone.utc)
return Ticket(
id=f"t-{code}-{paid}",
wallet="w",
event="evt",
registered=False,
paid=paid,
time=now,
reg_timestamp=now,
extra={"applied_promo_code": code},
)
# --- model -----------------------------------------------------------------
def test_code_is_stripped_and_uppercased():
assert PromoCode(code=" half ", discount_percent=50).code == "HALF"
def test_empty_code_is_rejected():
with pytest.raises(ValidationError):
PromoCode(code=" ", discount_percent=10)
@pytest.mark.parametrize(
"raw,expected", [(None, None), ("", None), (0, None), ("0", None), (3, 3), ("7", 7)]
)
def test_max_uses_normalisation(raw, expected):
assert PromoCode(code="X", max_uses=raw).max_uses == expected
def test_max_uses_below_one_rejected():
with pytest.raises(ValidationError):
PromoCode(code="X", max_uses=-1)
def test_discount_bounds():
with pytest.raises(ValidationError):
PromoCode(code="X", discount_percent=101)
def test_public_event_projection_drops_promo_codes():
event = _event(codes=[PromoCode(code="SECRET", discount_percent=100)])
public = PublicEvent.parse_obj(event.dict()).dict()
assert "promo_codes" not in public["extra"]
assert public["extra"]["payment_methods"] == []
# the full model keeps them
assert Event.parse_obj(event.dict()).extra.promo_codes[0].code == "SECRET"
# --- helpers ---------------------------------------------------------------
def test_normalize_code():
assert normalize_code(" save20 ") == "SAVE20"
assert normalize_code("") is None
assert normalize_code(None) is None
def test_promo_usage_counts_paid_rows_only_per_ticket():
usage = promo_usage(
[_ticket("HALF"), _ticket("HALF"), _ticket("HALF", paid=False), _ticket(None)]
)
assert usage == {"HALF": 2}
def test_remaining_uses():
assert remaining_uses(PromoCode(code="X"), 5) is None
assert remaining_uses(PromoCode(code="X", max_uses=3), 1) == 2
assert remaining_uses(PromoCode(code="X", max_uses=3), 9) == 0
# --- basket_totals -----------------------------------------------------------
def test_sat_totals_round_to_whole_sats():
event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)])
totals = basket_totals(event, ["off15"], 1, {}, _wave(event))
assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50)
assert totals.currency == "sat"
assert totals.discounts_applied[0].dict() == {
"code": "OFF15",
"discount_percent": 15,
"discount_fixed": None,
"amount_saved": 50,
}
def test_fiat_totals_round_to_cents_and_scale_by_quantity():
event = _event(
currency="EUR",
price=19.99,
codes=[PromoCode(code="THIRD", discount_percent=33)],
)
totals = basket_totals(event, ["THIRD"], 3, {}, _wave(event))
assert totals.subtotal == 59.97
assert totals.total == 40.18
assert totals.discount == 19.79
assert totals.discount + totals.total == totals.subtotal
def test_first_applicable_code_wins():
event = _event(
codes=[
PromoCode(code="A", discount_percent=10),
PromoCode(code="B", discount_percent=50),
]
)
assert (
basket_totals(event, ["NOPE", "B", "A"], 1, {}, _wave(event))
.discounts_applied[0]
.code
== "B"
)
def test_inactive_unknown_zero_and_exhausted_codes_are_absent():
event = _event(
codes=[
PromoCode(code="OLD", discount_percent=20, active=False),
PromoCode(code="ZERO", discount_percent=0),
PromoCode(code="TWO", discount_percent=50, max_uses=2),
]
)
for codes, usage, qty in (
(["OLD"], {}, 1),
(["ZERO"], {}, 1),
(["NOPE"], {}, 1),
(["TWO"], {"TWO": 2}, 1),
(["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity
):
totals = basket_totals(event, codes, qty, usage, _wave(event))
assert totals.discounts_applied == []
assert totals.total == totals.subtotal and totals.discount == 0
def test_unlimited_and_partially_used_codes_apply():
event = _event(
codes=[
PromoCode(code="TWO", discount_percent=50, max_uses=2),
PromoCode(code="INF", discount_percent=10),
]
)
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}, _wave(event)).total == 500
assert basket_totals(event, ["INF"], 10, {"INF": 999}, _wave(event)).total == 9000
def test_full_discount_prices_to_zero():
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
assert basket_totals(event, ["FREE"], 2, {}, _wave(event)).total == 0
def test_price_comes_from_the_selected_wave_not_the_event():
"""Regression guard for the v1.6.8 merge.
`sync_event_ticket_waves` makes `event.price_per_ticket` a roll-up of the
PRIMARY wave, so pricing off the event charged every buyer the first
wave's price no matter which wave they picked.
"""
event = _event(price=1000.0, codes=[PromoCode(code="HALF", discount_percent=50)])
late = TicketWave(
id="late",
title="Late",
opening_date="2030-01-01",
closing_date="2030-02-01",
currency="sat",
price_per_ticket=2500.0,
amount_tickets=10,
)
assert basket_totals(event, [], 2, {}, _wave(event)).total == 2000
assert basket_totals(event, [], 2, {}, late).total == 5000
assert basket_totals(event, ["HALF"], 2, {}, late).total == 2500

View file

@ -1,178 +0,0 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from .. import views_api
from ..models import CreateTicket, Event, EventExtra, PromoCode, PromoValidateRequest
def _event(codes) -> Event:
return Event(
id="evt",
wallet="w",
name="Test",
info="",
closing_date="2030-01-01",
event_start_date="2030-01-01",
event_end_date="2030-01-02",
currency="sat",
price_per_ticket=1000,
amount_tickets=10,
time=datetime.now(timezone.utc),
extra=EventExtra(promo_codes=codes),
status="approved",
)
@pytest.fixture
def event(monkeypatch):
ev = _event(
[
PromoCode(code="HALF", discount_percent=50, max_uses=2),
PromoCode(code="OLD", discount_percent=20, active=False),
]
)
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=ev))
monkeypatch.setattr(
views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 1})
)
return ev
@pytest.mark.asyncio
async def test_validate_returns_v2_shaped_totals(event):
totals = await views_api.api_validate_promo_codes(
"evt", PromoValidateRequest(codes=["half"], quantity=1)
)
assert totals.dict() == {
"subtotal": 1000,
"discount": 500,
"total": 500,
"currency": "sat",
"discounts_applied": [
{
"code": "HALF",
"discount_percent": 50,
"discount_fixed": None,
"amount_saved": 500,
}
],
}
@pytest.mark.asyncio
async def test_validate_is_advisory_for_bad_codes(event):
for codes, qty in ((["OLD"], 1), (["NOPE"], 1), (["HALF"], 2)):
totals = await views_api.api_validate_promo_codes(
"evt", PromoValidateRequest(codes=codes, quantity=qty)
)
assert totals.discounts_applied == [] and totals.total == totals.subtotal
@pytest.mark.asyncio
async def test_validate_unknown_event_is_404(monkeypatch):
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=None))
with pytest.raises(HTTPException) as exc:
await views_api.api_validate_promo_codes(
"nope", PromoValidateRequest(codes=["X"])
)
assert exc.value.status_code == 404
@pytest.mark.asyncio
@pytest.mark.parametrize(
"code,quantity,detail",
[
("NOPE", 1, "Invalid promo code."),
("old", 1, "Promo code is not active."),
("HALF", 2, "Only 1 use(s) left on this promo code."),
],
)
async def test_purchase_rejects_bad_codes_before_any_invoice(
event, monkeypatch, code, quantity, detail
):
monkeypatch.setattr(
views_api,
"create_payment_request",
AsyncMock(side_effect=AssertionError("must not be called")),
)
data = CreateTicket(user_id="u1", promo_code=code, quantity=quantity)
with pytest.raises(HTTPException) as exc:
await views_api.api_ticket_create(
"evt", data, SimpleNamespace(base_url="http://lnbits.local/")
)
assert exc.value.status_code == 400
assert exc.value.detail == detail
@pytest.mark.asyncio
async def test_purchase_reports_fully_redeemed(event, monkeypatch):
monkeypatch.setattr(
views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 2})
)
monkeypatch.setattr(
views_api,
"create_payment_request",
AsyncMock(side_effect=AssertionError("must not be called")),
)
with pytest.raises(HTTPException) as exc:
await views_api.api_ticket_create(
"evt",
CreateTicket(user_id="u1", promo_code="HALF"),
SimpleNamespace(base_url="http://lnbits.local/"),
)
assert exc.value.detail == "Promo code has been fully redeemed."
@pytest.fixture
def update_env(monkeypatch):
stored = _event([PromoCode(code="KEEP", discount_percent=10)])
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=stored))
monkeypatch.setattr(
views_api,
"get_settings",
AsyncMock(return_value=SimpleNamespace(auto_approve=True)),
)
monkeypatch.setattr(views_api, "update_event", AsyncMock(side_effect=lambda e: e))
monkeypatch.setattr(views_api, "publish_or_delete_nostr_event", AsyncMock())
return stored
def _update_payload(extra: dict) -> dict:
return {
"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": 1000,
"extra": extra,
}
def _wallet():
return SimpleNamespace(wallet=SimpleNamespace(id="w", user="u1"))
@pytest.mark.asyncio
async def test_update_without_promo_key_keeps_stored_codes(update_env):
from ..models import CreateEvent
data = CreateEvent.parse_obj(_update_payload({"email_notifications": True}))
event = await views_api.api_event_update("evt", data, _wallet())
assert [pc.code for pc in event.extra.promo_codes] == ["KEEP"]
assert event.extra.email_notifications is True
@pytest.mark.asyncio
async def test_update_with_empty_promo_list_clears(update_env):
from ..models import CreateEvent
data = CreateEvent.parse_obj(_update_payload({"promo_codes": []}))
event = await views_api.api_event_update("evt", data, _wallet())
assert event.extra.promo_codes == []

View file

@ -1,195 +0,0 @@
"""The NIP-52 tags describe the ACTIVE ticket wave, not the roll-up (#61).
Upstream v1.6.8 moved price, currency and inventory onto time-boxed waves
and made the event-level fields derived: `price_per_ticket` and `currency`
become the PRIMARY (first) wave's, `amount_tickets` the SUM across every
wave. Publishing those would advertise the early-bird price after early
bird closed and count stock in waves that have not opened yet.
"""
from datetime import datetime, timedelta, timezone
from typing import cast
import pytest
from ..models import (
Event,
EventExtra,
TicketWave,
advertised_wave_key,
sync_event_ticket_waves,
)
from ..nostr_publisher import build_nip52_event
PUBKEY = "a" * 64
TODAY = datetime.now(timezone.utc).date()
def _day(offset: int) -> str:
return (TODAY + timedelta(days=offset)).isoformat()
def _wave(
wave_id: str,
price: float,
stock: int,
opens: int,
closes: int,
*,
currency: str = "EUR",
allow_fiat: bool = False,
) -> TicketWave:
return TicketWave(
id=wave_id,
title=wave_id,
opening_date=_day(opens),
closing_date=_day(closes),
currency=currency,
price_per_ticket=price,
amount_tickets=stock,
allow_fiat=allow_fiat,
fiat_currency="GBP",
)
def _event(waves: list[TicketWave], sold: int = 0) -> Event:
event = Event(
id="evt",
wallet="w",
name="Waves",
info="",
closing_date=_day(30),
event_start_date=_day(30),
event_end_date=_day(30),
currency="sat",
price_per_ticket=0,
amount_tickets=0,
sold=sold,
time=datetime.now(timezone.utc),
status="approved",
extra=EventExtra(ticket_waves=waves),
)
# Mirrors the CRUD layer, which syncs on every read and write.
return cast(Event, sync_event_ticket_waves(event))
def _tags(event: Event) -> dict[str, str]:
return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1}
CLOSED_EARLY_BIRD = _wave("early", 10.0, 5, -10, -1)
OPEN_REGULAR = _wave("regular", 25.0, 40, 0, 20)
UNOPENED_VIP = _wave("vip", 50.0, 10, 5, 25)
def test_closed_wave_price_is_not_advertised():
"""The defect this fixes: early bird closed yesterday."""
event = _event([CLOSED_EARLY_BIRD, OPEN_REGULAR, UNOPENED_VIP])
# What the event-level roll-up would have published.
assert event.price_per_ticket == 10.0
assert event.amount_tickets == 55
tags = _tags(event)
assert tags["tickets_price"] == "25.0"
assert tags["tickets_available"] == "40"
def test_unopened_wave_stock_is_not_counted():
event = _event([OPEN_REGULAR, UNOPENED_VIP])
assert _tags(event)["tickets_available"] == "40"
def test_cheapest_open_wave_wins_when_several_are_open():
"""A publisher cannot ask which wave the buyer wants, so it advertises
the price a buyer is actually able to obtain."""
cheaper = _wave("cheap", 15.0, 3, 0, 10)
tags = _tags(_event([OPEN_REGULAR, cheaper]))
assert tags["tickets_price"] == "15.0"
assert tags["tickets_available"] == "3"
@pytest.mark.parametrize(
"waves",
[
pytest.param([CLOSED_EARLY_BIRD], id="all-closed"),
pytest.param([UNOPENED_VIP], id="not-yet-open"),
pytest.param([_wave("only", 25.0, 0, 0, 20)], id="sold-out"),
],
)
def test_no_open_wave_publishes_zero_availability(waves):
"""Never omit the tag: omission meant "unlimited" (#34, #62)."""
tags = _tags(_event(waves))
assert tags["tickets_available"] == "0"
def test_currency_and_fiat_follow_the_advertised_wave():
fiat_primary = _wave("a", 10.0, 0, -10, -1, currency="GBP", allow_fiat=True)
sats_open = _wave("b", 2500.0, 9, 0, 20, currency="sat", allow_fiat=False)
tags = _tags(_event([fiat_primary, sats_open]))
assert tags["tickets_currency"] == "sat"
assert "tickets_allow_fiat" not in tags
# Must agree with tickets_allow_fiat — they are the same fact, and a
# client rendering a fiat button here would hit a purchase-time refusal.
assert tags["tickets_payment_methods"] == "lightning"
def test_payment_methods_offer_fiat_when_the_advertised_wave_accepts_it():
tags = _tags(
_event(
[
_wave("a", 10.0, 0, -10, -1, allow_fiat=False),
_wave("b", 25.0, 9, 0, 20, allow_fiat=True),
]
)
)
assert tags["tickets_allow_fiat"] == "true"
assert tags["tickets_payment_methods"] == "lightning,fiat"
def test_sold_stays_event_level():
"""`tickets_sold` is the total paid across all waves."""
assert _tags(_event([OPEN_REGULAR], sold=7))["tickets_sold"] == "7"
def test_advertised_wave_key_tracks_the_published_wave():
assert advertised_wave_key(_event([CLOSED_EARLY_BIRD, OPEN_REGULAR])) == "regular"
# Published while nothing is on sale — a real state, distinct from the
# NULL that means "never published".
assert advertised_wave_key(_event([CLOSED_EARLY_BIRD])) == ""
def test_published_rails_drop_fiat_when_the_advertised_wave_cannot_take_it():
"""The webapp always sets `extra.payment_methods`, so the explicit-list
path is the normal one — and it used to ignore the wave entirely.
That published `tickets_payment_methods: lightning,fiat` beside an
absent `tickets_allow_fiat`, and a card button the purchase endpoint
then refused ("Fiat payments are not enabled for this ticket wave").
"""
event = _event(
[
_wave("a", 10.0, 0, -10, -1, allow_fiat=True),
_wave("b", 25.0, 9, 0, 20, allow_fiat=False),
]
)
event.extra.payment_methods = ["lightning", "fiat"]
tags = _tags(event)
assert "tickets_allow_fiat" not in tags
assert tags["tickets_payment_methods"] == "lightning"
def test_published_rails_keep_fiat_when_the_advertised_wave_takes_it():
event = _event(
[
_wave("a", 10.0, 0, -10, -1, allow_fiat=False),
_wave("b", 25.0, 9, 0, 20, allow_fiat=True),
]
)
event.extra.payment_methods = ["lightning", "fiat"]
tags = _tags(event)
assert tags["tickets_allow_fiat"] == "true"
assert tags["tickets_payment_methods"] == "lightning,fiat"

View file

@ -1,56 +0,0 @@
"""`tickets_available` is always published, including zero (#34).
Omitting the tag used to mean "unlimited", which contradicted every other
reader: `api_get_event` and `api_ticket_create` both treat
`amount_tickets < 1` as sold out. On aio-demo three zero-capacity events
advertised "Unlimited tickets" on the card while the detail endpoint and
the purchase both returned 410.
"""
from datetime import datetime, timezone
import pytest
from ..models import Event
from ..nostr_publisher import build_nip52_event
PUBKEY = "a" * 64
def _event(amount_tickets: int, sold: int = 0) -> Event:
return Event(
id="evt",
wallet="w",
name="Availability",
info="",
closing_date="2030-01-01",
event_start_date="2030-01-01T18:00",
event_end_date="2030-01-01T22:00",
currency="sat",
price_per_ticket=0,
amount_tickets=amount_tickets,
sold=sold,
time=datetime.now(timezone.utc),
status="approved",
)
def _tags(event: Event) -> dict[str, str]:
return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1}
@pytest.mark.parametrize("amount", [0, 1, 50])
def test_tickets_available_is_always_present(amount):
assert _tags(_event(amount))["tickets_available"] == str(amount)
def test_zero_capacity_reads_as_sold_out_not_unlimited():
"""The regression: an absent tag is what clients render as unlimited."""
tags = _tags(_event(0, sold=0))
assert "tickets_available" in tags
assert tags["tickets_available"] == "0"
def test_sold_out_after_selling_through_still_publishes_zero():
assert _tags(_event(0, sold=25))["tickets_available"] == "0"
assert _tags(_event(0, sold=25))["tickets_sold"] == "25"

View file

@ -1,105 +0,0 @@
"""Publish confirmation against the relay's `OK` (aiolabs/events#56).
Queueing is not delivery. nostrclient drops an EVENT outright when no
relay is connected and answers `OK false`; before this, that reply was
discarded and the publish reported success, which once cleared the
`nostr_publish_pending` flag on a republish that never left the
building.
"""
import asyncio
import json
import pytest
from ..nostr import nostr_client as nc
from ..nostr.event import NostrEvent
def _event(event_id: str = "a" * 64) -> NostrEvent:
e = NostrEvent(pubkey="b" * 64, created_at=0, kind=31923)
e.id = event_id
return e
async def _publish_and_reply(client, event, reply, delay=0.01):
"""Start a publish, then feed `reply` through the receive path."""
task = asyncio.create_task(client.publish_nostr_event(event))
await asyncio.sleep(delay) # let the future register
if reply is not None:
client.receive_event_queue.put_nowait(reply)
consumer = asyncio.create_task(client.get_event())
await asyncio.sleep(delay)
consumer.cancel()
return await task
@pytest.mark.asyncio
async def test_accepted_publish_returns_true():
client = nc.NostrClient()
event = _event()
ok = json.dumps(["OK", event.id, True, ""])
assert await _publish_and_reply(client, event, ok) is True
assert client._pending_oks == {}
@pytest.mark.asyncio
async def test_rejected_publish_returns_false():
"""The shape that bit us: no relay connected, so nostrclient's
router answers `OK false` without the event ever being sent."""
client = nc.NostrClient()
event = _event()
ok = json.dumps(["OK", event.id, False, "error: no relays connected"])
assert await _publish_and_reply(client, event, ok) is False
assert client._pending_oks == {}
@pytest.mark.asyncio
async def test_missing_ok_times_out_as_unconfirmed(monkeypatch):
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
client = nc.NostrClient()
assert await _publish_and_reply(client, _event(), None) is False
assert client._pending_oks == {}
@pytest.mark.asyncio
async def test_ok_for_a_different_event_does_not_settle_ours(monkeypatch):
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
client = nc.NostrClient()
other = json.dumps(["OK", "c" * 64, True, ""])
assert await _publish_and_reply(client, _event(), other) is False
@pytest.mark.asyncio
async def test_get_event_swallows_ok_and_forwards_everything_else():
client = nc.NostrClient()
client.receive_event_queue.put_nowait(json.dumps(["OK", "d" * 64, True, ""]))
forwarded = json.dumps(["EVENT", "sub", {"id": "e" * 64}])
client.receive_event_queue.put_nowait(forwarded)
assert await client.get_event() == forwarded
@pytest.mark.asyncio
async def test_disconnect_settles_inflight_publishes_immediately(monkeypatch):
"""A dropped socket must not leave the caller waiting the full
timeout for an OK that can no longer arrive."""
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 30)
client = nc.NostrClient()
event = _event()
task = asyncio.create_task(client.publish_nostr_event(event))
await asyncio.sleep(0.01)
client.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
consumer = asyncio.create_task(client.get_event())
await asyncio.sleep(0.01)
consumer.cancel()
assert await asyncio.wait_for(task, 1) is False
def test_settle_ok_ignores_non_ok_frames():
client = nc.NostrClient()
assert client._settle_ok(json.dumps(["EVENT", "sub", {}])) is False
assert client._settle_ok(json.dumps(["EOSE", "sub"])) is False
assert client._settle_ok("not json") is False
assert client._settle_ok(json.dumps(["OK", "f" * 64, True, ""])) is True

View file

@ -1,89 +0,0 @@
from datetime import datetime, timezone
from lnbits.settings import settings
from ..models import Event, Ticket
from ..services import _ticket_notification_payload, build_ticket_email
def _event(**overrides) -> Event:
data = {
"id": "evt1",
"wallet": "w",
"name": "Test Event",
"info": "",
"closing_date": "2030-01-01",
"event_start_date": "2030-01-01T16:00:00+01:00",
"event_end_date": "2030-01-01T20:00:00+01:00",
"location": "The Chateau",
"amount_tickets": 10,
"price_per_ticket": 5,
"time": datetime.now(timezone.utc),
}
data.update(overrides)
return Event(**data)
def _ticket(**overrides) -> Ticket:
now = datetime.now(timezone.utc)
data = {
"id": "tkt1",
"wallet": "w",
"event": "evt1",
"name": "Ada",
"email": "ada@example.com",
"registered": False,
"paid": True,
"time": now,
"reg_timestamp": now,
}
data.update(overrides)
return Ticket(**data)
def test_email_carries_date_message_id_and_display_name(monkeypatch):
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
msg = build_ticket_email(
"tickets@example.org", ["ada@example.com"], "Subj", "text", "<p>html</p>"
)
assert msg["Date"]
assert msg["Message-ID"].endswith("@example.org>")
assert msg["From"] == "Oyez! <tickets@example.org>"
parts = [p.get_content_type() for p in msg.get_payload()]
assert parts == ["text/plain", "text/html"]
def test_email_attaches_the_ticket_card(monkeypatch):
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
png = b"\x89PNG\r\n\x1a\n" + b"0" * 32
msg = build_ticket_email(
"tickets@example.org",
["ada@example.com"],
"Subj",
"text",
"<p>html</p>",
attachments=[("ticket-test-8auNuuaB.png", png)],
)
assert msg.get_content_type() == "multipart/mixed"
body, attachment = msg.get_payload()
assert body.get_content_type() == "multipart/alternative"
assert attachment.get_content_type() == "image/png"
assert attachment.get_filename() == "ticket-test-8auNuuaB.png"
assert attachment.get_payload(decode=True) == png
def test_payload_includes_event_details_and_qr(monkeypatch):
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
subject, text, html = _ticket_notification_payload(_ticket(), _event())
assert "Test Event" in subject
for needle in (
"Event: Test Event",
"Where: The Chateau",
"Name on ticket: Ada",
"Ticket ID: tkt1",
"at the door",
):
assert needle in text
assert "/events/api/v1/ticket-card/tkt1" in text
assert "<img" not in html and "Ticket ID: tkt1" in html
assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html

View file

@ -1,219 +0,0 @@
import pytest
from pydantic import ValidationError
from ..models import (
CreateEvent,
CreateTicket,
EventExtra,
PromoCode,
PublicEvent,
PublicEventExtra,
TicketWave,
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"])
# --- public projection ------------------------------------------------------
def test_public_extra_exposes_waves_but_never_promo_codes():
"""A buyer needs a wave id to choose a tier, so waves are public; promo
codes stay organizer-only (aiolabs/events#61, v1.6.1-aio.12)."""
organizer = EventExtra(
promo_codes=[PromoCode(code="SECRET", discount_percent=50)],
ticket_waves=[
TicketWave(
id="early",
title="Early",
opening_date="2030-01-01",
closing_date="2030-02-01",
currency="sat",
price_per_ticket=10,
amount_tickets=5,
)
],
)
public = PublicEventExtra(**organizer.dict())
assert [wave.id for wave in public.ticket_waves] == ["early"]
assert public.ticket_waves[0].price_per_ticket == 10
assert not hasattr(public, "promo_codes")
assert "promo_codes" not in public.dict()
def test_public_event_response_carries_waves():
"""End of the chain: what `GET /events/{id}` actually serialises."""
wave = TicketWave(
id="regular",
title="Regular",
opening_date="2030-01-01",
closing_date="2030-02-01",
currency="sat",
price_per_ticket=25,
amount_tickets=40,
)
organizer_extra = EventExtra(
ticket_waves=[wave], promo_codes=[PromoCode(code="SECRET")]
)
public = PublicEvent(
id="evt",
name="Test",
info="",
canceled=False,
event_start_date="2030-01-01",
currency="sat",
price_per_ticket=25,
banner=None,
extra=PublicEventExtra(**organizer_extra.dict()),
)
body = public.dict()
assert [w["id"] for w in body["extra"]["ticket_waves"]] == ["regular"]
assert "promo_codes" not in body["extra"]
# --- rails vs per-wave fiat --------------------------------------------------
def _fiat_wave(allow_fiat: bool):
from ..models import TicketWave
return TicketWave(
id="w",
title="w",
opening_date="2030-01-01",
closing_date="2030-02-01",
currency="EUR",
price_per_ticket=10,
amount_tickets=5,
allow_fiat=allow_fiat,
)
def test_explicit_rails_drop_fiat_for_a_wave_that_cannot_take_it():
"""The organiser's rail list is event-level; fiat is per-wave.
Without this the NIP-52 tag advertises fiat and the checkout renders a
card button that `api_ticket_create` refuses with "Fiat payments are
not enabled for this ticket wave" (reported on aio-demo).
"""
event = _event()
event.extra.payment_methods = ["lightning", "fiat"]
assert effective_payment_methods(event, _fiat_wave(True)) == ["lightning", "fiat"]
assert effective_payment_methods(event, _fiat_wave(False)) == ["lightning"]
def test_event_level_question_still_reports_every_rail():
"""No wave means "what did the organiser enable at all" — unfiltered."""
event = _event()
event.extra.payment_methods = ["lightning", "fiat"]
assert effective_payment_methods(event) == ["lightning", "fiat"]
def test_fiat_only_rails_on_a_non_fiat_wave_leave_nothing_purchasable():
event = _event()
event.extra.payment_methods = ["fiat"]
assert effective_payment_methods(event, _fiat_wave(False)) == []

View file

@ -1,64 +0,0 @@
from io import BytesIO
from PIL import Image
from ..qr 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")
def test_render_ticket_card_is_self_describing():
from datetime import datetime, timezone
from ..models import Event, Ticket
from ..qr import format_event_when, render_ticket_card, ticket_card_filename
now = datetime.now(timezone.utc)
event = Event(
id="evt1",
wallet="w",
name="Château du Faune | Uru Ecstatic Dance",
info="",
closing_date="2027-02-19",
event_start_date="2027-02-19T16:00:00+01:00",
event_end_date="2027-02-19T20:00:00+01:00",
location="The Chateau",
amount_tickets=10,
price_per_ticket=15,
time=now,
)
ticket = Ticket(
id="8auNuuaBv7TFGj7BYoVnTN",
wallet="w",
event="evt1",
name="Guest Test",
email="g@example.com",
registered=False,
paid=True,
time=now,
reg_timestamp=now,
)
assert format_event_when(event) == "Fri 19 Feb 2027, 16:00 - 20:00"
assert (
ticket_card_filename(ticket, event)
== "ticket-ch-teau-du-faune-uru-ecstatic-dance-8auNuuaB.png"
)
card = render_ticket_card(ticket, event, site_title="Oyez!")
assert card.width == 800 and card.height > 800
# QR area is centred; its finder pattern is black
assert card.getpixel((400, card.height // 2)) in ((0, 0, 0), (255, 255, 255))

View file

@ -1,118 +0,0 @@
"""Capacity is required per ticket wave (#34, #62).
"Capacity is always required, there is no unlimited" was settled when
capacity was one number on the event. Since v1.6.8 it is per-wave and
`event.amount_tickets` is a derived roll-up, so the rule has to bite where
the organiser sets it. A zero-capacity wave can never be active
(`get_active_ticket_waves` requires `> 0`), so it is the wave-level form of
the trap #62 removed: an event that looks on sale but refuses every
purchase.
"""
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
import pytest
from fastapi import HTTPException
from ..models import CreateEvent, Event, EventExtra, TicketWave
from ..views_api import _validate_wave_capacity
TODAY = datetime.now(timezone.utc).date()
def _day(offset: int) -> str:
return (TODAY + timedelta(days=offset)).isoformat()
def _wave(wave_id: str, stock: int) -> TicketWave:
return TicketWave(
id=wave_id,
title=wave_id,
opening_date=_day(0),
closing_date=_day(20),
currency="sat",
price_per_ticket=10,
amount_tickets=stock,
)
def _create(waves=None, amount_tickets=10) -> CreateEvent:
return CreateEvent(
wallet="w",
name="Fete",
info="",
event_start_date=_day(30),
currency="sat",
price_per_ticket=10,
amount_tickets=amount_tickets,
extra=EventExtra(ticket_waves=waves or []),
)
def _stored(waves) -> Event:
return Event(
id="evt",
wallet="w",
name="Fete",
info="",
closing_date=_day(30),
event_start_date=_day(30),
currency="sat",
price_per_ticket=10,
amount_tickets=sum(w.amount_tickets for w in waves),
time=datetime.now(timezone.utc),
extra=EventExtra(ticket_waves=waves),
)
def _detail(exc_info) -> str:
assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST
return exc_info.value.detail
def test_wave_with_capacity_is_accepted():
_validate_wave_capacity(_create([_wave("early", 5), _wave("late", 10)]))
def test_zero_capacity_wave_is_rejected_on_create():
with pytest.raises(HTTPException) as exc_info:
_validate_wave_capacity(_create([_wave("early", 5), _wave("late", 0)]))
assert "late" in _detail(exc_info)
def test_event_submitted_without_waves_is_checked_through_its_primary_wave():
"""No waves means the event gets a synthesized primary wave seeded from
`amount_tickets`, so the rule must see that rather than an empty list."""
_validate_wave_capacity(_create(waves=None, amount_tickets=10))
with pytest.raises(HTTPException) as exc_info:
_validate_wave_capacity(_create(waves=None, amount_tickets=0))
assert "Primary wave" in _detail(exc_info)
def test_selling_a_wave_out_does_not_block_later_edits():
"""Zero is where a wave legitimately ends up. Rejecting it on edit would
make a sold-out event uneditable."""
sold_out = _wave("early", 0)
existing = _stored([sold_out, _wave("late", 10)])
_validate_wave_capacity(_create([sold_out, _wave("late", 10)]), existing)
def test_new_wave_added_by_an_edit_still_needs_capacity():
existing = _stored([_wave("early", 5)])
with pytest.raises(HTTPException) as exc_info:
_validate_wave_capacity(
_create([_wave("early", 5), _wave("brand-new", 0)]), existing
)
assert "brand-new" in _detail(exc_info)
def test_legacy_zero_capacity_event_stays_editable():
"""An event stored before the rule existed keeps its primary wave id, so
an edit is not blocked — otherwise the only way to fix it would be
barred."""
existing = _stored([_wave("primary", 0)])
_validate_wave_capacity(_create([_wave("primary", 0)]), existing)

View file

@ -1,124 +0,0 @@
"""Editing an event must not destroy its ticket waves.
`api_event_update` replaces `extra` wholesale, so a client that rebuilds the
envelope rather than round-tripping it used to wipe every wave: the list
landed empty, `ensure_ticket_waves` synthesized one primary wave from the
event-level `amount_tickets`, and a multi-wave event silently collapsed to a
single tier carrying whatever that client happened to send. Same hazard the
`promo_codes` guard already covered.
"""
from datetime import datetime, timedelta, timezone
import pytest
from ..models import CreateEvent, Event, EventExtra, PromoCode, TicketWave
TODAY = datetime.now(timezone.utc).date()
def _day(offset: int) -> str:
return (TODAY + timedelta(days=offset)).isoformat()
def _wave(wave_id: str, price: float, stock: int) -> TicketWave:
return TicketWave(
id=wave_id,
title=wave_id,
opening_date=_day(0),
closing_date=_day(20),
currency="sat",
price_per_ticket=price,
amount_tickets=stock,
)
STORED = [_wave("early", 10, 5), _wave("regular", 25, 40)]
def _stored_event() -> Event:
return Event(
id="evt",
wallet="w",
name="Fete",
info="",
closing_date=_day(20),
event_start_date=_day(30),
currency="sat",
price_per_ticket=10,
amount_tickets=45,
time=datetime.now(timezone.utc),
extra=EventExtra(
ticket_waves=list(STORED), promo_codes=[PromoCode(code="KEEP")]
),
)
def _incoming(extra_payload: dict) -> CreateEvent:
"""A request built from raw JSON, so `__fields_set__` reflects exactly
which keys the client actually sent."""
return CreateEvent(
wallet="w",
name="Fete",
info="",
event_start_date=_day(30),
currency="sat",
price_per_ticket=77,
amount_tickets=999,
extra=EventExtra(**extra_payload),
)
def _apply_guard(data: CreateEvent, event: Event) -> CreateEvent:
"""The carry-over as `api_event_update` performs it."""
if "ticket_waves" not in data.extra.__fields_set__:
data.extra.ticket_waves = event.extra.ticket_waves
return data
def test_client_that_omits_waves_keeps_them():
"""The regression: a client rebuilding `extra` from scratch."""
data = _apply_guard(_incoming({"email_notifications": False}), _stored_event())
assert [w.id for w in data.extra.ticket_waves] == ["early", "regular"]
assert [w.price_per_ticket for w in data.extra.ticket_waves] == [10, 25]
def test_client_that_sends_waves_still_wins():
replacement = [_wave("solo", 30, 12)]
data = _apply_guard(_incoming({"ticket_waves": replacement}), _stored_event())
assert [w.id for w in data.extra.ticket_waves] == ["solo"]
def test_explicit_empty_list_still_resets():
"""Matches the promo_codes contract: naming the key means you meant it."""
data = _apply_guard(_incoming({"ticket_waves": []}), _stored_event())
assert data.extra.ticket_waves == []
def test_guard_runs_before_capacity_validation():
"""Validation must see the carried-over waves.
Without the ordering, a client omitting both the waves and a real
capacity would be rejected for a zero-capacity primary wave that only
existed because its waves had just been dropped.
"""
from ..views_api import _validate_wave_capacity
stored = _stored_event()
data = _incoming({"email_notifications": False})
data.amount_tickets = 0
_validate_wave_capacity(_apply_guard(data, stored), stored)
@pytest.mark.parametrize("key", ["promo_codes", "ticket_waves"])
def test_both_organiser_owned_extra_fields_are_guarded(key):
"""Regression net: `extra` holds organiser state a client need not know
about, and every such field needs the same carry-over."""
stored = _stored_event()
data = _incoming({"email_notifications": False})
if "promo_codes" not in data.extra.__fields_set__:
data.extra.promo_codes = stored.extra.promo_codes
_apply_guard(data, stored)
assert getattr(data.extra, key), f"{key} was dropped on edit"

View file

@ -1,159 +0,0 @@
"""Wave boundaries trigger a republish (#61).
Every republish this extension performs is sale-driven. A wave boundary is
a *date* boundary, so when early bird closes at midnight nothing fires and
the relay keeps serving the closed wave's price until the next ticket
happens to sell. `flag_wave_transitions` closes that gap by comparing the
wave a row would advertise now against the one its last successful publish
did, handing the work to the existing reconciliation sweep.
"""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from .. import crud, nostr_hooks
from ..models import Event, EventExtra, TicketWave
TODAY = datetime.now(timezone.utc).date()
def _day(offset: int) -> str:
return (TODAY + timedelta(days=offset)).isoformat()
def _wave(wave_id: str, price: float, stock: int, opens: int, closes: int):
return TicketWave(
id=wave_id,
title=wave_id,
opening_date=_day(opens),
closing_date=_day(closes),
currency="EUR",
price_per_ticket=price,
amount_tickets=stock,
)
def _event(waves, published_wave_id, pending=False) -> Event:
return Event(
id="evt",
wallet="w",
name="Waves",
info="",
closing_date=_day(30),
event_start_date=_day(30),
event_end_date=_day(30),
currency="sat",
price_per_ticket=0,
amount_tickets=0,
time=datetime.now(timezone.utc),
status="approved",
nostr_publish_pending=pending,
nostr_published_wave_id=published_wave_id,
extra=EventExtra(ticket_waves=waves),
)
@pytest.fixture
def swept(monkeypatch):
"""Capture rows the detector writes back."""
written: list[Event] = []
async def _update(event):
written.append(event)
return event
monkeypatch.setattr(crud, "update_event", _update)
return written
def _rows(monkeypatch, events):
monkeypatch.setattr(crud.db, "fetchall", AsyncMock(return_value=events))
CLOSED = _wave("early", 10.0, 5, -10, -1)
OPEN = _wave("regular", 25.0, 40, 0, 20)
@pytest.mark.asyncio
async def test_moved_wave_is_flagged(monkeypatch, swept):
"""Early bird closed; the relay still advertises it."""
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="early")])
assert await crud.flag_wave_transitions() == 1
assert [e.nostr_publish_pending for e in swept] == [True]
@pytest.mark.asyncio
async def test_unchanged_wave_is_left_alone(monkeypatch, swept):
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="regular")])
assert await crud.flag_wave_transitions() == 0
assert swept == []
@pytest.mark.asyncio
async def test_selling_out_the_open_wave_is_a_transition(monkeypatch, swept):
"""No wave open is itself an advertisable state, and a different one."""
sold_out = _wave("regular", 25.0, 0, 0, 20)
_rows(monkeypatch, [_event([sold_out], published_wave_id="regular")])
assert await crud.flag_wave_transitions() == 1
@pytest.mark.asyncio
async def test_already_advertising_nothing_is_not_reflagged(monkeypatch, swept):
""" "" means "published while nothing was on sale" — not drift."""
_rows(monkeypatch, [_event([CLOSED], published_wave_id="")])
assert await crud.flag_wave_transitions() == 0
assert swept == []
@pytest.mark.asyncio
async def test_never_published_rows_are_skipped(monkeypatch, swept):
"""NULL is no evidence of drift. Flagging these would republish the
whole table on the first boot after the upgrade."""
_rows(monkeypatch, []) # the SQL filters them out
assert await crud.flag_wave_transitions() == 0
@pytest.mark.asyncio
async def test_publish_records_the_advertised_wave(monkeypatch):
"""The sweep can only detect drift if a success writes the wave down."""
event = _event([CLOSED, OPEN], published_wave_id="early")
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
monkeypatch.setattr(
"lnbits.core.signers.resolve_for_wallet",
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
)
monkeypatch.setattr(
nostr_hooks,
"publish_event_to_nostr",
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
assert event.nostr_published_wave_id == "regular"
@pytest.mark.asyncio
async def test_delete_does_not_record_a_wave(monkeypatch):
"""A takedown advertises nothing, so it must not claim a wave."""
event = _event([CLOSED, OPEN], published_wave_id="early")
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
monkeypatch.setattr(
"lnbits.core.signers.resolve_for_wallet",
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
)
monkeypatch.setattr(
nostr_hooks,
"publish_event_to_nostr",
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
)
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
assert event.nostr_published_wave_id == "early"

View file

@ -1,10 +1,7 @@
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
from fastapi import (
APIRouter,
@ -15,22 +12,18 @@ from fastapi import (
WebSocket,
WebSocketDisconnect,
)
from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user
from lnbits.core.crud.assets import get_public_asset
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.db import Filters, Page
from lnbits.helpers import urlsafe_short_hash
from lnbits.decorators import (
check_admin,
check_user_exists,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
@ -38,7 +31,6 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat,
)
from lnbits.utils.nostr import normalize_public_key
from PIL import Image
from .crud import (
create_event,
@ -55,66 +47,29 @@ from .crud import (
get_settings,
get_ticket,
get_tickets,
get_tickets_by_event,
get_tickets_by_payment_hash,
get_tickets_by_user_id,
get_tickets_paginated,
purge_unpaid_tickets,
update_event,
update_settings,
update_ticket,
)
from .models import (
BasketTotals,
CreateEvent,
CreateTicket,
Event,
EventsSettings,
PromoValidateRequest,
PublicEvent,
PublicTicket,
Ticket,
TicketFilters,
TicketPaymentRequest,
TicketResendResult,
TicketWave,
effective_payment_methods,
ensure_ticket_waves,
get_active_ticket_waves,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .promo import (
basket_totals,
find_promo,
normalize_code,
remaining_uses,
round_amount,
)
from .qr import (
image_png_bytes,
load_qr_logo,
make_qr_png,
render_ticket_card,
ticket_card_filename,
)
from .services import (
event_promo_usage,
hydrate_promo_usage,
refund_tickets,
resend_ticket_email_notification,
send_ticket_notification_in_background,
set_ticket_paid,
)
from .tasks import (
deregister_payment_listener,
register_payment_listener,
)
from .services import refund_tickets, resend_ticket_email_notification
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")
promo_api_router = APIRouter(prefix="/api/v1/promo")
tickets_filters = parse_filters(TicketFilters)
def _is_fiat_currency(currency: str | None) -> bool:
@ -123,6 +78,8 @@ def _is_fiat_currency(currency: str | None) -> bool:
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
@events_api_router.get("")
async def api_events(
all_wallets: bool = Query(False),
@ -132,17 +89,12 @@ async def api_events(
if all_wallets:
user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else []
events = await get_events(wallet_ids)
for event in events:
await hydrate_promo_usage(event)
return events
return await get_events(wallet_ids)
@events_api_router.get("/public", response_model=list[PublicEvent])
@events_api_router.get("/public")
async def api_events_public() -> list[Event]:
"""Approved, non-canceled events for an anonymous public listing.
Projected through `PublicEvent`: no wallet id, no promo codes."""
"""Approved, non-canceled events for an anonymous public listing."""
return await get_public_events()
@ -160,7 +112,6 @@ async def api_events_all(
events = await get_all_events()
enriched: list[dict] = []
for event in events:
await hydrate_promo_usage(event)
wallet = await get_wallet(event.wallet)
row = event.dict()
row["wallet_user_id"] = wallet.user if wallet else None
@ -285,80 +236,33 @@ async def api_get_event(event_id: str) -> Event:
closing_date = event.closing_date or event.event_end_date or event.event_start_date
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
# may carry a time component since v1.3.0-aio.3 / our start-end-time
# feature). Upstream v1.6.8 parses closing_date with a bare
# strptime("%Y-%m-%d") here; that raises ValueError on any event of ours
# whose closing date carries a time, which is most of them.
# feature).
try:
closing_dt = datetime.fromisoformat(closing_date)
except ValueError:
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
if closing_dt.tzinfo is None:
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
today = now.date()
active_waves = get_active_ticket_waves(event, today)
# `is_sales_closed` is upstream's name for `not is_window_open`; the
# comparison stays ours (instant-precise) rather than upstream's
# whole-day `today > closing_date.date()`. Upstream's form keeps sales
# open for the whole of the closing day, which for our datetime-bearing
# closing dates would extend every existing event's sales window.
is_sales_closed = now >= closing_dt
is_window_open = datetime.now(timezone.utc) < closing_dt
is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True
)
if event.amount_tickets < 1:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if event.extra.conditional and not is_min_tickets_met and is_sales_closed:
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
event.canceled = True
await update_event(event)
await refund_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
if not active_waves:
if not is_window_open:
raise HTTPException(
status_code=HTTPStatus.GONE,
detail=(
"Ticket closing date has passed."
if is_sales_closed
else "No ticket wave is currently open."
),
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
)
return event
def _validate_wave_capacity(data: CreateEvent, existing: Event | None = None) -> None:
"""Every ticket wave must state a real capacity.
"Capacity is always required, there is no unlimited" (#34, #62) was
settled when capacity was a single number on the event. Since v1.6.8 it
is per-wave and `event.amount_tickets` is a derived roll-up, so the rule
has to be enforced where the organiser actually sets it — otherwise it
only survives as a `min="1"` on one HTML input, which no API client is
bound by.
A zero-capacity wave can never be active (`get_active_ticket_waves`
requires `amount_tickets > 0`), so it is the wave-level form of exactly
what #62 removed: an event that looks on sale but refuses every
purchase.
Selling out is the one legitimate route to zero, so an edit only checks
waves that are NEW to the event; waves already stored keep whatever
sales decremented them to. `ensure_ticket_waves` is used rather than the
raw list so an event submitted with no waves is checked through the
primary wave it will be given.
"""
known = {wave.id for wave in (existing.extra.ticket_waves if existing else [])}
for wave in ensure_ticket_waves(data):
if wave.id in known or wave.amount_tickets >= 1:
continue
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Ticket wave '{wave.title}' needs a capacity of at least 1.",
)
@events_api_router.post("")
async def api_event_create(
data: CreateEvent,
@ -372,8 +276,6 @@ async def api_event_create(
if not data.wallet:
data.wallet = wallet.wallet.id
_validate_wave_capacity(data)
from lnbits.settings import settings
ext_settings = await get_settings()
@ -416,22 +318,6 @@ async def api_event_update(
if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
# Carry the stored waves over unless the request names the key. `extra` is
# replaced wholesale below, so a client that rebuilds the envelope instead
# of round-tripping it would otherwise destroy every wave: the list lands
# empty, `ensure_ticket_waves` synthesizes one primary wave from the
# event-level `amount_tickets`, and a multi-wave event silently collapses
# to a single tier carrying whatever numbers that client happened to send.
# Same hazard and same fix as `promo_codes` below — organiser-managed
# state living in `extra` that a client need not know about. Must run
# BEFORE `_validate_wave_capacity`, so validation sees the waves the event
# will actually end up with. An explicit `[]` still resets, as it does for
# promo codes.
if "ticket_waves" not in data.extra.__fields_set__:
data.extra.ticket_waves = event.extra.ticket_waves
_validate_wave_capacity(data, event)
from lnbits.settings import settings
ext_settings = await get_settings()
@ -448,13 +334,6 @@ async def api_event_update(
if not data.closing_date:
data.closing_date = data.event_end_date
# Promo codes are organizer-only and absent from public responses, so a
# client that round-trips a public record (or simply doesn't manage
# codes) would otherwise wipe them on every edit. Carry the stored list
# over unless the request names the key; an explicit `[]` still clears.
if "promo_codes" not in data.extra.__fields_set__:
data.extra.promo_codes = event.extra.promo_codes
# Explicit field list — never copy `status` from the request body.
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
# owner editing a fiat-enabled event keeps the fiat config.
@ -613,31 +492,6 @@ async def api_tickets_by_user(
return await get_tickets_by_user_id(user_id)
@tickets_api_router.get(
"/paginated",
summary="Get paginated list of tickets",
openapi_extra=generate_filter_params_openapi(TicketFilters),
response_model=Page[Ticket],
)
async def api_tickets_paginated(
all_wallets: bool = Query(False),
filters: Filters = Depends(tickets_filters),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Page[Ticket]:
wallet_ids = [key_info.wallet.id]
if all_wallets:
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
if not filters.sortby:
filters.sortby = "time"
if not filters.direction:
filters.direction = "desc"
return await get_tickets_paginated(wallet_ids, filters)
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
async def api_get_ticket(ticket_id: str) -> Ticket:
ticket = await get_ticket(ticket_id)
@ -653,144 +507,6 @@ 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("/")
def _resolve_ticket_wave(event: Event, ticket_wave_id: str | None) -> TicketWave:
"""The wave a purchase or a price preview is priced against.
Shared by the purchase and promo-validate endpoints so the two cannot
drift: whatever wave the preview quoted is the wave the invoice charges.
Selection is upstream v1.6.8's — an explicit id must be an OPEN wave, a
single open wave is implied, and an ambiguous choice is an error rather
than a silent pick.
"""
active_waves = get_active_ticket_waves(event)
if not active_waves:
raise HTTPException(
status_code=HTTPStatus.GONE, detail="No ticket wave is currently open."
)
if ticket_wave_id:
wave = next((wave for wave in active_waves if wave.id == ticket_wave_id), None)
if not wave:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid ticket wave selected.",
)
return wave
if len(active_waves) == 1:
return active_waves[0]
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Please select a ticket wave.",
)
async def _issue_free_tickets(
*,
event: Event,
quantity: int,
name: str | None,
email: str | None,
user_id: str | None,
promo_code: str | None,
nostr_identifier: str | None,
frontend_root: str,
selected_wave: TicketWave,
) -> TicketPaymentRequest:
"""Issue `quantity` free tickets without minting an invoice.
Each row is created then run through `set_ticket_paid` — the exact path
`on_invoice_paid` drives for a settled payment: it flips `paid`, bumps
the sold / available counters under the per-event lock, and republishes
the NIP-52 calendar event so connected clients see the new counts.
Notifications fire the same way. No invoice exists, so `sats_paid` is 0
and these tickets are naturally skipped by `refund_tickets`.
All rows in the batch share one synthetic `payment_hash` — the join key
the poll / WebSocket / My-Tickets lookups use — mirroring how the paid
multi-ticket path shares the real invoice hash.
"""
payment_hash = urlsafe_short_hash()
ticket_ids: list[str] = []
for _ in range(quantity):
row_id = urlsafe_short_hash()
ticket = await create_ticket(
payment_hash=payment_hash,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
user_id=user_id,
ticket_id=row_id,
extra={
"applied_promo_code": promo_code,
# Free tickets consume wave stock exactly like paid ones —
# `set_ticket_paid` runs on this path too — so they must name
# their wave or the decrement lands on the primary wave.
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"nostr_identifier": nostr_identifier,
"ticket_base_url": frontend_root,
"sats_paid": 0,
},
)
await set_ticket_paid(ticket)
send_ticket_notification_in_background(ticket)
ticket_ids.append(row_id)
return TicketPaymentRequest(
payment_hash=payment_hash,
payment_request=None,
is_fiat=False,
paid=True,
ticket_ids=ticket_ids,
)
@tickets_api_router.post("/{event_id}")
async def api_ticket_create(
event_id: str, data: CreateTicket, request: Request
@ -808,23 +524,20 @@ async def api_ticket_create(
if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
quantity = data.quantity
# `amount_tickets` IS the remaining count — `set_ticket_paid` decrements
# it on every sale, and upstream reads it the same way everywhere. Do
# not subtract `sold` from it: `sold` counts the same tickets the
# decrement already removed, so doing both takes each sale off twice
# (aiolabs/events#34).
if event.amount_tickets < 1:
if event.amount_tickets > 0:
if event.sold >= event.amount_tickets:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if quantity > event.amount_tickets:
remaining = event.amount_tickets - event.sold
if quantity > remaining:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.",
detail=f"Only {remaining} ticket(s) remaining for this event.",
)
name = data.name
email = data.email
user_id = data.user_id
promo_code = normalize_code(data.promo_code)
promo_code = data.promo_code.upper() if data.promo_code else None
refund_address = data.refund_address
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
payment_method = (data.payment_method or "lightning").lower()
@ -833,11 +546,6 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="Unsupported payment method.",
)
# npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this
# with a hard "Only NIP-05 Nostr identifiers are supported." rejection —
# true for upstream, false here: `_send_nostr_ticket_notification` sends
# bare pubkeys via `send_nostr_dm`. That rejection merged in outside any
# conflict marker, so it would have silently dropped npub checkout.
if nostr_identifier and "@" not in nostr_identifier:
try:
nostr_identifier = normalize_public_key(nostr_identifier)
@ -846,104 +554,45 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.",
) from exc
selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id)
unit_price = event.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
frontend_root = _resolve_frontend_root(data, request)
# One invoice, N tickets; the promo (if any) prices the whole quantity
# through the same `basket_totals` the validate endpoint uses, so the
# preview a buyer saw is what gets charged. Unlike validate, a bad code
# is a hard error here — silently charging full price would be worse.
if promo_code:
promo = find_promo(event, promo_code)
if not promo:
# check if promo_code exists in event.extra.promo_codes
if promo_code not in [pc.code for pc in event.extra.promo_codes]:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
)
if not promo.active:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Promo code is not active.",
)
usage = await event_promo_usage(event.id)
uses_left = remaining_uses(promo, usage.get(promo.code, 0))
if uses_left is not None and uses_left < quantity:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
"Promo code has been fully redeemed."
if uses_left == 0
else f"Only {uses_left} use(s) left on this promo code."
),
)
# get the promocode
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
extra["promo_code"] = promo.code
# Priced off `selected_wave`, not `event`: since v1.6.8 the price and
# currency live on the wave, and the event-level fields are a roll-up
# of the PRIMARY wave (`sync_event_ticket_waves`). Pricing off the
# event charges every buyer the first wave's price whichever wave they
# actually picked. Upstream prices one ticket; `basket_totals` keeps
# our quantity + promo arithmetic, so the "Apply" preview and the
# invoice still agree.
price = basket_totals(event, [promo.code], quantity, usage, selected_wave).total
else:
price = round_amount(
selected_wave.price_per_ticket * quantity, selected_wave.currency
)
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100)
# Scale by quantity AFTER the promo applies. One invoice, N tickets.
price = unit_price * quantity
# Free tickets (final charge 0 — a free event or a 100%-off promo).
# Short-circuit before any invoice / fiat-provider logic: no Lightning
# invoice can settle for 0, so we issue the rows and mark them paid
# directly. payment_method is irrelevant here (nothing is charged).
if price <= 0:
return await _issue_free_tickets(
event=event,
quantity=quantity,
name=name,
email=email,
user_id=user_id,
promo_code=promo_code,
nostr_identifier=nostr_identifier,
frontend_root=frontend_root,
selected_wave=selected_wave,
)
# Fiat is a per-wave opt-in since v1.6.8. `effective_payment_methods`
# below reads `event.allow_fiat`, which is only the PRIMARY wave's, so
# this check is what actually protects a non-fiat wave.
if payment_method == "fiat" and not selected_wave.allow_fiat:
if payment_method == "fiat" and not event.allow_fiat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Fiat payments are not enabled for this ticket wave.",
detail="Fiat payments are not enabled for this event.",
)
# 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="Payment method not enabled for this event.",
)
if _is_fiat_currency(selected_wave.currency):
if _is_fiat_currency(event.currency):
extra["fiat"] = True
extra["currency"] = selected_wave.currency
extra["currency"] = event.currency
extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency)
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
if payment_method != "fiat":
price = await fiat_amount_as_satoshis(price, selected_wave.currency)
price = await fiat_amount_as_satoshis(price, event.currency)
invoice_unit = selected_wave.currency
invoice_unit = event.currency
fiat_amount = price
fiat_provider = None
if payment_method == "fiat":
if _is_fiat_currency(selected_wave.currency):
invoice_unit = selected_wave.currency
if _is_fiat_currency(event.currency):
invoice_unit = event.currency
else:
invoice_unit = selected_wave.fiat_currency
invoice_unit = event.fiat_currency
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
extra["fiat"] = True
extra["currency"] = invoice_unit
@ -965,37 +614,6 @@ 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),
**({"promo_code": promo_code} if promo_code else {}),
},
}
payment = await create_payment_request(
wallet_id=event.wallet,
invoice_data=CreateInvoice(
@ -1007,8 +625,15 @@ 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 row_id in ticket_ids:
for _ in range(quantity):
row_id = urlsafe_short_hash()
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
@ -1019,18 +644,13 @@ async def api_ticket_create(
ticket_id=row_id,
extra={
"applied_promo_code": promo_code,
# Which wave this ticket came from. `set_ticket_paid` debits
# the wave named here and falls back to waves[0] when it is
# absent, so omitting it would take every sale off the
# primary wave no matter what the buyer bought.
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"refund_address": refund_address,
"nostr_identifier": nostr_identifier,
"ticket_base_url": frontend_root,
"ticket_base_url": str(request.base_url).rstrip("/"),
"sats_paid": sats_per_ticket,
},
)
ticket_ids.append(row_id)
return TicketPaymentRequest(
payment_hash=payment.payment_hash,
@ -1126,12 +746,10 @@ async def api_ticket_delete(
await delete_ticket(ticket_id)
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
@tickets_api_router.post("/{ticket_id}/resend-email")
async def api_ticket_resend_email(
ticket_id: str,
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> TicketResendResult:
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Ticket:
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
@ -1148,13 +766,16 @@ async def api_ticket_resend_email(
)
try:
return await resend_ticket_email_notification(
ticket, str(request.base_url).rstrip("/")
)
return await resend_ticket_email_notification(ticket)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Failed to resend ticket email.",
) from exc
@tickets_api_router.put("/register/{ticket_id}")
@ -1211,176 +832,3 @@ async def api_event_register_ticket(
ticket.reg_timestamp = datetime.now(timezone.utc)
ticket = await update_ticket(ticket)
return ticket
@tickets_api_router.get("/event/{event_id}/stats")
async def api_event_ticket_stats(
event_id: str,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Door-scanner roster + counts for one event, organizer-only.
Mirrors the `events_list_event_tickets` nostr-transport RPC for
callers that don't hold a raw user prvkey (the webapp post-#9, in
particular). Auth: wallet admin_key + the event's wallet must be
in the caller's wallet set.
"""
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
user = await get_user(key_info.wallet.user)
owned_wallet_ids = user.wallet_ids if user else [key_info.wallet.id]
if event.wallet not in owned_wallet_ids:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="You do not own this event.",
)
tickets = await get_tickets_by_event(event_id)
paid_tickets = [t for t in tickets if t.paid]
registered_count = sum(1 for t in paid_tickets if t.registered)
return {
"event_id": event_id,
"sold": len(paid_tickets),
"registered": registered_count,
"remaining": len(paid_tickets) - registered_count,
"tickets": [
{
"id": t.id,
"name": t.name,
"registered": t.registered,
"registered_at": (
t.reg_timestamp.isoformat() if t.reg_timestamp else None
),
"applied_promo_code": t.extra.applied_promo_code,
}
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>`).
Two shapes behind one route. Before the v1.6.8 merge this endpoint
existed on both sides — ours was "upstream's, without ticket-image
compositing", theirs added the compositing but dropped the logo. They
are the same endpoint, so they are merged rather than registered twice
(FastAPI serves whichever route is declared first, so the second copy
would have been silently unreachable):
- wave opted into `use_ticket_image`: upstream's composite — the QR
pasted onto the organiser's uploaded template, or the bundled
default. QR size and paste coordinates are upstream's and are tied
to each other; no logo, because the template carries the branding.
- otherwise: our standalone QR at 300px with the instance logo.
Anonymous by design — it is what the ticket email links to — and the id
is the same bearer token the ticket page exposes.
"""
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
event = await get_event(ticket.event)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
headers = {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
}
waves = ensure_ticket_waves(event)
wave = next(
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
waves[0],
)
if not wave.use_ticket_image:
logo = await load_qr_logo()
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
return StreamingResponse(
BytesIO(image_png_bytes(image)),
media_type="image/png",
headers=headers,
)
background_bytes = None
if wave.ticket_image_id:
asset = await get_public_asset(wave.ticket_image_id)
if asset:
background_bytes = asset.data
if background_bytes:
ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA")
else:
default_template = (
Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg"
)
ticket_image = Image.open(default_template).convert("RGBA")
ticket_image.paste(make_qr_png(f"ticket://{ticket_id}", size=157), (122, 505))
output = BytesIO()
ticket_image.save(output, format="PNG")
output.seek(0)
return StreamingResponse(output, media_type="image/png", headers=headers)
@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_card(ticket_id: str):
"""Self-describing ticket PNG (event, when, where, QR, name, id) — the
same image the ticket email attaches. Anonymous like the QR endpoint."""
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
event = await get_event(ticket.event)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
logo = await load_qr_logo()
card = render_ticket_card(
ticket, event, logo=logo, site_title=settings.lnbits_site_title
)
filename = ticket_card_filename(ticket, event)
return StreamingResponse(
BytesIO(image_png_bytes(card)),
media_type="image/png",
headers={
"Content-Disposition": f'inline; filename="{filename}"',
"Cache-Control": "no-cache, no-store, must-revalidate",
},
)
@promo_api_router.post("/validate/{event_id}")
async def api_validate_promo_codes(
event_id: str, data: PromoValidateRequest
) -> BasketTotals:
"""Price a purchase with the given codes without committing to it —
what the buyer sees before paying. Anonymous and advisory: a code that
is unknown / inactive / exhausted is simply absent from
`discounts_applied`; the purchase endpoint is where hard errors live.
Same URL as upstream v2 (`quantity` instead of v2's `items`)."""
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
usage = await event_promo_usage(event_id) if event.extra.promo_codes else {}
# Same resolver the purchase endpoint uses, so the quote and the charge
# are priced against the same wave.
wave = _resolve_ticket_wave(event, data.ticket_wave_id)
return basket_totals(event, data.codes, data.quantity, usage, wave)