Compare commits

..

1 commit

Author SHA1 Message Date
72b9c540f7 feat: per-event organizer name + reply-to on ticket emails
Some checks failed
lint.yml / feat: per-event organizer name + reply-to on ticket emails (pull_request) Failing after 0s
Each organizer is different, so the sender identity of ticket emails is
now per event rather than per instance:

- `extra.organizer_name` → From display name "Organizer via <site title>"
  (site title alone when unset). The From address stays the instance
  mailbox — that is what DKIM signs — so this costs nothing in mail auth.
- `extra.reply_to_email` → Reply-To; blank falls back to the email on the
  LNbits account that owns the event wallet; no header when neither
  exists. When a reply-to exists the body says "Questions? Reply to this
  email and it reaches <organizer>", and "Organizer: <name>" is listed
  with the ticket details.

Both fields sit next to the existing per-event subject/body in the admin
dialog. The organizer's own wording (in whatever language) remains
`notification_body`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-13 18:19:33 +02:00
33 changed files with 498 additions and 4221 deletions

View file

@ -62,19 +62,15 @@ Events includes a shareable ticket scanner, which can be used to register attend
- **Stripe session.** The buyer's email is passed as `customer_email` - **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 (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. 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** - **Email.** Multipart text + HTML (links, no images) with the **ticket card**
attached — a self-describing PNG (site, event, when, where, QR with the attached — a self-describing PNG (site, event, when, where, QR with the
instance logo, name on ticket, ticket id) also served at 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/ticket-card/{ticket_id}`; the bare QR stays at
`GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a `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` From display name — `extra.organizer_name` gives "Organizer via <site title>",
otherwise the site title. Replies go to `extra.reply_to_email`, falling back
to the event owner's account email (no Reply-To when neither exists). The
From _address_ is always the instance mailbox, which is what DKIM signs. `POST /events/api/v1/tickets/{ticket_id}/resend-email`
returns a `TicketResendResult` with per-channel outcome. returns a `TicketResendResult` with per-channel outcome.
## Powered by LNbits ## Powered by LNbits

View file

@ -6,19 +6,13 @@ from loguru import logger
from .crud import db from .crud import db
from .tasks import wait_for_paid_invoices from .tasks import wait_for_paid_invoices
from .views import events_generic_router from .views import events_generic_router
from .views_api import ( from .views_api import events_api_router, qr_api_router, tickets_api_router
events_api_router,
promo_api_router,
qr_api_router,
tickets_api_router,
)
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
events_ext.include_router(events_generic_router) events_ext.include_router(events_generic_router)
events_ext.include_router(events_api_router) events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router) events_ext.include_router(tickets_api_router)
events_ext.include_router(qr_api_router) events_ext.include_router(qr_api_router)
events_ext.include_router(promo_api_router)
events_static_files = [ events_static_files = [
{ {
@ -34,15 +28,6 @@ scheduled_tasks: list[asyncio.Task] = []
# from nostr_hooks.publish_or_delete_nostr_event. # from nostr_hooks.publish_or_delete_nostr_event.
nostr_client = None 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(): def events_stop():
for task in scheduled_tasks: for task in scheduled_tasks:
@ -126,55 +111,5 @@ def events_start():
task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events) task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events)
scheduled_tasks.append(task3) 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"] __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]

View file

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

143
crud.py
View file

@ -1,20 +1,10 @@
import json import json
from datetime import datetime, timedelta, timezone 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 lnbits.helpers import urlsafe_short_hash
from .models import ( from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
CreateEvent,
Event,
EventsSettings,
Ticket,
TicketExtra,
TicketFilters,
advertised_wave_key,
sync_event_ticket_waves,
)
db = Database("ext_events") db = Database("ext_events")
@ -165,31 +155,6 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
return [Ticket(**_parse_ticket_row(row)) for row in rows] 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: async def delete_ticket(payment_hash: str) -> None:
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
@ -219,55 +184,44 @@ async def create_event(data: CreateEvent) -> Event:
if not data.closing_date: if not data.closing_date:
data.closing_date = data.event_end_date data.closing_date = data.event_end_date
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) 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) await db.insert("events.events", event)
return event return event
async def update_event(event: Event) -> Event: async def update_event(event: Event) -> Event:
event = cast(Event, sync_event_ticket_waves(event))
await db.update("events.events", event) await db.update("events.events", event)
return event return event
async def get_event(event_id: str) -> Event | None: async def get_event(event_id: str) -> Event | None:
event = await db.fetchone( return await db.fetchone(
"SELECT * FROM events.events WHERE id = :id", "SELECT * FROM events.events WHERE id = :id",
{"id": event_id}, {"id": event_id},
Event, Event,
) )
return cast(Event, sync_event_ticket_waves(event)) if event else None
async def get_events(wallet_ids: str | list[str]) -> list[Event]: async def get_events(wallet_ids: str | list[str]) -> list[Event]:
if isinstance(wallet_ids, str): if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in 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})", f"SELECT * FROM events.events WHERE wallet IN ({q})",
model=Event, model=Event,
) )
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
async def get_all_events() -> list[Event]: async def get_all_events() -> list[Event]:
"""All events, no wallet filter. Admin-only callers.""" """All events, no wallet filter. Admin-only callers."""
events = await db.fetchall( return await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC", "SELECT * FROM events.events ORDER BY time DESC",
model=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_public_events() -> list[Event]: async def get_public_events() -> list[Event]:
"""Approved, non-canceled events for the public listing.""" """Approved, non-canceled events for the public listing."""
events = await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM events.events SELECT * FROM events.events
WHERE status = 'approved' AND canceled = FALSE WHERE status = 'approved' AND canceled = FALSE
@ -275,97 +229,14 @@ async def get_public_events() -> list[Event]:
""", """,
model=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]: async def get_pending_events() -> list[Event]:
"""Proposed events awaiting admin approval.""" """Proposed events awaiting admin approval."""
events = await db.fetchall( return await db.fetchall(
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC", "SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
model=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 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: 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

@ -4,18 +4,17 @@ 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 `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. in an upstream-compatible form; strike it when the PR merges upstream.
| Feature | Where | Upstream target | Readiness | | 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 | | `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 | | 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.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 | | `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 | | `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 | | 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 | | 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 | | 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 | | 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 | | `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) | | 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 | | Per-event sender identity: `extra.organizer_name` (From display name "X via <site>") + `extra.reply_to_email` (Reply-To, falls back to owner account email) | `models.py`, `services.py`, admin dialog | lnbits/events | small, standalone |
| 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

@ -127,61 +127,3 @@ async def m002_ticket_payment_hash(db):
"UPDATE events.ticket SET payment_hash = id " "UPDATE events.ticket SET payment_hash = id "
"WHERE payment_hash IS NULL OR payment_hash = ''" "WHERE payment_hash IS NULL OR payment_hash = ''"
) )
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",
)

283
models.py
View file

@ -1,9 +1,7 @@
import json import json
from datetime import date, datetime from datetime import datetime
from urllib.parse import urlsplit from urllib.parse import urlsplit
from uuid import uuid4
from lnbits.db import FilterModel
from pydantic import BaseModel, EmailStr, Field, root_validator, validator from pydantic import BaseModel, EmailStr, Field, root_validator, validator
PAYMENT_METHODS = ("lightning", "fiat") PAYMENT_METHODS = ("lightning", "fiat")
@ -13,80 +11,45 @@ class PromoCode(BaseModel):
code: str code: str
discount_percent: float = 0.0 discount_percent: float = 0.0
active: bool = True 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") @validator("code")
def uppercase_code(cls, v): def uppercase_code(cls, v):
v = (v or "").strip().upper() return v.upper()
if not v:
raise ValueError("Promo code cannot be empty.")
return v
@validator("discount_percent") @validator("discount_percent")
def validate_discount_percent(cls, v): def validate_discount_percent(cls, v):
assert 0 <= v <= 100, "Discount must be between 0 and 100." assert 0 <= v <= 100, "Discount must be between 0 and 100."
return v 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 conditional: bool = False
min_tickets: int = 1 min_tickets: int = 1
email_notifications: bool = False email_notifications: bool = False
nostr_notifications: bool = False nostr_notifications: bool = False
notification_subject: str = "" notification_subject: str = ""
notification_body: str = "" notification_body: str = ""
# Sender identity for ticket emails, per event: the From display name
# becomes "<organizer_name> via <site title>" and replies go to
# `reply_to_email` (falls back to the event owner's account email).
# The From *address* stays the instance mailbox — that is what DKIM
# signs — so organizers get a human sender without touching mail auth.
organizer_name: str = ""
reply_to_email: EmailStr | None = None
@validator("reply_to_email", pre=True)
def blank_reply_to_is_none(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
# Rails the organizer accepts for this event. Empty = legacy rule # Rails the organizer accepts for this event. Empty = legacy rule
# ("lightning" always, "fiat" when allow_fiat) — see # ("lightning" always, "fiat" when allow_fiat) — see
# `effective_payment_methods`. Same field name/shape as upstream v2 so the # `effective_payment_methods`. Same field name/shape as upstream v2 so the
# eventual rebase (#33) merges cleanly. # eventual rebase (#33) merges cleanly.
payment_methods: list[str] = Field(default_factory=list) 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) @validator("payment_methods", pre=True)
def normalize_payment_methods(cls, v): def normalize_payment_methods(cls, v):
@ -104,13 +67,6 @@ class EventExtraBase(BaseModel):
return seen return seen
class EventExtra(EventExtraBase):
promo_codes: list[PromoCode] = Field(default_factory=list)
PublicEventExtra = EventExtraBase
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
wallet: str | None = None # filled from caller's wallet if absent wallet: str | None = None # filled from caller's wallet if absent
name: str # title (required) name: str # title (required)
@ -123,12 +79,7 @@ class CreateEvent(BaseModel):
currency: str = "sat" currency: str = "sat"
allow_fiat: bool = False allow_fiat: bool = False
fiat_currency: str = "GBP" fiat_currency: str = "GBP"
# Capacity is always required and there is no unlimited (#34): a zero amount_tickets: int = 0 # 0 = unlimited / not ticketed
# 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
price_per_ticket: float = 0 # 0 = free price_per_ticket: float = 0 # 0 = free
banner: str | None = None banner: str | None = None
location: str | None = None # venue/address (NIP-52 'location' tag) location: str | None = None # venue/address (NIP-52 'location' tag)
@ -160,16 +111,6 @@ class Event(BaseModel):
status: str = "approved" status: str = "approved"
nostr_event_id: str | None = None nostr_event_id: str | None = None
nostr_event_created_at: int | 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) @validator("categories", pre=True)
def parse_categories(cls, v): def parse_categories(cls, v):
@ -193,9 +134,7 @@ class PublicEvent(BaseModel):
banner: str | None banner: str | None
location: str | None = None location: str | None = None
categories: list[str] = Field(default_factory=list) categories: list[str] = Field(default_factory=list)
# PublicEventExtra: promo codes are organizer-only (a buyer who can read extra: EventExtra = Field(default_factory=EventExtra)
# every code can mint every discount).
extra: PublicEventExtra = Field(default_factory=PublicEventExtra)
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
@validator("categories", pre=True) @validator("categories", pre=True)
@ -205,69 +144,22 @@ class PublicEvent(BaseModel):
return v or [] return v or []
def effective_payment_methods( def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]:
event: "Event | PublicEvent | CreateEvent",
wave: "TicketWave | None" = None,
) -> list[str]:
"""Rails a buyer may pick for `event`. """Rails a buyer may pick for `event`.
Explicit `extra.payment_methods` wins; an empty list falls back to the Explicit `extra.payment_methods` wins; an empty list falls back to the
pre-#payment-methods rule so events created before the field existed pre-#payment-methods rule so events created before the field existed
keep behaving the same (Lightning always, fiat iff `allow_fiat`). 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 []) explicit = list(getattr(event.extra, "payment_methods", []) or [])
if explicit: 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 return explicit
methods = ["lightning"] methods = ["lightning"]
if wave.allow_fiat if wave is not None else event.allow_fiat: if event.allow_fiat:
methods.append("fiat") methods.append("fiat")
return methods 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): class EventsSettings(BaseModel):
"""Extension-level settings for the events extension.""" """Extension-level settings for the events extension."""
@ -276,8 +168,6 @@ class EventsSettings(BaseModel):
class TicketExtra(BaseModel): class TicketExtra(BaseModel):
applied_promo_code: str | None = None applied_promo_code: str | None = None
ticket_wave_id: str | None = None
ticket_wave_title: str | None = None
sats_paid: int | None = None sats_paid: int | None = None
refund_address: str | None = None refund_address: str | None = None
nostr_identifier: str | None = None nostr_identifier: str | None = None
@ -285,18 +175,12 @@ class TicketExtra(BaseModel):
email_notification_sent: bool = False email_notification_sent: bool = False
nostr_notification_sent: bool = False nostr_notification_sent: bool = False
refunded: 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): class CreateTicket(BaseModel):
name: str | None = None name: str | None = None
email: EmailStr | None = None email: EmailStr | None = None
user_id: str | None = None # LNbits user id (alternative to name+email) user_id: str | None = None # LNbits user id (alternative to name+email)
ticket_wave_id: str | None = None
promo_code: str | None = None promo_code: str | None = None
refund_address: str | None = None refund_address: str | None = None
nostr_identifier: str | None = None nostr_identifier: str | None = None
@ -398,124 +282,3 @@ class TicketPaymentRequest(BaseModel):
# the door). Buyers fetch these after payment to render N QRs in # the door). Buyers fetch these after payment to render N QRs in
# My Tickets. # My Tickets.
ticket_ids: list[str] = Field(default_factory=list) 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 from .event import NostrEvent
MAX_SEEN_EVENTS = 500 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: class NostrClient:
@ -37,10 +29,6 @@ class NostrClient:
self.subscription_id = "events-" + urlsafe_short_hash()[:32] self.subscription_id = "events-" + urlsafe_short_hash()[:32]
self.running = False self.running = False
self._seen_events: OrderedDict[str, None] = OrderedDict() 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 @property
def is_websocket_connected(self): def is_websocket_connected(self):
@ -90,37 +78,17 @@ class NostrClient:
async def run_forever(self): async def run_forever(self):
self.running = True 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: while self.running:
try: try:
if not self.is_websocket_connected: if not self.is_websocket_connected:
self.ws = await self.connect() self.ws = await self.connect()
await asyncio.sleep(5) await asyncio.sleep(5)
if held_req is not None: req = await self.send_req_queue.get()
req = held_req
else:
req = await self.send_req_queue.get()
held_req, held_attempts = req, held_attempts + 1
assert self.ws assert self.ws
self.ws.send(json.dumps(req)) self.ws.send(json.dumps(req))
held_req, held_attempts = None, 0
except Exception as ex: except Exception as ex:
logger.warning(f"[EVENTS] NostrClient error: {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) await asyncio.sleep(60)
def is_duplicate_event(self, event_id: str) -> bool: def is_duplicate_event(self, event_id: str) -> bool:
@ -133,82 +101,14 @@ class NostrClient:
return False return False
async def get_event(self): async def get_event(self):
"""Get the next relay message, consuming `OK` frames on the way. """Get next event from the receive queue."""
value = await self.receive_event_queue.get()
if isinstance(value, ValueError):
raise value
return value
This is the only place relay messages cross from the websocket async def publish_nostr_event(self, e: NostrEvent):
thread into the event loop, which makes it the natural place to await self.send_req_queue.put(["EVENT", e.dict()])
settle publish confirmations — no cross-thread future juggling.
`OK` frames are swallowed rather than forwarded; the sync loop
never handled them.
"""
while True:
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:
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]): async def subscribe(self, filters: list[dict]):
"""Subscribe to events matching the given filters.""" """Subscribe to events matching the given filters."""

View file

@ -8,11 +8,11 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models).
from loguru import logger from loguru import logger
from .crud import update_event from .crud import update_event
from .models import Event, advertised_wave_key from .models import Event
from .nostr_publisher import publish_event_to_nostr 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`. """Publish or delete the NIP-52 calendar event for `event`.
Resolves a `NostrSigner` for the wallet owner — backend-agnostic Resolves a `NostrSigner` for the wallet owner — backend-agnostic
@ -22,22 +22,7 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -
`await signer.sign_event(...)` for signing. Failures are logged `await signer.sign_event(...)` for signing. Failures are logged
and swallowed so a Nostr outage doesn't break the HTTP flow that and swallowed so a Nostr outage doesn't break the HTTP flow that
triggered the publish. 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.
""" """
# 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: try:
from lnbits.core.signers import resolve_for_wallet from lnbits.core.signers import resolve_for_wallet
@ -47,40 +32,17 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -
if signer is None: if signer is None:
# Wallet missing, account missing, unclassified row, or # Wallet missing, account missing, unclassified row, or
# ClientSideOnlySigner account (server can't sign for them). # ClientSideOnlySigner account (server can't sign for them).
# Soft-fail: the HTTP / payment flow that triggered this must # Soft-fail: skip the publish silently. The user can still
# not break. The user can still publish kind-31922/31923 # publish kind-31922/31923 events client-side once we have
# events client-side once we have that path. # that path.
# return
# 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
nostr_event = await publish_event_to_nostr( nostr_event = await publish_event_to_nostr(
nostr_client, event, signer, delete=delete nostr_client, event, signer, delete=delete
) )
if nostr_event is None: if nostr_event and not delete:
return False
event.nostr_publish_pending = False
if not delete:
event.nostr_event_id = nostr_event.id event.nostr_event_id = nostr_event.id
event.nostr_event_created_at = nostr_event.created_at event.nostr_event_created_at = nostr_event.created_at
# Record which wave this publish advertised so the sweep can await update_event(event)
# 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: except Exception as exc:
# ERROR, not warning: the row stays flagged and its published logger.warning(f"[EVENTS] Nostr publish failed: {exc}")
# 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

View file

@ -17,12 +17,7 @@ from datetime import datetime, timezone
from lnbits.core.signers import NostrSigner from lnbits.core.signers import NostrSigner
from loguru import logger from loguru import logger
from .models import ( from .models import Event, effective_payment_methods
Event,
advertised_ticket_wave,
effective_payment_methods,
ensure_ticket_waves,
)
from .nostr.event import NostrEvent from .nostr.event import NostrEvent
from .nostr_timestamp import monotonic_created_at from .nostr_timestamp import monotonic_created_at
@ -51,13 +46,11 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
start - unix timestamp (31923) or YYYY-MM-DD (31922) start - unix timestamp (31923) or YYYY-MM-DD (31922)
end - same encoding (optional) end - same encoding (optional)
image, location, t (categories) - optional image, location, t (categories) - optional
tickets_available - remaining capacity of the advertised wave tickets_available - current remaining capacity (omitted when unlimited)
(always emitted; 0 = nothing on sale now) tickets_sold - running paid-count (always emitted; clients can
tickets_sold - running paid-count across all waves (always derive original_capacity = available + sold)
emitted) tickets_price - price_per_ticket (always emitted; 0 means free)
tickets_price - the advertised wave's price (always emitted; tickets_currency - the currency string
0 means free)
tickets_currency - the advertised wave's currency
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise) tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
tickets_fiat_currency - the fiat settle currency (only when allow_fiat) tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
Content: event.info Content: event.info
@ -103,59 +96,25 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
for cat in event.categories or []: for cat in event.categories or []:
tags.append(["t", cat]) tags.append(["t", cat])
# Always emitted, including zero. Omitting it used to mean "unlimited", # `amount_tickets == 0` means unlimited capacity in this extension's
# which contradicted every other reader: `api_get_event` and # schema. Omitting the tag is how clients distinguish unlimited from
# `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a # "0 left" (sold out).
# zero-capacity event advertised "Unlimited tickets" on the card while if event.amount_tickets > 0:
# the detail page and the purchase both returned 410 (aiolabs/events#34). tags.append(["tickets_available", str(event.amount_tickets)])
# 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.
tags.append(["tickets_sold", str(event.sold)]) tags.append(["tickets_sold", str(event.sold)])
tags.append(["tickets_price", str(advertised.price_per_ticket)]) tags.append(["tickets_price", str(event.price_per_ticket)])
tags.append(["tickets_currency", advertised.currency]) tags.append(["tickets_currency", event.currency])
# Fiat-checkout config — only emitted when allow_fiat is on so # Fiat-checkout config — only emitted when allow_fiat is on so
# clients can branch the buy UI without re-reading the schema. # 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"]) tags.append(["tickets_allow_fiat", "true"])
if advertised.fiat_currency: if event.fiat_currency:
tags.append(["tickets_fiat_currency", advertised.fiat_currency]) tags.append(["tickets_fiat_currency", event.fiat_currency])
# Rails the organizer accepts, resolved through the same helper the # Rails the organizer accepts, resolved through the same helper the
# ticket endpoint enforces with, so a client can render exactly 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 # buttons that will be accepted (e.g. a card-only event) without a REST
# round-trip. Comma-separated, lowercase. # round-trip. Comma-separated, lowercase.
tags.append( tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))])
[
"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)),
]
)
# NIP-52 calendar events are replaceable: this d-tag is republished # NIP-52 calendar events are replaceable: this d-tag is republished
# whenever inventory changes (a ticket sells). Use a strictly-monotonic # whenever inventory changes (a ticket sells). Use a strictly-monotonic
@ -213,14 +172,7 @@ async def publish_event_to_nostr(
Returns the published NostrEvent for metadata storage, or None on failure. Returns the published NostrEvent for metadata storage, or None on failure.
""" """
if not nostr_client: if not nostr_client:
# WARNING, not debug: with no client the event is never queued, so logger.debug("[EVENTS] No NostrClient available, skipping publish")
# 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}"
)
return None return None
try: try:
@ -244,16 +196,7 @@ async def publish_event_to_nostr(
nostr_event.pubkey = signed["pubkey"] nostr_event.pubkey = signed["pubkey"]
nostr_event.sig = signed["sig"] nostr_event.sig = signed["sig"]
accepted = await nostr_client.publish_nostr_event(nostr_event) 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
logger.info( logger.info(
f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} " f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} "
@ -262,8 +205,5 @@ async def publish_event_to_nostr(
return nostr_event return nostr_event
except Exception as e: except Exception as e:
# ERROR, not warning: this is the signer-outage shape of logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}")
# 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}")
return None return None

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

View file

@ -10,6 +10,8 @@ from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, make_msgid from email.utils import formataddr, formatdate, make_msgid
from html import escape from html import escape
from lnbits.core.crud import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models.users import UserNotifications from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import send_nostr_dm from lnbits.core.services.nostr import send_nostr_dm
from lnbits.core.services.notifications import send_user_notification from lnbits.core.services.notifications import send_user_notification
@ -26,15 +28,8 @@ from .crud import (
update_event, update_event,
update_ticket, update_ticket,
) )
from .models import ( from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
Event,
NotificationDeliveryResult,
Ticket,
TicketResendResult,
ensure_ticket_waves,
)
from .nostr_hooks import publish_or_delete_nostr_event from .nostr_hooks import publish_or_delete_nostr_event
from .promo import promo_usage
from .qr import ( from .qr import (
format_event_when, format_event_when,
image_png_bytes, image_png_bytes,
@ -76,29 +71,7 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event) event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid" assert event, "Couldn't get event from ticket being paid"
event.sold += 1 event.sold += 1
# Debit the wave the buyer actually bought from. v1.6.8 moved event.amount_tickets -= 1
# 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) await update_event(event)
# Republish the NIP-52 calendar event so connected clients see # Republish the NIP-52 calendar event so connected clients see
@ -111,22 +84,6 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
return 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: def send_ticket_notification_in_background(ticket: Ticket) -> None:
create_task(_send_ticket_notification(ticket)) create_task(_send_ticket_notification(ticket))
@ -140,17 +97,10 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
await _deliver_ticket_notifications(ticket, event) await _deliver_ticket_notifications(ticket, event)
async def resend_ticket_email_notification( async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult:
ticket: Ticket, base_url: str | None = None
) -> TicketResendResult:
"""Organizer-triggered re-delivery of the ticket email. Bypasses the """Organizer-triggered re-delivery of the ticket email. Bypasses the
per-event `email_notifications` opt-in (the organizer asked explicitly) per-event `email_notifications` opt-in (the organizer asked explicitly)
but still needs the instance mailer and an address on the ticket. 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).
"""
event = await get_event(ticket.event) event = await get_event(ticket.event)
if not event: if not event:
raise ValueError("Event does not exist.") raise ValueError("Event does not exist.")
@ -158,12 +108,7 @@ async def resend_ticket_email_notification(
raise ValueError("Email notifications are not enabled.") raise ValueError("Email notifications are not enabled.")
if not ticket.email: if not ticket.email:
raise ValueError("Ticket does not have an email address.") 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) return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
@ -195,66 +140,82 @@ def _ticket_details(ticket: Ticket, event: Event) -> str:
"or open the link above on your phone, and show the QR code at the " "or open the link above on your phone, and show the QR code at the "
"door to be scanned in." "door to be scanned in."
) )
if event.extra.organizer_name:
lines.append(f"Organizer: {event.extra.organizer_name}")
return "\n".join(lines) return "\n".join(lines)
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str: def _ticket_details_with_reply_hint(
"""Text part of the ticket mail. ticket: Ticket, event: Event, reply_to: str | None
) -> str:
details = _ticket_details(ticket, event)
if reply_to:
who = event.extra.organizer_name or "the organizer"
details += f"\nQuestions? Reply to this email and it reaches {who}."
return details
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 def _ticket_delivery_message(
present, and the organiser's uploaded template, only when the buyer's ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None
wave opted into it. They had the same label before the v1.6.8 merge ) -> str:
because only one of them existed; now that both can appear the labels return (
have to distinguish them. f"{base_message}\n\n{_ticket_details_with_reply_hint(ticket, event, reply_to)}"
""" f"\n\nTicket image: {_ticket_image_url(ticket)}"
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: def _ticket_email_html_message(
"""HTML twin of the text part. ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None
) -> str:
Deliberately no <img> for our own ticket card: it travels as an """HTML twin of the text part. Deliberately no <img>: the card travels
attachment (renders inline in most clients, works offline, and keeps as an attachment (renders inline in most clients, works offline, and
SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link. keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
The organiser's uploaded ticket image is a remote URL with no attachment links."""
to fall back on, so that one does get upstream's <img> — the surrounding text_message = _ticket_delivery_message(ticket, event, base_message, reply_to)
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 = escape(text_message)
html = re.sub( html = re.sub(
r"(https?://[^\s<]+)", r"(https?://[^\s<]+)",
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>', lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
html, html,
) )
html_message = f"<p>{html.replace(chr(10), '<br />')}</p>" return 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]: def _ticket_notification_payload(
ticket: Ticket, event: Event, reply_to: str | None = None
) -> tuple[str, str, str]:
subject, base_message = _ticket_notification_message(ticket, event) subject, base_message = _ticket_notification_message(ticket, event)
text_message = _ticket_delivery_message(ticket, event, base_message) text_message = _ticket_delivery_message(ticket, event, base_message, reply_to)
html_message = _ticket_email_html_message(ticket, event, base_message) html_message = _ticket_email_html_message(ticket, event, base_message, reply_to)
return subject, text_message, html_message return subject, text_message, html_message
async def organizer_reply_to(event: Event) -> str | None:
"""Where replies to a ticket email should go: the event's explicit
`reply_to_email`, else the email on the LNbits account that owns the
event wallet, else nothing (no Reply-To header)."""
if event.extra.reply_to_email:
return str(event.extra.reply_to_email)
try:
wallet = await get_wallet(event.wallet)
if not wallet:
return None
user = await get_user(wallet.user)
email = (user.email or "").strip() if user else ""
return email if is_valid_email_address(email) else None
except Exception as exc:
logger.warning(f"Could not resolve organizer email for {event.id}: {exc}")
return None
def organizer_sender_name(event: Event) -> str:
"""From display name: "<organizer> via <site title>" when the event
names an organizer, else the site title."""
site = (settings.lnbits_site_title or "").strip() or "Tickets"
organizer = (event.extra.organizer_name or "").strip()
return f"{organizer} via {site}" if organizer else site
async def _deliver_ticket_notifications( async def _deliver_ticket_notifications(
ticket: Ticket, ticket: Ticket,
event: Event, event: Event,
@ -264,17 +225,11 @@ async def _deliver_ticket_notifications(
) -> TicketResendResult: ) -> TicketResendResult:
"""Send the ticket by every configured channel and report per-channel """Send the ticket by every configured channel and report per-channel
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
opt-ins when not None; the instance-level prerequisites always apply. opt-ins when not None; the instance-level prerequisites always apply."""
reply_to = await organizer_reply_to(event)
Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery` subject, text_message, html_message = _ticket_notification_payload(
check that errors with "Only NIP-05 Nostr identifiers are supported." ticket, event, reply_to
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 updated = False
email_wanted = event.extra.email_notifications if email is None else email email_wanted = event.extra.email_notifications if email is None else email
@ -314,6 +269,8 @@ async def _deliver_ticket_notifications(
attachments=[ attachments=[
(ticket_card_filename(ticket, event), image_png_bytes(card)) (ticket_card_filename(ticket, event), image_png_bytes(card))
], ],
reply_to=reply_to,
sender_name=organizer_sender_name(event),
) )
ticket.extra.email_notification_sent = True ticket.extra.email_notification_sent = True
result.email.sent = True result.email.sent = True
@ -345,6 +302,8 @@ async def _send_ticket_email_notification(
subject: str, subject: str,
html_message: str | None = None, html_message: str | None = None,
attachments: list[tuple[str, bytes]] | None = None, attachments: list[tuple[str, bytes]] | None = None,
reply_to: str | None = None,
sender_name: str | None = None,
) -> None: ) -> None:
"""Multipart (text + HTML) ticket email through the instance SMTP """Multipart (text + HTML) ticket email through the instance SMTP
settings. Core's `send_email_notification` is plain-text only, which is settings. Core's `send_email_notification` is plain-text only, which is
@ -363,7 +322,14 @@ async def _send_ticket_email_notification(
raise ValueError(f"Invalid email address: {address}") raise ValueError(f"Invalid email address: {address}")
msg = build_ticket_email( msg = build_ticket_email(
from_email, to_emails, subject, message, html_message, attachments from_email,
to_emails,
subject,
message,
html_message,
attachments,
reply_to=reply_to,
sender_name=sender_name,
) )
username = settings.lnbits_email_notifications_username or from_email username = settings.lnbits_email_notifications_username or from_email
await asyncio.to_thread( await asyncio.to_thread(
@ -385,6 +351,8 @@ def build_ticket_email(
message: str, message: str,
html_message: str | None = None, html_message: str | None = None,
attachments: list[tuple[str, bytes]] | None = None, attachments: list[tuple[str, bytes]] | None = None,
reply_to: str | None = None,
sender_name: str | None = None,
) -> MIMEMultipart: ) -> MIMEMultipart:
"""Assemble the ticket email: text + HTML alternatives, PNG attachments """Assemble the ticket email: text + HTML alternatives, PNG attachments
(the ticket card), and the headers receivers score on — a Date and a (the ticket card), and the headers receivers score on — a Date and a
@ -406,9 +374,13 @@ def build_ticket_email(
else: else:
msg = body msg = body
sender_name = (settings.lnbits_site_title or "").strip() or "Tickets" display_name = (
msg["From"] = formataddr((sender_name, from_email)) sender_name or settings.lnbits_site_title or ""
).strip() or "Tickets"
msg["From"] = formataddr((display_name, from_email))
msg["To"] = ", ".join(to_emails) msg["To"] = ", ".join(to_emails)
if reply_to and is_valid_email_address(reply_to):
msg["Reply-To"] = reply_to
msg["Subject"] = subject msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True) msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1]) msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])
@ -449,36 +421,16 @@ def _ticket_url(ticket: Ticket) -> str:
return f"{base_url}/events/ticket/{ticket.id}" return f"{base_url}/events/ticket/{ticket.id}"
def _ticket_card_url(ticket: Ticket) -> str: def _ticket_image_url(ticket: Ticket) -> str:
"""Our rendered ticket-card PNG — always available, and served by THIS """The ticket card PNG is served by THIS extension on the LNbits host, so
extension on the LNbits host, so it is built from `lnbits_baseurl` even it is built from `lnbits_baseurl` even when `ticket_base_url` points at
when `ticket_base_url` points at a separate web app (deviation from a separate web app (deviation from upstream, which assumes both are the
upstream, which assumes both are the same host).""" same host)."""
return ( return (
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}" 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): async def refund_tickets(event_id: str):
""" """
Refund tickets for an event that has not met the minimum ticket requirement. Refund tickets for an event that has not met the minimum ticket requirement.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

View file

@ -13,7 +13,6 @@ window.PageEventsDisplay = {
email: '', email: '',
refund: '', refund: '',
nostr_identifier: '', nostr_identifier: '',
ticket_wave_id: null,
payment_method: 'lightning' payment_method: 'lightning'
} }
}, },
@ -47,62 +46,26 @@ window.PageEventsDisplay = {
paymentMethods() { paymentMethods() {
// Mirrors `effective_payment_methods` on the backend: an explicit // Mirrors `effective_payment_methods` on the backend: an explicit
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat. // 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 || [] const explicit = this.event?.extra?.payment_methods || []
if (explicit.length) return explicit if (explicit.length) return explicit
const allowFiat = this.selectedTicketWave return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])]
? 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() { allowFiatCheckout() {
return this.paymentMethods.includes('fiat') return this.paymentMethods.includes('fiat')
}, },
paymentMethodOptions() { 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 => ({ return this.paymentMethods.map(method => ({
value: method, value: method,
label: labels[method] || method label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning'
})) }))
}, },
fiatCheckoutLabel() { fiatCheckoutLabel() {
if (!this.allowFiatCheckout) return 'Fiat' if (!this.allowFiatCheckout) return 'Fiat'
const unit = ['sat', 'sats'].includes( const unit = ['sat', 'sats'].includes(
(this.selectedTicketWave?.currency || '').toLowerCase() (this.event?.currency || '').toLowerCase()
) )
? this.selectedTicketWave?.fiat_currency ? this.event?.fiat_currency
: this.selectedTicketWave?.currency : this.event?.currency
return `Fiat (${(unit || 'GBP').toUpperCase()})` return `Fiat (${(unit || 'GBP').toUpperCase()})`
}, },
allowEmailNotifications() { allowEmailNotifications() {
@ -119,16 +82,6 @@ window.PageEventsDisplay = {
'GET', 'GET',
`/events/api/v1/events/${this.eventId}` `/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 return data
} catch (error) { } catch (error) {
this.eventErrorLabel = 'Event unavailable.' this.eventErrorLabel = 'Event unavailable.'
@ -141,11 +94,6 @@ window.PageEventsDisplay = {
this.formDialog.data.email = '' this.formDialog.data.email = ''
this.formDialog.data.refund = '' this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = '' 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.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning' this.paymentMethods[0] || 'lightning'
}, },
@ -159,13 +107,6 @@ window.PageEventsDisplay = {
this.paymentWebsocket.close() this.paymentWebsocket.close()
this.paymentWebsocket = null this.paymentWebsocket = null
} }
this.paymentReq = null
this.receive = {
show: false,
status: 'pending',
paymentReq: null,
isFiat: false
}
}, },
nameValidation(val) { nameValidation(val) {
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
@ -188,10 +129,6 @@ window.PageEventsDisplay = {
this.formDialog.data.email = '' this.formDialog.data.email = ''
this.formDialog.data.refund = '' this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = '' 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.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning' this.paymentMethods[0] || 'lightning'
Quasar.Notify.create({ Quasar.Notify.create({
@ -222,19 +159,10 @@ window.PageEventsDisplay = {
{ {
name: this.formDialog.data.name, name: this.formDialog.data.name,
email: this.formDialog.data.email, email: this.formDialog.data.email,
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
promo_code: this.formDialog.data.promo_code || null, promo_code: this.formDialog.data.promo_code || null,
refund_address: this.formDialog.data.refund || null, refund_address: this.formDialog.data.refund || null,
nostr_identifier: this.formDialog.data.nostr_identifier || null, nostr_identifier: this.formDialog.data.nostr_identifier || null,
// Gate matches the template's (`paymentMethods.length > 1`). payment_method: this.formDialog.data.payment_method
// 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'
} }
) )
const isFiat = Boolean(data.is_fiat) const isFiat = Boolean(data.is_fiat)
@ -268,7 +196,7 @@ window.PageEventsDisplay = {
const url = new URL(window.location) const url = new URL(window.location)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` url.pathname = `/api/v1/ws/${paymentHash}`
url.search = '' url.search = ''
url.hash = '' url.hash = ''
@ -277,7 +205,7 @@ window.PageEventsDisplay = {
ws.onmessage = event => { ws.onmessage = event => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.paid === true) { if (data.pending === false) {
this.paymentSuccess(paymentHash) this.paymentSuccess(paymentHash)
ws.close() ws.close()
} }
@ -286,12 +214,8 @@ window.PageEventsDisplay = {
console.error('WebSocket error:', error) console.error('WebSocket error:', error)
} }
ws.onclose = () => { ws.onclose = () => {
if (this.paymentWebsocket !== ws) return if (this.paymentWebsocket === ws) {
this.paymentWebsocket = null this.paymentWebsocket = null
if (this.receive.show) {
setTimeout(() => {
if (this.receive.show) this.paymentWatcher(paymentHash)
}, 3000)
} }
} }
} }

View file

@ -74,7 +74,7 @@
filled filled
dense dense
v-model.trim="formDialog.data.nostr_identifier" 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." hint="If provided, we'll DM your ticket link after payment."
></q-input> ></q-input>
</div> </div>
@ -89,21 +89,6 @@
lazy-rules lazy-rules
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input> ></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 class="row q-col-gutter-md q-pt-lg items-center">
<div v-if="paymentMethods.length > 1" class="col-auto"> <div v-if="paymentMethods.length > 1" class="col-auto">
<q-option-group <q-option-group
@ -132,8 +117,7 @@
:disable=" :disable="
formDialog.data.name == '' || formDialog.data.name == '' ||
formDialog.data.email == '' || formDialog.data.email == '' ||
(showTicketWaveSelector && !formDialog.data.ticket_wave_id) || Boolean(paymentReq)
(receive.show && Boolean(paymentReq))
" "
type="submit" type="submit"
>Submit</q-btn >Submit</q-btn

View file

@ -4,10 +4,7 @@ window.PageEvents = {
return { return {
events: [], events: [],
tickets: [], tickets: [],
allPaidTickets: [],
resendingTicketEmails: [], resendingTicketEmails: [],
isUploadingTicketTemplate: false,
ticketImageUploadTarget: null,
currencies: [], currencies: [],
pendingEvents: [], pendingEvents: [],
allUserEvents: [], allUserEvents: [],
@ -28,12 +25,7 @@ window.PageEvents = {
label: 'Owner', label: 'Owner',
field: 'wallet_user_id' field: 'wallet_user_id'
}, },
{ {name: 'id', align: 'left', label: 'ID', field: 'id'},
name: 'id',
align: 'left',
label: 'ID',
field: row => this.shortenId(row.id)
},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{ {
name: 'event_start_date', name: 'event_start_date',
@ -47,6 +39,12 @@ window.PageEvents = {
label: 'End date', label: 'End date',
field: 'event_end_date' field: 'event_end_date'
}, },
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{ {
name: 'canceled', name: 'canceled',
align: 'left', align: 'left',
@ -131,41 +129,16 @@ window.PageEvents = {
} }
}, },
ticketsTable: { ticketsTable: {
loading: false,
columns: [ columns: [
{ {name: 'event', align: 'left', label: 'Event', field: 'event'},
name: 'event',
align: 'left',
label: 'Event',
field: row => this.shortenId(row.event)
},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'}, {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', name: 'registered',
align: 'left', align: 'left',
label: 'Registered', label: 'Registered',
field: 'registered' field: 'registered'
}, },
{
name: 'nostr',
align: 'left',
label: 'Nostr',
field: row => row.extra?.nostr_identifier || ''
},
{ {
name: 'promo_code', name: 'promo_code',
align: 'left', align: 'left',
@ -175,11 +148,7 @@ window.PageEvents = {
{name: 'id', align: 'left', label: 'ID', field: 'id'} {name: 'id', align: 'left', label: 'ID', field: 'id'}
], ],
pagination: { pagination: {
sortBy: 'time', rowsPerPage: 10
descending: true,
page: 1,
rowsPerPage: 10,
rowsNumber: 10
} }
}, },
// Rails an organizer can enable per event. Mirrors the webapp's // Rails an organizer can enable per event. Mirrors the webapp's
@ -236,42 +205,13 @@ window.PageEvents = {
categories: [], categories: [],
extra: { extra: {
payment_methods: ['lightning'], payment_methods: ['lightning'],
ticket_waves: [],
promo_codes: [], promo_codes: [],
organizer_name: '',
reply_to_email: '',
notification_subject: '', notification_subject: '',
notification_body: '' 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: []
}
}
} }
} }
}, },
@ -297,180 +237,19 @@ window.PageEvents = {
} }
}, },
methods: { 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) { isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase()) return !['sat', 'sats'].includes((currency || '').toLowerCase())
}, },
normalizePromoCodes(promoCodes = []) { getTickets() {
return promoCodes LNbits.api
.filter(code => code.code?.trim() !== '') .request(
.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(
'GET', 'GET',
'/events/api/v1/tickets?all_wallets=true', '/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.allPaidTickets = data.filter(ticket => ticket.paid) .then(response => {
} catch (error) { this.tickets = response.data.filter(e => e.paid)
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
}
}, },
deleteTicket(ticketId) { deleteTicket(ticketId) {
const tickets = _.findWhere(this.tickets, {id: ticketId}) const tickets = _.findWhere(this.tickets, {id: ticketId})
@ -485,9 +264,10 @@ window.PageEvents = {
'/events/api/v1/tickets/' + ticketId, '/events/api/v1/tickets/' + ticketId,
wallet.adminkey wallet.adminkey
) )
.then(async () => { .then(response => {
await this.getTickets() this.tickets = _.reject(this.tickets, function (obj) {
await this.getAllTickets() return obj.id == ticketId
})
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}) })
@ -505,30 +285,14 @@ window.PageEvents = {
wallet.adminkey wallet.adminkey
) )
.then(response => { .then(response => {
const result = response.data
this.tickets = this.tickets.map(obj => this.tickets = this.tickets.map(obj =>
obj.id === ticket.id ? result.ticket : obj obj.id === ticket.id ? response.data : obj
) )
Quasar.Notify.create({
if (result.email?.attempted) { type: 'positive',
Quasar.Notify.create({ message: 'Ticket email resent.',
type: result.email.sent ? 'positive' : 'negative', icon: null
message: result.email.sent })
? 'Ticket email resent.'
: `Ticket email failed: ${result.email.error || 'Unknown error.'}`,
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) .catch(LNbits.utils.notifyApiError)
.finally(() => { .finally(() => {
@ -538,7 +302,7 @@ window.PageEvents = {
}) })
}, },
exportticketsCSV() { exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets) LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
}, },
getEvents() { getEvents() {
LNbits.api LNbits.api
@ -745,14 +509,14 @@ window.PageEvents = {
data.location = (data.location || '').trim() || null data.location = (data.location || '').trim() || null
data.categories = data.categories || [] data.categories = data.categories || []
data.closing_date = data.closing_date || null 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) { 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 || [] const methods = data.extra?.payment_methods || []
if (methods.length === 0) { if (methods.length === 0) {
@ -772,7 +536,6 @@ window.PageEvents = {
} else if (!data.allow_fiat) { } else if (!data.allow_fiat) {
data.fiat_currency = 'GBP' data.fiat_currency = 'GBP'
} }
this.syncPrimaryWaveFromForm(data)
if (data.id) { if (data.id) {
this.updateEvent(wallet, data) this.updateEvent(wallet, data)
@ -785,14 +548,10 @@ window.PageEvents = {
if (data && data.id) { if (data && data.id) {
const start = this.splitDateTime(data.event_start_date) const start = this.splitDateTime(data.event_start_date)
const end = this.splitDateTime(data.event_end_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 = { this.formDialog.data = {
...hydrated, ...data,
extra: { extra: {
...(hydrated.extra || {}), ...(data.extra || {}),
// Events created before extra.payment_methods existed carry an // Events created before extra.payment_methods existed carry an
// empty list; show the rails the backend actually accepts for // empty list; show the rails the backend actually accepts for
// them (Lightning always, fiat when allow_fiat). // them (Lightning always, fiat when allow_fiat).
@ -824,22 +583,9 @@ window.PageEvents = {
min_tickets: 1, min_tickets: 1,
email_notifications: false, email_notifications: false,
nostr_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: [], promo_codes: [],
organizer_name: '',
reply_to_email: '',
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
} }
@ -857,26 +603,11 @@ window.PageEvents = {
categories: [], categories: [],
extra: { extra: {
payment_methods: ['lightning'], payment_methods: ['lightning'],
conditional: false,
min_tickets: 1,
email_notifications: false, email_notifications: false,
nostr_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: [], promo_codes: [],
organizer_name: '',
reply_to_email: '',
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
} }
@ -896,183 +627,6 @@ window.PageEvents = {
const link = _.findWhere(this.events, {id: formId}) const link = _.findWhere(this.events, {id: formId})
this.openEventDialog(link) 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) { updateEvent(wallet, data) {
LNbits.api LNbits.api
.request( .request(
@ -1139,7 +693,6 @@ window.PageEvents = {
async created() { async created() {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getTickets() this.getTickets()
this.getAllTickets()
this.getEvents() this.getEvents()
this.getSettings() this.getSettings()
this.getPendingEvents() this.getPendingEvents()

View file

@ -243,86 +243,45 @@
<q-tr v-show="props.expand" :props="props"> <q-tr v-show="props.expand" :props="props">
<q-td colspan="100%"> <q-td colspan="100%">
<div class="q-pa-md"> <div class="q-pa-md">
<div class="row items-center q-mb-md"> <div class="text-subtitle1 q-mb-md">Promo codes</div>
<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="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 class="column">
<div <div
v-if=" v-if="props.row.extra.promo_codes.length == 0"
props.row.extra.promo_codes.filter(
code => code.active
).length == 0
"
class="text-caption" class="text-caption"
> >
No active promo codes for this event. No promo codes for this event.
</div> </div>
<div class="row q-col-gutter-sm"> <div
<div v-for="(code, index) in props.row.extra.promo_codes"
v-for="( :key="index"
code, index class="row items-center q-col-gutter-sm q-mb-sm"
) in props.row.extra.promo_codes.filter( >
code => code.active <div class="col-auto">
)"
:key="index"
class="col-auto q-mb-sm"
>
<q-chip <q-chip
square square
size="md"
clickable clickable
@click="utils.copyText(code.code.toUpperCase())" @click="utils.copyText(code.code.toUpperCase())"
> >
<span <q-avatar
v-text=" icon="bookmark"
`${code.code.toUpperCase()} - ${code.discount_percent}%` :color="code.active ? 'green' : 'grey'"
" text-color="white"
></span> ></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip> </q-chip>
</div> </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> </div>
</div> </div>
@ -349,11 +308,9 @@
dense dense
flat flat
:rows="tickets" :rows="tickets"
:loading="ticketsTable.loading"
row-key="id" row-key="id"
:columns="ticketsTable.columns" :columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination" v-model:pagination="ticketsTable.pagination"
@request="getTickets"
> >
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
@ -640,39 +597,6 @@
></q-input> ></q-input>
</div> </div>
</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="row q-col-gutter-sm">
<div class="col"> <div class="col">
<q-select <q-select
@ -690,9 +614,9 @@
dense dense
v-model.number="formDialog.data.amount_tickets" v-model.number="formDialog.data.amount_tickets"
type="number" type="number"
min="1" min="0"
label="Amount of tickets" label="Amount of tickets"
hint="Total tickets on sale" hint="0 = unlimited"
></q-input> ></q-input>
</div> </div>
<div class="col"> <div class="col">
@ -750,49 +674,6 @@
</div> </div>
</div> </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"
left-label
hint="Lets attendees pay through a configured fiat provider using the event currency."
></q-toggle>
<q-select <q-select
v-if="acceptsFiat && isSatPrice" v-if="acceptsFiat && isSatPrice"
filled filled
@ -837,50 +718,134 @@
</div> </div>
</div> </div>
<q-separator class="q-my-md"></q-separator> <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-subtitle1 q-mb-md">Ticket Delivery</div>
<div class="text-caption"> <div class="text-caption">
Send the paid ticket link automatically by email or Nostr DM. Send the paid ticket link automatically by email or Nostr DM.
</div> </div>
<div class="row items-center q-col-gutter-md"> <q-toggle
<div class="col-auto"> v-model="formDialog.data.extra.email_notifications"
<q-toggle label="Email notifications"
v-model="formDialog.data.extra.email_notifications" left-label
label="Email notifications" ></q-toggle>
left-label <q-toggle
></q-toggle> v-model="formDialog.data.extra.nostr_notifications"
</div> label="Nostr notifications"
<div class="col-auto"> left-label
<q-toggle ></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-input
filled
dense
v-model.trim="formDialog.data.extra.notification_subject"
type="text"
label="Ticket notification subject"
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"
type="textarea"
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
</div>
</q-expansion-item> </q-expansion-item>
<q-separator class="q-my-md"></q-separator>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_subject"
type="text"
label="Ticket notification subject"
hint="Used as the email subject when sending paid ticket links."
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_body"
type="textarea"
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input
filled
dense
v-model.trim="formDialog.data.extra.organizer_name"
type="text"
label="Organizer name (email sender)"
hint="Shown as 'Name via <site>' in the From line of ticket emails."
></q-input>
</div>
<div class="col-12 col-md-6">
<q-input
filled
dense
v-model.trim="formDialog.data.extra.reply_to_email"
type="email"
label="Reply-to email"
hint="Where attendee replies go. Blank = your account email."
></q-input>
</div>
</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
v-if="formDialog.data.id" v-if="formDialog.data.id"
@ -896,9 +861,7 @@
:disable=" :disable="
!formDialog.data.wallet || !formDialog.data.wallet ||
!formDialog.data.name || !formDialog.data.name ||
!formDialog.data.event_start_day || !formDialog.data.event_start_day
primaryTicketWave().title == null ||
primaryTicketWave().opening_date == null
" "
type="submit" type="submit"
>Create Event</q-btn >Create Event</q-btn
@ -910,277 +873,5 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </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> </div>
</template> </template>

View file

@ -3,36 +3,16 @@ window.PageEventsTicket = {
data() { data() {
return { return {
ticketId: null, ticketId: null,
ticket: null, ticket: null
printMode: false,
qrSrc: ''
} }
}, },
methods: { methods: {
async printWindow() { printWindow() {
this.printMode = true window.print()
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)
})
} }
}, },
async created() { async created() {
this.ticketId = this.$route.params.id this.ticketId = this.$route.params.id
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
`ticket://${this.ticketId}`
)}`
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
@ -42,8 +22,5 @@ window.PageEventsTicket = {
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
window.addEventListener('afterprint', () => {
this.printMode = false
})
} }
} }

View file

@ -36,53 +36,4 @@
</q-card> </q-card>
</div> </div>
</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> </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,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,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

@ -87,3 +87,59 @@ def test_payload_includes_event_details_and_qr(monkeypatch):
assert "/events/api/v1/ticket-card/tkt1" in text assert "/events/api/v1/ticket-card/tkt1" in text
assert "<img" not in html and "Ticket ID: tkt1" in html 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 assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html
def test_reply_to_and_organizer_sender_name(monkeypatch):
from ..services import organizer_sender_name
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
event = _event(
extra={
"organizer_name": "Château du Faune",
"reply_to_email": "hello@faune.example",
}
)
assert organizer_sender_name(event) == "Château du Faune via Oyez!"
assert organizer_sender_name(_event()) == "Oyez!"
msg = build_ticket_email(
"tickets@example.org",
["ada@example.com"],
"Subj",
"text",
None,
reply_to="hello@faune.example",
sender_name=organizer_sender_name(event),
)
assert msg["Reply-To"] == "hello@faune.example"
# Non-ASCII display names are RFC 2047-encoded on the wire; decode to compare.
from email.header import decode_header, make_header
assert str(make_header(decode_header(msg["From"]))) == (
"Château du Faune via Oyez! <tickets@example.org>"
)
no_reply = build_ticket_email(
"tickets@example.org", ["ada@example.com"], "Subj", "text"
)
assert no_reply["Reply-To"] is None
def test_reply_hint_only_when_reply_to_exists(monkeypatch):
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
event = _event(extra={"organizer_name": "Château du Faune"})
_, text, _ = _ticket_notification_payload(
_ticket(), event, reply_to="hello@faune.example"
)
assert "Organizer: Château du Faune" in text
assert "Reply to this email and it reaches Château du Faune" in text
_, text, _ = _ticket_notification_payload(_ticket(), event, reply_to=None)
assert "Reply to this email" not in text
def test_blank_reply_to_email_is_none():
from ..models import EventExtra
assert EventExtra(reply_to_email="").reply_to_email is None
assert EventExtra(reply_to_email=" ").reply_to_email is None
assert EventExtra(reply_to_email="a@b.example").reply_to_email == "a@b.example"

View file

@ -5,10 +5,6 @@ from ..models import (
CreateEvent, CreateEvent,
CreateTicket, CreateTicket,
EventExtra, EventExtra,
PromoCode,
PublicEvent,
PublicEventExtra,
TicketWave,
effective_payment_methods, effective_payment_methods,
) )
@ -112,108 +108,3 @@ def test_payment_methods_are_normalised_and_deduplicated():
def test_unknown_payment_method_is_rejected(): def test_unknown_payment_method_is_rejected():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
EventExtra(payment_methods=["cash"]) 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,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

@ -2,7 +2,6 @@ import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO from io import BytesIO
from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
@ -17,20 +16,17 @@ from fastapi import (
) )
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user 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.crud.wallets import get_wallet
from lnbits.core.models import Account, User, WalletTypeInfo from lnbits.core.models import Account, User, WalletTypeInfo
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services import create_payment_request from lnbits.core.services import create_payment_request
from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
check_admin, check_admin,
check_user_exists, check_user_exists,
parse_filters,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
@ -38,7 +34,6 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat, satoshis_amount_as_fiat,
) )
from lnbits.utils.nostr import normalize_public_key from lnbits.utils.nostr import normalize_public_key
from PIL import Image
from .crud import ( from .crud import (
create_event, create_event,
@ -58,38 +53,24 @@ from .crud import (
get_tickets_by_event, get_tickets_by_event,
get_tickets_by_payment_hash, get_tickets_by_payment_hash,
get_tickets_by_user_id, get_tickets_by_user_id,
get_tickets_paginated,
purge_unpaid_tickets, purge_unpaid_tickets,
update_event, update_event,
update_settings, update_settings,
update_ticket, update_ticket,
) )
from .models import ( from .models import (
BasketTotals,
CreateEvent, CreateEvent,
CreateTicket, CreateTicket,
Event, Event,
EventsSettings, EventsSettings,
PromoValidateRequest,
PublicEvent, PublicEvent,
PublicTicket, PublicTicket,
Ticket, Ticket,
TicketFilters,
TicketPaymentRequest, TicketPaymentRequest,
TicketResendResult, TicketResendResult,
TicketWave,
effective_payment_methods, effective_payment_methods,
ensure_ticket_waves,
get_active_ticket_waves,
) )
from .nostr_hooks import publish_or_delete_nostr_event 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 ( from .qr import (
image_png_bytes, image_png_bytes,
load_qr_logo, load_qr_logo,
@ -98,23 +79,16 @@ from .qr import (
ticket_card_filename, ticket_card_filename,
) )
from .services import ( from .services import (
event_promo_usage,
hydrate_promo_usage,
refund_tickets, refund_tickets,
resend_ticket_email_notification, resend_ticket_email_notification,
send_ticket_notification_in_background, send_ticket_notification_in_background,
set_ticket_paid, set_ticket_paid,
) )
from .tasks import ( from .tasks import deregister_payment_listener, register_payment_listener
deregister_payment_listener,
register_payment_listener,
)
events_api_router = APIRouter(prefix="/api/v1/events") events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets") tickets_api_router = APIRouter(prefix="/api/v1/tickets")
qr_api_router = APIRouter(prefix="/api/v1") 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: def _is_fiat_currency(currency: str | None) -> bool:
@ -123,6 +97,8 @@ def _is_fiat_currency(currency: str | None) -> bool:
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared # Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
# before any "/{event_id}" route or FastAPI matches them as a path parameter. # before any "/{event_id}" route or FastAPI matches them as a path parameter.
@events_api_router.get("") @events_api_router.get("")
async def api_events( async def api_events(
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
@ -132,17 +108,12 @@ async def api_events(
if all_wallets: if all_wallets:
user = await get_user(wallet.wallet.user) user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
events = await get_events(wallet_ids) return await get_events(wallet_ids)
for event in events:
await hydrate_promo_usage(event)
return events
@events_api_router.get("/public", response_model=list[PublicEvent]) @events_api_router.get("/public")
async def api_events_public() -> list[Event]: async def api_events_public() -> list[Event]:
"""Approved, non-canceled events for an anonymous public listing. """Approved, non-canceled events for an anonymous public listing."""
Projected through `PublicEvent`: no wallet id, no promo codes."""
return await get_public_events() return await get_public_events()
@ -160,7 +131,6 @@ async def api_events_all(
events = await get_all_events() events = await get_all_events()
enriched: list[dict] = [] enriched: list[dict] = []
for event in events: for event in events:
await hydrate_promo_usage(event)
wallet = await get_wallet(event.wallet) wallet = await get_wallet(event.wallet)
row = event.dict() row = event.dict()
row["wallet_user_id"] = wallet.user if wallet else None row["wallet_user_id"] = wallet.user if wallet else None
@ -285,80 +255,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 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 # 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 # 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 # feature).
# strptime("%Y-%m-%d") here; that raises ValueError on any event of ours
# whose closing date carries a time, which is most of them.
try: try:
closing_dt = datetime.fromisoformat(closing_date) closing_dt = datetime.fromisoformat(closing_date)
except ValueError: except ValueError:
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d") closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
if closing_dt.tzinfo is None: if closing_dt.tzinfo is None:
closing_dt = closing_dt.replace(tzinfo=timezone.utc) closing_dt = closing_dt.replace(tzinfo=timezone.utc)
is_window_open = datetime.now(timezone.utc) < closing_dt
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_min_tickets_met = ( is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True event.sold >= event.extra.min_tickets if event.extra.conditional else True
) )
if event.amount_tickets < 1: if event.amount_tickets < 1:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") 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 event.canceled = True
await update_event(event) await update_event(event)
await refund_tickets(event_id) await refund_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
if not active_waves: if not is_window_open:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.GONE, status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
detail=(
"Ticket closing date has passed."
if is_sales_closed
else "No ticket wave is currently open."
),
) )
return event 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("") @events_api_router.post("")
async def api_event_create( async def api_event_create(
data: CreateEvent, data: CreateEvent,
@ -372,8 +295,6 @@ async def api_event_create(
if not data.wallet: if not data.wallet:
data.wallet = wallet.wallet.id data.wallet = wallet.wallet.id
_validate_wave_capacity(data)
from lnbits.settings import settings from lnbits.settings import settings
ext_settings = await get_settings() ext_settings = await get_settings()
@ -416,22 +337,6 @@ async def api_event_update(
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") 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 from lnbits.settings import settings
ext_settings = await get_settings() ext_settings = await get_settings()
@ -448,13 +353,6 @@ async def api_event_update(
if not data.closing_date: if not data.closing_date:
data.closing_date = data.event_end_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. # Explicit field list — never copy `status` from the request body.
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an # Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
# owner editing a fiat-enabled event keeps the fiat config. # owner editing a fiat-enabled event keeps the fiat config.
@ -613,31 +511,6 @@ async def api_tickets_by_user(
return await get_tickets_by_user_id(user_id) 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) @tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
async def api_get_ticket(ticket_id: str) -> Ticket: async def api_get_ticket(ticket_id: str) -> Ticket:
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
@ -699,36 +572,6 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
return data.frontend_url.rstrip("/") 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( async def _issue_free_tickets(
*, *,
event: Event, event: Event,
@ -739,7 +582,6 @@ async def _issue_free_tickets(
promo_code: str | None, promo_code: str | None,
nostr_identifier: str | None, nostr_identifier: str | None,
frontend_root: str, frontend_root: str,
selected_wave: TicketWave,
) -> TicketPaymentRequest: ) -> TicketPaymentRequest:
"""Issue `quantity` free tickets without minting an invoice. """Issue `quantity` free tickets without minting an invoice.
@ -768,11 +610,6 @@ async def _issue_free_tickets(
ticket_id=row_id, ticket_id=row_id,
extra={ extra={
"applied_promo_code": promo_code, "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, "nostr_identifier": nostr_identifier,
"ticket_base_url": frontend_root, "ticket_base_url": frontend_root,
"sats_paid": 0, "sats_paid": 0,
@ -808,23 +645,22 @@ async def api_ticket_create(
if event.canceled: if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
quantity = data.quantity quantity = data.quantity
# `amount_tickets` IS the remaining count — `set_ticket_paid` decrements if event.amount_tickets > 0:
# it on every sale, and upstream reads it the same way everywhere. Do if event.sold >= event.amount_tickets:
# not subtract `sold` from it: `sold` counts the same tickets the raise HTTPException(
# decrement already removed, so doing both takes each sale off twice status_code=HTTPStatus.GONE, detail="Event is sold out."
# (aiolabs/events#34). )
if event.amount_tickets < 1: remaining = event.amount_tickets - event.sold
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") if quantity > remaining:
if quantity > event.amount_tickets: raise HTTPException(
raise HTTPException( status_code=HTTPStatus.BAD_REQUEST,
status_code=HTTPStatus.BAD_REQUEST, detail=f"Only {remaining} ticket(s) remaining for this event.",
detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.", )
)
name = data.name name = data.name
email = data.email email = data.email
user_id = data.user_id 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 refund_address = data.refund_address
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
payment_method = (data.payment_method or "lightning").lower() payment_method = (data.payment_method or "lightning").lower()
@ -833,11 +669,6 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Unsupported payment method.", 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: if nostr_identifier and "@" not in nostr_identifier:
try: try:
nostr_identifier = normalize_public_key(nostr_identifier) nostr_identifier = normalize_public_key(nostr_identifier)
@ -846,50 +677,22 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.", detail="Invalid Nostr identifier.",
) from exc ) from exc
unit_price = event.price_per_ticket
selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id)
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
frontend_root = _resolve_frontend_root(data, request) 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: if promo_code:
promo = find_promo(event, promo_code) # check if promo_code exists in event.extra.promo_codes
if not promo: if promo_code not in [pc.code for pc in event.extra.promo_codes]:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
) )
if not promo.active: # get the promocode
raise HTTPException( promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
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."
),
)
extra["promo_code"] = promo.code extra["promo_code"] = promo.code
# Priced off `selected_wave`, not `event`: since v1.6.8 the price and unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100)
# currency live on the wave, and the event-level fields are a roll-up # Scale by quantity AFTER the promo applies. One invoice, N tickets.
# of the PRIMARY wave (`sync_event_ticket_waves`). Pricing off the price = unit_price * quantity
# 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
)
# Free tickets (final charge 0 — a free event or a 100%-off promo). # Free tickets (final charge 0 — a free event or a 100%-off promo).
# Short-circuit before any invoice / fiat-provider logic: no Lightning # Short-circuit before any invoice / fiat-provider logic: no Lightning
@ -905,16 +708,6 @@ async def api_ticket_create(
promo_code=promo_code, promo_code=promo_code,
nostr_identifier=nostr_identifier, nostr_identifier=nostr_identifier,
frontend_root=frontend_root, 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:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Fiat payments are not enabled for this ticket wave.",
) )
# Organizer-controlled rails (extra.payment_methods; legacy events fall # Organizer-controlled rails (extra.payment_methods; legacy events fall
@ -926,24 +719,23 @@ async def api_ticket_create(
detail="Payment method not enabled for this event.", 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["fiat"] = True
extra["currency"] = selected_wave.currency extra["currency"] = event.currency
extra["fiatAmount"] = price 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": 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_amount = price
fiat_provider = None fiat_provider = None
if payment_method == "fiat": if payment_method == "fiat":
if _is_fiat_currency(selected_wave.currency): if _is_fiat_currency(event.currency):
invoice_unit = selected_wave.currency invoice_unit = event.currency
else: else:
invoice_unit = selected_wave.fiat_currency invoice_unit = event.fiat_currency
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit) fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
extra["fiat"] = True extra["fiat"] = True
extra["currency"] = invoice_unit extra["currency"] = invoice_unit
@ -992,7 +784,6 @@ async def api_ticket_create(
"event_id": event.id, "event_id": event.id,
"quantity": str(quantity), "quantity": str(quantity),
"ticket_ids": ",".join(ticket_ids), "ticket_ids": ",".join(ticket_ids),
**({"promo_code": promo_code} if promo_code else {}),
}, },
} }
@ -1019,12 +810,6 @@ async def api_ticket_create(
ticket_id=row_id, ticket_id=row_id,
extra={ extra={
"applied_promo_code": promo_code, "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, "refund_address": refund_address,
"nostr_identifier": nostr_identifier, "nostr_identifier": nostr_identifier,
"ticket_base_url": frontend_root, "ticket_base_url": frontend_root,
@ -1128,9 +913,7 @@ async def api_ticket_delete(
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) @tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
async def api_ticket_resend_email( async def api_ticket_resend_email(
ticket_id: str, ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> TicketResendResult: ) -> TicketResendResult:
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
if not ticket: if not ticket:
@ -1148,13 +931,16 @@ async def api_ticket_resend_email(
) )
try: try:
return await resend_ticket_email_notification( return await resend_ticket_email_notification(ticket)
ticket, str(request.base_url).rstrip("/")
)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from 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}") @tickets_api_router.put("/register/{ticket_id}")
@ -1256,7 +1042,6 @@ async def api_event_ticket_stats(
"registered_at": ( "registered_at": (
t.reg_timestamp.isoformat() if t.reg_timestamp else None t.reg_timestamp.isoformat() if t.reg_timestamp else None
), ),
"applied_promo_code": t.extra.applied_promo_code,
} }
for t in paid_tickets for t in paid_tickets
], ],
@ -1265,76 +1050,28 @@ async def api_event_ticket_stats(
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse) @qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_qr(ticket_id: str): async def api_ticket_qr(ticket_id: str):
"""PNG of the ticket's scan payload (`ticket://<id>`). """PNG of the ticket's scan payload (`ticket://<id>`), branded with the
instance QR logo. Anonymous by design — it is what the ticket email
Two shapes behind one route. Before the v1.6.8 merge this endpoint embeds — and the id is the same bearer token the ticket page exposes.
existed on both sides — ours was "upstream's, without ticket-image Port of upstream v1.6.8 without ticket-image compositing."""
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) ticket = await get_ticket(ticket_id)
if not ticket: if not ticket:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." 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 = { logo = await load_qr_logo()
"Cache-Control": "no-cache, no-store, must-revalidate", image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
"Pragma": "no-cache", return StreamingResponse(
"Expires": "0", BytesIO(image_png_bytes(image)),
} media_type="image/png",
headers={
waves = ensure_ticket_waves(event) "Cache-Control": "no-cache, no-store, must-revalidate",
wave = next( "Pragma": "no-cache",
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), "Expires": "0",
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) @qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_card(ticket_id: str): async def api_ticket_card(ticket_id: str):
@ -1363,24 +1100,3 @@ async def api_ticket_card(ticket_id: str):
"Cache-Control": "no-cache, no-store, must-revalidate", "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)