Merge upstream v1.6.8 into the aio fork

Brings in ticket waves (per-wave price/currency/stock/fiat), the paginated
ticket endpoint, the organiser ticket-image template, and the SatsPay
on-chain surface. Refs #33.

Resolutions that were not mechanical, and why:

- set_ticket_paid debits the wave named on the ticket, keeping upstream's
  `> 0` guards; ours decremented unconditionally and could go negative.
  The purchase and free-ticket paths now stamp ticket_wave_id /
  ticket_wave_title, without which every sale would debit the primary wave.

- Pricing moved onto the selected wave (basket_totals takes it as a required
  argument, the promo-validate endpoint resolves the same wave through the
  shared _resolve_ticket_wave). event.price_per_ticket is a roll-up of the
  PRIMARY wave since sync_event_ticket_waves, so pricing off the event
  quoted and charged the first wave's price to buyers who picked a later
  one. Regression test added.

- Kept npub support in two places upstream removed it: the purchase
  endpoint's normalize_public_key path and the notification dispatcher.
  Upstream's replacement rejects with "Only NIP-05 Nostr identifiers are
  supported", which is false for this fork. The purchase-side rejection had
  merged in outside any conflict marker.

- _ticket_image_url existed on both sides as two unrelated features. Ours
  (always-attached rendered QR card) is now _ticket_card_url; upstream's
  (organiser template, opt-in per wave) keeps the name. Both are wired into
  the mail, and the /qr/{ticket_id} endpoint — also duplicated on both
  sides, on the same route — is merged into one handler rather than
  registered twice, where the second copy would have been unreachable.

- models._parse_date now accepts a full ISO datetime. Upstream's date-only
  strptime raised ValueError on any event whose closing_date carries a time,
  which create_event produces by defaulting it from event_end_date — it
  would have 500'd the purchase path, the public event gate and the promo
  preview. Reproduced before fixing.

- Dropped upstream's inline make_qr_png (we import a superset from .qr) and
  its duplicate paymentMethodOptions in display.js, which re-derived payment
  options from per-method booleans and offered an on-chain option the
  backend rejects; the submit gate now matches the template's condition.

- Restored imports the merge silently dropped with upstream's npub removal
  (normalize_public_key, normalize_private_key, DEFAULT_NOSTR_RELAYS).

Event create/update stays ours: upstream's combined endpoint would have
replaced the approval workflow and the explicit field allowlist that keeps
`status` out of the request body.

mypy error set is unchanged from HEAD; ruff, black and the 97 tests pass.
This commit is contained in:
Padreug 2026-09-28 22:09:18 +02:00
commit ae5affa44f
14 changed files with 2228 additions and 275 deletions

82
crud.py
View file

@ -1,10 +1,19 @@
import json
from datetime import datetime, timedelta, timezone
from typing import cast
from lnbits.db import Database
from lnbits.db import Database, Filters, Page
from lnbits.helpers import urlsafe_short_hash
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
from .models import (
CreateEvent,
Event,
EventsSettings,
Ticket,
TicketExtra,
TicketFilters,
sync_event_ticket_waves,
)
db = Database("ext_events")
@ -155,6 +164,31 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_paginated(
wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None
) -> Page[Ticket]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
wallet_where = []
values = {}
for idx, wallet_id in enumerate(wallet_ids):
key = f"wallet_id_{idx}"
wallet_where.append(f":{key}")
values[key] = wallet_id
where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"]
return await db.fetch_page(
"SELECT * FROM events.ticket",
where=where,
values=values,
filters=filters,
model=Ticket,
table_name="events.ticket",
)
async def delete_ticket(payment_hash: str) -> None:
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
@ -184,44 +218,55 @@ async def create_event(data: CreateEvent) -> Event:
if not data.closing_date:
data.closing_date = data.event_end_date
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
event = cast(Event, sync_event_ticket_waves(event))
await db.insert("events.events", event)
return event
async def update_event(event: Event) -> Event:
event = cast(Event, sync_event_ticket_waves(event))
await db.update("events.events", event)
return event
async def get_event(event_id: str) -> Event | None:
return await db.fetchone(
event = await db.fetchone(
"SELECT * FROM events.events WHERE id = :id",
{"id": event_id},
Event,
)
return cast(Event, sync_event_ticket_waves(event)) if event else None
async def get_events(wallet_ids: str | list[str]) -> list[Event]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
return await db.fetchall(
events = await db.fetchall(
f"SELECT * FROM events.events WHERE wallet IN ({q})",
model=Event,
)
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
async def get_all_events() -> list[Event]:
"""All events, no wallet filter. Admin-only callers."""
return await db.fetchall(
events = await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC",
model=Event,
)
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def get_public_events() -> list[Event]:
"""Approved, non-canceled events for the public listing."""
return await db.fetchall(
events = await db.fetchall(
"""
SELECT * FROM events.events
WHERE status = 'approved' AND canceled = FALSE
@ -229,14 +274,28 @@ async def get_public_events() -> list[Event]:
""",
model=Event,
)
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def get_pending_events() -> list[Event]:
"""Proposed events awaiting admin approval."""
return await db.fetchall(
events = await db.fetchall(
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
model=Event,
)
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def get_events_pending_republish() -> list[Event]:
@ -249,7 +308,7 @@ async def get_events_pending_republish() -> list[Event]:
Ordered oldest-first so a backlog drains in the order it accrued.
"""
return await db.fetchall(
events = await db.fetchall(
"""
SELECT * FROM events.events
WHERE nostr_publish_pending = TRUE
@ -257,6 +316,13 @@ async def get_events_pending_republish() -> list[Event]:
""",
model=Event,
)
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def get_settings() -> EventsSettings:

173
docs/rebase-playbook.md Normal file
View file

@ -0,0 +1,173 @@
# 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`

145
models.py
View file

@ -1,7 +1,9 @@
import json
from datetime import datetime
from datetime import date, datetime
from urllib.parse import urlsplit
from uuid import uuid4
from lnbits.db import FilterModel
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
PAYMENT_METHODS = ("lightning", "fiat")
@ -41,6 +43,20 @@ class PromoCode(BaseModel):
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. `EventExtra` adds
the organizer-only promo codes on top; `PublicEventExtra` is this base,
@ -76,6 +92,20 @@ class EventExtraBase(BaseModel):
class EventExtra(EventExtraBase):
promo_codes: list[PromoCode] = 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.
ticket_waves: list[TicketWave] = Field(default_factory=list)
# Upstream's SatsPay-backed on-chain config. Removed in the commit
# that follows this merge — we use native lnbits on-chain instead
# (aiolabs/events#41). Kept here so the removal is a legible diff
# against what upstream shipped rather than an invisible omission.
onchain_enabled: bool = False
onchain_wallet_id: str | None = None
onchain_zeroconf: bool = False
onchain_fasttrack: bool = False
PublicEventExtra = EventExtraBase
@ -181,11 +211,17 @@ def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> lis
class PromoValidateRequest(BaseModel):
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork has one
price per event, so a plain `quantity` replaces it."""
"""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):
@ -211,6 +247,8 @@ class EventsSettings(BaseModel):
class TicketExtra(BaseModel):
applied_promo_code: str | None = None
ticket_wave_id: str | None = None
ticket_wave_title: str | None = None
sats_paid: int | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
@ -218,12 +256,16 @@ class TicketExtra(BaseModel):
email_notification_sent: bool = False
nostr_notification_sent: bool = False
refunded: bool = False
onchain: bool = False
onchain_address: str | None = None
satspay_charge_id: str | None = None
class CreateTicket(BaseModel):
name: str | None = None
email: EmailStr | None = None
user_id: str | None = None # LNbits user id (alternative to name+email)
ticket_wave_id: str | None = None
promo_code: str | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
@ -325,3 +367,100 @@ class TicketPaymentRequest(BaseModel):
# the door). Buyers fetch these after payment to render N QRs in
# My Tickets.
ticket_ids: list[str] = Field(default_factory=list)
onchain_amount_sat: int | None = None
satspay_charge_url: str | 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 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

@ -11,7 +11,7 @@ from __future__ import annotations
from collections import Counter
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket, TicketWave
SAT_UNITS = ("sat", "sats")
@ -66,17 +66,26 @@ def basket_totals(
codes: list[str],
quantity: int,
usage: dict[str, int],
wave: TicketWave,
) -> BasketTotals:
"""Price `quantity` tickets with the first applicable code in `codes`.
"""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 = event.currency or "sat"
subtotal = round_amount(event.price_per_ticket * quantity, currency)
currency = wave.currency or "sat"
subtotal = round_amount(wave.price_per_ticket * quantity, currency)
totals = BasketTotals(
subtotal=subtotal, discount=0, total=subtotal, currency=currency
)

View file

@ -9,7 +9,9 @@ from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, make_msgid
from html import escape
from typing import Any
import httpx
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import send_nostr_dm
from lnbits.core.services.notifications import send_user_notification
@ -26,7 +28,13 @@ from .crud import (
update_event,
update_ticket,
)
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
from .models import (
Event,
NotificationDeliveryResult,
Ticket,
TicketResendResult,
ensure_ticket_waves,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .promo import promo_usage
from .qr import (
@ -37,6 +45,59 @@ from .qr import (
ticket_card_filename,
)
async def fetch_watchonly_config(api_key: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config",
headers={"X-API-KEY": api_key},
)
resp.raise_for_status()
return resp.json()
async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet",
headers={"X-API-KEY": api_key},
params={"network": network},
)
resp.raise_for_status()
return resp.json()
async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}",
headers={"X-API-KEY": api_key},
)
resp.raise_for_status()
return resp.json()
async def get_satspay_charge(api_key: str, charge_id: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge/{charge_id}",
headers={"X-API-KEY": api_key},
)
resp.raise_for_status()
return resp.json()
async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.post(
url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge",
headers={"X-API-KEY": api_key},
json=data,
)
resp.raise_for_status()
return resp.json()
DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io",
"wss://relay.primal.net",
@ -70,6 +131,24 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid"
event.sold += 1
# Debit the wave the buyer actually bought from. v1.6.8 moved
# inventory onto waves; the event-level counter is only the
# fallback for events that predate them, and is itself a derived
# roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are
# kept — ours decremented unconditionally and could go negative.
ticket_waves = event.extra.ticket_waves or []
if ticket_waves:
selected_wave = next(
(
wave
for wave in ticket_waves
if wave.id == ticket.extra.ticket_wave_id
),
ticket_waves[0],
)
if selected_wave.amount_tickets > 0:
selected_wave.amount_tickets -= 1
elif event.amount_tickets > 0:
event.amount_tickets -= 1
# Flag inside this same write: the counters and "the relay does
# not know about them yet" land atomically, so a crash between
@ -116,10 +195,17 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
await _deliver_ticket_notifications(ticket, event)
async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult:
async def resend_ticket_email_notification(
ticket: Ticket, base_url: str | None = None
) -> TicketResendResult:
"""Organizer-triggered re-delivery of the ticket email. Bypasses the
per-event `email_notifications` opt-in (the organizer asked explicitly)
but still needs the instance mailer and an address on the ticket."""
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)
if not event:
raise ValueError("Event does not exist.")
@ -127,7 +213,12 @@ async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult
raise ValueError("Email notifications are not enabled.")
if not ticket.email:
raise ValueError("Ticket does not have an email address.")
if base_url:
ticket.extra.ticket_base_url = base_url.rstrip("/")
# email-only: this is the *email* resend endpoint. Upstream calls
# `_deliver_ticket_notifications(ticket, event)` unqualified, which in
# our fork would also fire a Nostr DM the organiser did not ask for.
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
@ -163,17 +254,35 @@ def _ticket_details(ticket: Ticket, event: Event) -> str:
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
return (
"""Text part of the ticket mail.
Carries up to two images, which are NOT the same thing (see the note on
`_ticket_card_url` vs `_ticket_image_url`): our rendered QR card, always
present, and the organiser's uploaded template, only when the buyer's
wave opted into it. They had the same label before the v1.6.8 merge
because only one of them existed; now that both can appear the labels
have to distinguish them.
"""
message = (
f"{base_message}\n\n{_ticket_details(ticket, event)}"
f"\n\nTicket image: {_ticket_image_url(ticket)}"
f"\n\nTicket card: {_ticket_card_url(ticket)}"
)
ticket_image_url = _ticket_image_url(ticket, event)
if ticket_image_url:
message = f"{message}\n\nTicket image: {ticket_image_url}"
return message
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
"""HTML twin of the text part. Deliberately no <img>: the card travels
as an attachment (renders inline in most clients, works offline, and
keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
links."""
"""HTML twin of the text part.
Deliberately no <img> for our own ticket card: it travels as an
attachment (renders inline in most clients, works offline, and keeps
SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link.
The organiser's uploaded ticket image is a remote URL with no attachment
to fall back on, so that one does get upstream's <img> — the surrounding
ticket details keep the mail from being image-only either way.
"""
text_message = _ticket_delivery_message(ticket, event, base_message)
html = escape(text_message)
html = re.sub(
@ -181,7 +290,17 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str)
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
html,
)
return f"<p>{html.replace(chr(10), '<br />')}</p>"
html_message = f"<p>{html.replace(chr(10), '<br />')}</p>"
ticket_image_url = _ticket_image_url(ticket, event)
if not ticket_image_url:
return html_message
return (
f"{html_message}"
f'<p><img src="{escape(ticket_image_url, quote=True)}" alt="Ticket image" '
'style="max-width: 200px; height: auto;" /></p>'
)
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
@ -200,7 +319,16 @@ async def _deliver_ticket_notifications(
) -> TicketResendResult:
"""Send the ticket by every configured channel and report per-channel
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
opt-ins when not None; the instance-level prerequisites always apply."""
opt-ins when not None; the instance-level prerequisites always apply.
Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery`
check that errors with "Only NIP-05 Nostr identifiers are supported."
That guard is NOT taken here: it encodes upstream's limitation, not ours.
`_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to
core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard
would silently refuse identifiers we deliver to today — and say so with
a message that would be false for this fork.
"""
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
updated = False
@ -376,16 +504,36 @@ def _ticket_url(ticket: Ticket) -> str:
return f"{base_url}/events/ticket/{ticket.id}"
def _ticket_image_url(ticket: Ticket) -> str:
"""The ticket card PNG is served by THIS extension on the LNbits host, so
it is built from `lnbits_baseurl` even when `ticket_base_url` points at
a separate web app (deviation from upstream, which assumes both are the
same host)."""
def _ticket_card_url(ticket: Ticket) -> str:
"""Our rendered ticket-card PNG — always available, and served by THIS
extension on the LNbits host, so it is built from `lnbits_baseurl` even
when `ticket_base_url` points at a separate web app (deviation from
upstream, which assumes both are the same host)."""
return (
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}"
)
def _ticket_image_url(ticket: Ticket, event: Event) -> str | None:
"""Upstream's organiser-uploaded ticket template, opted into per wave.
Distinct from `_ticket_card_url`: that is our own rendered card and is
always attached, this is a template the organiser uploads and enables
on a specific wave. They shared a name before the v1.6.8 merge, which
made them look like one feature.
"""
waves = ensure_ticket_waves(event)
wave = next(
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
waves[0],
)
if not wave.use_ticket_image:
return None
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
return f"{base_url}/events/api/v1/qr/{ticket.id}"
async def refund_tickets(event_id: str):
"""
Refund tickets for an event that has not met the minimum ticket requirement.

BIN
static/image/ticket.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -13,6 +13,7 @@ window.PageEventsDisplay = {
email: '',
refund: '',
nostr_identifier: '',
ticket_wave_id: null,
payment_method: 'lightning'
}
},
@ -46,26 +47,68 @@ window.PageEventsDisplay = {
paymentMethods() {
// Mirrors `effective_payment_methods` on the backend: an explicit
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
// Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat`
// is only the PRIMARY wave's, so prefer the active wave when there is
// one — otherwise a later fiat-enabled wave would not offer fiat.
const explicit = this.event?.extra?.payment_methods || []
if (explicit.length) return explicit
return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])]
const allowFiat = this.selectedTicketWave
? this.selectedTicketWave.allow_fiat
: this.event?.allow_fiat
return ['lightning', ...(allowFiat ? ['fiat'] : [])]
},
activeTicketWaves() {
const today = new Date().toISOString().slice(0, 10)
return (this.event?.extra?.ticket_waves || []).filter(
wave =>
wave.amount_tickets > 0 &&
wave.opening_date <= today &&
wave.closing_date >= today
)
},
selectedTicketWave() {
return (
this.activeTicketWaves.find(
wave => wave.id === this.formDialog.data.ticket_wave_id
) ||
this.activeTicketWaves[0] ||
null
)
},
showTicketWaveSelector() {
return this.activeTicketWaves.length > 1
},
allowFiatCheckout() {
return this.paymentMethods.includes('fiat')
},
paymentMethodOptions() {
// Options come from `paymentMethods` — the same list the backend
// validates against in `effective_payment_methods` — rather than being
// re-derived from per-method booleans. v1.6.8 added a second copy of
// this computed that built the list from `allow_fiat`/`onchain_enabled`
// instead; because it was defined later in the object it silently won
// the merge, and it offers a "Bitcoin" option for any event with
// `extra.onchain_enabled` even though the backend rejects `onchain`
// unless it is in `extra.payment_methods` (views_api: "not in
// effective_payment_methods"). Labels below are upstream's; the source
// of the list is ours.
const labels = {
lightning: 'Lightning',
fiat: this.fiatCheckoutLabel,
onchain: 'Bitcoin'
}
return this.paymentMethods.map(method => ({
value: method,
label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning'
label: labels[method] || method
}))
},
fiatCheckoutLabel() {
if (!this.allowFiatCheckout) return 'Fiat'
const unit = ['sat', 'sats'].includes(
(this.event?.currency || '').toLowerCase()
(this.selectedTicketWave?.currency || '').toLowerCase()
)
? this.event?.fiat_currency
: this.event?.currency
? this.selectedTicketWave?.fiat_currency
: this.selectedTicketWave?.currency
return `Fiat (${(unit || 'GBP').toUpperCase()})`
},
allowEmailNotifications() {
@ -82,6 +125,16 @@ window.PageEventsDisplay = {
'GET',
`/events/api/v1/events/${this.eventId}`
)
const activeWaves = (data.extra?.ticket_waves || []).filter(wave => {
const today = new Date().toISOString().slice(0, 10)
return (
wave.amount_tickets > 0 &&
wave.opening_date <= today &&
wave.closing_date >= today
)
})
this.formDialog.data.ticket_wave_id =
activeWaves.length === 1 ? activeWaves[0].id : null
return data
} catch (error) {
this.eventErrorLabel = 'Event unavailable.'
@ -94,6 +147,10 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.ticket_wave_id =
this.activeTicketWaves.length === 1
? this.activeTicketWaves[0].id
: null
this.formDialog.data.promo_code = ''
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
@ -108,6 +165,13 @@ window.PageEventsDisplay = {
this.paymentWebsocket.close()
this.paymentWebsocket = null
}
this.paymentReq = null
this.receive = {
show: false,
status: 'pending',
paymentReq: null,
isFiat: false
}
},
nameValidation(val) {
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
@ -130,6 +194,10 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.ticket_wave_id =
this.activeTicketWaves.length === 1
? this.activeTicketWaves[0].id
: null
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
Quasar.Notify.create({
@ -160,12 +228,26 @@ window.PageEventsDisplay = {
{
name: this.formDialog.data.name,
email: this.formDialog.data.email,
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
promo_code: this.formDialog.data.promo_code || null,
refund_address: this.formDialog.data.refund || null,
nostr_identifier: this.formDialog.data.nostr_identifier || null,
payment_method: this.formDialog.data.payment_method
// Gate matches the template's (`paymentMethods.length > 1`).
// v1.6.8 gated on `showPaymentMethodSelector`
// (`allowFiatCheckout || allowOnchain`), a different condition:
// where the two disagreed the buyer's visible choice was
// silently replaced with 'lightning'.
payment_method:
this.paymentMethods.length > 1
? this.formDialog.data.payment_method
: this.paymentMethods[0] || 'lightning'
}
)
if (data.satspay_charge_url) {
window.location.href = data.satspay_charge_url
return
}
const isFiat = Boolean(data.is_fiat)
this.paymentReq = isFiat
? data.fiat_payment_request || null
@ -197,7 +279,7 @@ window.PageEventsDisplay = {
const url = new URL(window.location)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/api/v1/ws/${paymentHash}`
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
url.search = ''
url.hash = ''
@ -206,7 +288,7 @@ window.PageEventsDisplay = {
ws.onmessage = event => {
const data = JSON.parse(event.data)
if (data.pending === false) {
if (data.paid === true) {
this.paymentSuccess(paymentHash)
ws.close()
}
@ -215,8 +297,12 @@ window.PageEventsDisplay = {
console.error('WebSocket error:', error)
}
ws.onclose = () => {
if (this.paymentWebsocket === ws) {
if (this.paymentWebsocket !== ws) return
this.paymentWebsocket = null
if (this.receive.show) {
setTimeout(() => {
if (this.receive.show) this.paymentWatcher(paymentHash)
}, 3000)
}
}
}

View file

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

View file

@ -4,7 +4,10 @@ window.PageEvents = {
return {
events: [],
tickets: [],
allPaidTickets: [],
resendingTicketEmails: [],
isUploadingTicketTemplate: false,
ticketImageUploadTarget: null,
currencies: [],
pendingEvents: [],
allUserEvents: [],
@ -25,7 +28,12 @@ window.PageEvents = {
label: 'Owner',
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: 'event_start_date',
@ -39,12 +47,6 @@ window.PageEvents = {
label: 'End date',
field: 'event_end_date'
},
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{
name: 'canceled',
align: 'left',
@ -129,8 +131,14 @@ window.PageEvents = {
}
},
ticketsTable: {
loading: false,
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: 'email', align: 'left', label: 'Email', field: 'email'},
{
@ -152,6 +160,12 @@ window.PageEvents = {
label: 'Registered',
field: 'registered'
},
{
name: 'nostr',
align: 'left',
label: 'Nostr',
field: row => row.extra?.nostr_identifier || ''
},
{
name: 'promo_code',
align: 'left',
@ -161,7 +175,11 @@ window.PageEvents = {
{name: 'id', align: 'left', label: 'ID', field: 'id'}
],
pagination: {
rowsPerPage: 10
sortBy: 'time',
descending: true,
page: 1,
rowsPerPage: 10,
rowsNumber: 10
}
},
// Rails an organizer can enable per event. Mirrors the webapp's
@ -218,12 +236,44 @@ window.PageEvents = {
categories: [],
extra: {
payment_methods: ['lightning'],
ticket_waves: [],
promo_codes: [],
notification_subject: '',
notification_body: ''
}
}
},
ticketWaveDialog: {
show: false,
eventId: null,
wallet: null,
editingWaveId: null,
data: {
id: null,
title: '',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
},
promoCodesDialog: {
show: false,
data: {
id: null,
wallet: null,
name: '',
extra: {
promo_codes: []
}
}
},
onchainWallets: []
}
},
computed: {
@ -248,19 +298,196 @@ window.PageEvents = {
}
},
methods: {
shortenId(value) {
if (!value) return ''
return value.length > 4 ? `${value.slice(0, 4)}...` : value
},
async loadOnchainWallets() {
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
if (!wallet) return
try {
const {data} = await LNbits.api.request(
'GET',
'/events/api/v1/events/onchain/status',
wallet.adminkey
)
this.onchainWallets = data.available ? data.wallets || [] : []
} catch {
this.onchainWallets = []
}
},
async confirmOnchainTicket(ticket) {
const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet})
if (!wallet) return
try {
await LNbits.api.request(
'PUT',
`/events/api/v1/tickets/${ticket.id}/onchain-confirm`,
wallet.adminkey
)
Quasar.Notify.create({
type: 'positive',
message: 'Onchain payment confirmed.',
icon: null
})
await this.getTickets()
await this.getAllTickets()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
primaryTicketWave(data = this.formDialog.data) {
if (!data.extra) data.extra = {}
if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
data.extra.ticket_waves = [
{
id: 'primary',
title: 'Primary wave',
opening_date: data.closing_date || '',
closing_date: data.closing_date || '',
currency: data.currency || 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: Boolean(data.allow_fiat),
fiat_currency: data.fiat_currency || 'GBP',
amount_tickets: data.amount_tickets || 0,
price_per_ticket: data.price_per_ticket || 0
}
]
}
return data.extra.ticket_waves[0]
},
syncPrimaryWaveFromForm(data = this.formDialog.data) {
const primaryWave = this.primaryTicketWave(data)
primaryWave.title = primaryWave.title || 'Primary wave'
primaryWave.opening_date = primaryWave.opening_date || ''
primaryWave.closing_date = data.closing_date || ''
primaryWave.currency = data.currency || 'sats'
primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image)
primaryWave.ticket_image_id = primaryWave.ticket_image_id || null
primaryWave.allow_fiat = Boolean(data.allow_fiat)
primaryWave.fiat_currency = data.fiat_currency || 'GBP'
primaryWave.amount_tickets = Number(data.amount_tickets || 0)
primaryWave.price_per_ticket = Number(data.price_per_ticket || 0)
return primaryWave
},
hydrateEventForm(data) {
const formData = {
...data,
extra: {
...(data.extra || {}),
ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])]
}
}
const primaryWave = this.primaryTicketWave(formData)
formData.currency = primaryWave.currency || formData.currency || 'sats'
formData.allow_fiat = Boolean(primaryWave.allow_fiat)
formData.fiat_currency = primaryWave.fiat_currency || 'GBP'
formData.amount_tickets = primaryWave.amount_tickets
formData.price_per_ticket = primaryWave.price_per_ticket
formData.closing_date =
primaryWave.closing_date || formData.closing_date || ''
return formData
},
isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
},
getTickets() {
LNbits.api
.request(
normalizePromoCodes(promoCodes = []) {
return promoCodes
.filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
code: code.code.trim().toUpperCase(),
// fork-only: blank / 0 = unlimited; used_count is derived server-side
max_uses: code.max_uses ? Number(code.max_uses) : null
}))
},
templateDownloadUrl() {
return '/events/static/image/ticket.jpg'
},
async uploadAssetFile(file) {
const form = new FormData()
form.append('file', file)
form.append('public_asset', 'true')
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
form
)
return data.id
},
triggerTicketImageUpload(target) {
this.ticketImageUploadTarget = target
this.$refs.ticketImageUpload.value = null
this.$refs.ticketImageUpload.click()
},
async handleTicketImageSelected(event) {
const file = event.target.files?.[0]
if (!file || !this.ticketImageUploadTarget) return
this.isUploadingTicketTemplate = true
try {
const assetId = await this.uploadAssetFile(file)
if (this.ticketImageUploadTarget === 'primary') {
const wave = this.primaryTicketWave()
wave.use_ticket_image = true
wave.ticket_image_id = assetId
} else if (this.ticketImageUploadTarget === 'dialog') {
this.ticketWaveDialog.data.use_ticket_image = true
this.ticketWaveDialog.data.ticket_image_id = assetId
}
Quasar.Notify.create({
type: 'positive',
message: 'Ticket template uploaded.',
icon: null
})
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.isUploadingTicketTemplate = false
this.ticketImageUploadTarget = null
}
},
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',
'/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].adminkey
)
.then(response => {
this.tickets = response.data.filter(e => e.paid)
})
this.allPaidTickets = data.filter(ticket => ticket.paid)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async getTickets(props) {
try {
this.ticketsTable.loading = true
const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props)
const {data} = await LNbits.api.request(
'GET',
`/events/api/v1/tickets/paginated?all_wallets=true&${params}`,
this.g.user.wallets[0].adminkey
)
this.tickets = data.data
this.ticketsTable.pagination.rowsNumber = data.total
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.ticketsTable.loading = false
}
},
deleteTicket(ticketId) {
const tickets = _.findWhere(this.tickets, {id: ticketId})
@ -275,10 +502,9 @@ window.PageEvents = {
'/events/api/v1/tickets/' + ticketId,
wallet.adminkey
)
.then(response => {
this.tickets = _.reject(this.tickets, function (obj) {
return obj.id == ticketId
})
.then(async () => {
await this.getTickets()
await this.getAllTickets()
})
.catch(LNbits.utils.notifyApiError)
})
@ -296,14 +522,30 @@ window.PageEvents = {
wallet.adminkey
)
.then(response => {
const result = response.data
this.tickets = this.tickets.map(obj =>
obj.id === ticket.id ? response.data : obj
obj.id === ticket.id ? result.ticket : obj
)
if (result.email?.attempted) {
Quasar.Notify.create({
type: 'positive',
message: 'Ticket email resent.',
type: result.email.sent ? 'positive' : 'negative',
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)
.finally(() => {
@ -313,7 +555,7 @@ window.PageEvents = {
})
},
exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
},
getEvents() {
LNbits.api
@ -520,16 +762,14 @@ window.PageEvents = {
data.location = (data.location || '').trim() || null
data.categories = data.categories || []
data.closing_date = data.closing_date || null
// Fold the form's day+time pairs first, then let upstream's helper
// mirror the form fields onto the primary wave — order matters, the
// sync reads closing_date/currency/amount_tickets off `data`.
this.syncPrimaryWaveFromForm(data)
if (data.extra?.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
code: code.code.trim().toUpperCase(),
// blank / 0 = unlimited; used_count is derived server-side
max_uses: code.max_uses ? Number(code.max_uses) : null
}))
data.extra.promo_codes = this.normalizePromoCodes(
data.extra.promo_codes
)
}
const methods = data.extra?.payment_methods || []
if (methods.length === 0) {
@ -549,6 +789,7 @@ window.PageEvents = {
} else if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
}
this.syncPrimaryWaveFromForm(data)
if (data.id) {
this.updateEvent(wallet, data)
@ -561,10 +802,14 @@ window.PageEvents = {
if (data && data.id) {
const start = this.splitDateTime(data.event_start_date)
const end = this.splitDateTime(data.event_end_date)
// Seed from upstream's hydrator (it materialises ticket_waves and
// mirrors the primary wave onto the flat form fields), then layer
// our fork-only fields on top.
const hydrated = this.hydrateEventForm(data)
this.formDialog.data = {
...data,
...hydrated,
extra: {
...(data.extra || {}),
...(hydrated.extra || {}),
// Events created before extra.payment_methods existed carry an
// empty list; show the rails the backend actually accepts for
// them (Lightning always, fiat when allow_fiat).
@ -596,6 +841,25 @@ window.PageEvents = {
min_tickets: 1,
email_notifications: false,
nostr_notifications: false,
onchain_enabled: false,
onchain_wallet_id: null,
onchain_zeroconf: false,
onchain_fasttrack: false,
ticket_waves: [
{
id: 'primary',
title: 'Primary wave',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
],
promo_codes: [],
notification_subject: '',
notification_body: ''
@ -603,6 +867,12 @@ window.PageEvents = {
}
}
this.formDialog.show = true
if (
this.formDialog.data.wallet &&
this.formDialog.data.extra?.onchain_enabled
) {
this.loadOnchainWallets()
}
},
resetEventDialog() {
this.formDialog.show = false
@ -614,8 +884,27 @@ window.PageEvents = {
categories: [],
extra: {
payment_methods: ['lightning'],
conditional: false,
min_tickets: 1,
email_notifications: false,
nostr_notifications: false,
onchain_enabled: false,
onchain_wallet_id: null,
ticket_waves: [
{
id: 'primary',
title: 'Primary wave',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
],
promo_codes: [],
notification_subject: '',
notification_body: ''
@ -636,6 +925,179 @@ window.PageEvents = {
const link = _.findWhere(this.events, {id: formId})
this.openEventDialog(link)
},
openTicketWaveDialog(event, wave = null) {
const primaryWave = (event.extra?.ticket_waves || [])[0] || {}
const isEditing = Boolean(wave)
this.ticketWaveDialog = {
show: true,
eventId: event.id,
wallet: event.wallet,
editingWaveId: wave?.id || null,
data: {
id: wave?.id || null,
title: wave?.title || '',
opening_date: wave?.opening_date || '',
closing_date: wave?.closing_date || '',
currency:
wave?.currency || primaryWave.currency || event.currency || 'sats',
use_ticket_image: Boolean(wave?.use_ticket_image),
ticket_image_id: wave?.ticket_image_id || null,
allow_fiat: isEditing
? Boolean(wave?.allow_fiat)
: Boolean(primaryWave.allow_fiat ?? event.allow_fiat),
fiat_currency:
wave?.fiat_currency ||
primaryWave.fiat_currency ||
event.fiat_currency ||
'GBP',
amount_tickets: wave?.amount_tickets || 0,
price_per_ticket:
wave?.price_per_ticket ||
primaryWave.price_per_ticket ||
event.price_per_ticket ||
0
}
}
},
resetTicketWaveDialog() {
this.ticketWaveDialog = {
show: false,
eventId: null,
wallet: null,
editingWaveId: null,
data: {
id: null,
title: '',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
}
},
saveTicketWave() {
const event = _.findWhere(this.events, {
id: this.ticketWaveDialog.eventId
})
const wallet = _.findWhere(this.g.user.wallets, {
id: this.ticketWaveDialog.wallet
})
if (!event || !wallet) return
const payload = {
...event,
extra: {
...event.extra,
ticket_waves: (event.extra?.ticket_waves || []).map(existingWave =>
existingWave.id === this.ticketWaveDialog.editingWaveId
? {...this.ticketWaveDialog.data}
: existingWave
)
}
}
if (!this.ticketWaveDialog.editingWaveId) {
payload.extra.ticket_waves.push({...this.ticketWaveDialog.data})
}
if (payload.extra?.promo_codes) {
payload.extra.promo_codes = this.normalizePromoCodes(
payload.extra.promo_codes
)
}
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + payload.id,
wallet.adminkey,
payload
)
.then(response => {
this.events = this.events.map(item =>
item.id === payload.id ? response.data : item
)
Quasar.Notify.create({
type: 'positive',
message: this.ticketWaveDialog.editingWaveId
? 'Ticket wave updated.'
: 'Ticket wave added.',
icon: null
})
this.resetTicketWaveDialog()
})
.catch(LNbits.utils.notifyApiError)
},
openPromoCodesDialog(event) {
this.promoCodesDialog.data = {
...event,
extra: {
...event.extra,
promo_codes: [...(event.extra?.promo_codes || [])]
}
}
this.promoCodesDialog.show = true
},
resetPromoCodesDialog() {
this.promoCodesDialog.show = false
this.promoCodesDialog.data = {
id: null,
wallet: null,
name: '',
extra: {
promo_codes: []
}
}
},
addPromoCodeToDialog() {
this.promoCodesDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true,
// fork-only: null = unlimited uses
max_uses: null
})
},
savePromoCodes() {
const data = this.promoCodesDialog.data
const wallet = _.findWhere(this.g.user.wallets, {
id: data.wallet
})
if (!wallet) return
const payload = {
...data,
extra: {
...data.extra,
promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || [])
}
}
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + data.id,
wallet.adminkey,
payload
)
.then(response => {
this.events = this.events.map(event =>
event.id === data.id ? response.data : event
)
Quasar.Notify.create({
type: 'positive',
message: 'Promo codes updated.',
icon: null
})
this.resetPromoCodesDialog()
})
.catch(LNbits.utils.notifyApiError)
},
updateEvent(wallet, data) {
LNbits.api
.request(
@ -702,6 +1164,7 @@ window.PageEvents = {
async created() {
if (this.g.user.wallets.length) {
this.getTickets()
this.getAllTickets()
this.getEvents()
this.getSettings()
this.getPendingEvents()

View file

@ -243,44 +243,101 @@
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<div class="q-pa-md">
<div class="text-subtitle1 q-mb-md">Promo codes</div>
<div class="row items-center q-mb-md">
<div class="text-subtitle1">Ticket waves</div>
<q-btn
round
dense
unelevated
color="primary"
icon="add"
class="q-ml-sm"
@click="openTicketWaveDialog(props.row)"
></q-btn>
</div>
<div class="column q-mb-lg">
<div class="column">
<div
v-if="props.row.extra.promo_codes.length == 0"
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="
`${wave.title} - ${wave.opening_date} to ${
wave.closing_date
} - ${
isFiatCurrency(wave.currency)
? LNbits.utils.formatCurrency(
Number(
wave.price_per_ticket || 0
).toFixed(2),
wave.currency
)
: `${wave.price_per_ticket} sats`
} - ${wave.amount_tickets} tickets - ${soldTicketsForWave(
props.row.id,
wave.id
)} sold`
"
></span>
</q-chip>
</div>
</div>
</div>
<div class="row items-center q-mb-md">
<div class="text-subtitle1">Promo codes</div>
<q-btn
round
dense
unelevated
color="primary"
icon="edit"
class="q-ml-sm"
@click="openPromoCodesDialog(props.row)"
></q-btn>
</div>
<div class="column">
<div
v-if="
props.row.extra.promo_codes.filter(
code => code.active
).length == 0
"
class="text-caption"
>
No active promo codes for this event.
</div>
<div class="row q-col-gutter-sm">
<div
v-for="(
code, index
) in props.row.extra.promo_codes.filter(
code => code.active
)"
:key="index"
class="col-auto q-mb-sm"
>
No promo codes for this event.
</div>
<div
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-chip
square
size="md"
clickable
@click="utils.copyText(code.code.toUpperCase())"
>
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip>
</div>
<div class="col-auto">
Discount:
<span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span
:class="code.active ? 'text-green' : 'text-grey'"
v-text="code.active ? 'Active' : 'Inactive'"
v-text="
`${code.code.toUpperCase()} - ${code.discount_percent}%`
"
></span>
</q-chip>
</div>
</div>
</div>
@ -308,9 +365,11 @@
dense
flat
:rows="tickets"
:loading="ticketsTable.loading"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
@request="getTickets"
>
<template v-slot:header="props">
<q-tr :props="props">
@ -349,6 +408,18 @@
>
<q-tooltip>Resend ticket email</q-tooltip>
</q-btn>
<q-btn
v-if="props.row.extra?.onchain && !props.row.paid"
flat
dense
size="xs"
@click="confirmOnchainTicket(props.row)"
icon="currency_bitcoin"
color="orange"
class="q-ml-xs"
>
<q-tooltip>Confirm onchain payment</q-tooltip>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
@ -597,6 +668,39 @@
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mt-lg q-mb-md">
Primary ticket wave (you can add other waves later)
</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="primaryTicketWave().title"
type="text"
label="Wave title"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="primaryTicketWave().opening_date"
type="date"
label="Ticket opening date"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
label="Ticket closing date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
@ -674,6 +778,49 @@
</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
v-if="acceptsFiat && isSatPrice"
filled
@ -718,119 +865,30 @@
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-6"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
:hint="
formDialog.data.extra.promo_codes[index].used_count
? 'Used ' +
formDialog.data.extra.promo_codes[index].used_count +
(formDialog.data.extra.promo_codes[index].max_uses
? ' / ' +
formDialog.data.extra.promo_codes[index].max_uses
: '')
: ''
"
>
<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-3"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
min="0"
max="100"
></q-input>
<q-input
class="col-3"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].max_uses
"
type="number"
label="Max uses"
placeholder="∞"
min="1"
hint="Blank = unlimited"
>
<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,
max_uses: null
})
"
>Add Promo Code</q-btn
>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
<div class="text-caption">
Send the paid ticket link automatically by email or Nostr DM.
</div>
<div class="row items-center q-col-gutter-md">
<div class="col-auto">
<q-toggle
v-model="formDialog.data.extra.email_notifications"
label="Email notifications"
left-label
></q-toggle>
</div>
<div class="col-auto">
<q-toggle
v-model="formDialog.data.extra.nostr_notifications"
label="Nostr notifications"
left-label
></q-toggle>
</q-expansion-item>
<q-separator class="q-my-md"></q-separator>
</div>
</div>
<div
v-if="formDialog.data.extra.email_notifications"
class="q-mt-md"
>
<q-input
filled
dense
@ -840,6 +898,7 @@
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"
@ -847,6 +906,57 @@
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
</div>
</q-expansion-item>
<q-expansion-item
group="advanced"
icon="view_in_ar"
label="Onchain payments"
>
<div class="q-mt-lg">
<div class="text-caption q-mb-md">
Accept Bitcoin onchain payments. Requires the
<strong>SatsPay</strong> and <strong>Watchonly</strong>
extensions to be installed and enabled.
</div>
<q-toggle
v-model="formDialog.data.extra.onchain_enabled"
label="Accept onchain payments"
left-label
@update:model-value="val => val && loadOnchainWallets()"
></q-toggle>
<q-select
v-if="formDialog.data.extra.onchain_enabled"
filled
dense
class="q-mt-md"
v-model="formDialog.data.extra.onchain_wallet_id"
label="Watchonly wallet"
:options="
onchainWallets.map(w => ({
label: w.title || w.id,
value: w.id
}))
"
emit-value
map-options
hint="Bitcoin watchonly wallet for receiving onchain payments"
></q-select>
<div v-if="formDialog.data.extra.onchain_enabled" class="q-mt-md">
<q-toggle
v-model="formDialog.data.extra.onchain_zeroconf"
label="Zero-conf (accept unconfirmed transactions)"
left-label
></q-toggle>
<q-toggle
v-model="formDialog.data.extra.onchain_fasttrack"
label="Fasttrack (treat pending as paid)"
left-label
></q-toggle>
</div>
</div>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
@ -863,7 +973,9 @@
:disable="
!formDialog.data.wallet ||
!formDialog.data.name ||
!formDialog.data.event_start_day
!formDialog.data.event_start_day ||
primaryTicketWave().title == null ||
primaryTicketWave().opening_date == null
"
type="submit"
>Create Event</q-btn
@ -875,5 +987,275 @@
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="promoCodesDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="savePromoCodes" class="q-gutter-md">
<div class="text-subtitle1">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in promoCodesDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="promoCodesDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="
promoCodesDialog.data.extra.promo_codes[index].active
"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
promoCodesDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-2"
filled
dense
v-model.number="
promoCodesDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
:hint="
promoCodesDialog.data.extra.promo_codes[index].used_count
? 'Used ' +
promoCodesDialog.data.extra.promo_codes[index].used_count +
(promoCodesDialog.data.extra.promo_codes[index].max_uses
? ' / ' +
promoCodesDialog.data.extra.promo_codes[index].max_uses
: '')
: ''
"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="
promoCodesDialog.data.extra.promo_codes.splice(index, 1)
"
></q-btn>
</template>
</q-input>
<q-input
class="col-2"
filled
dense
v-model.number="
promoCodesDialog.data.extra.promo_codes[index].max_uses
"
type="number"
label="Max uses"
placeholder="∞"
min="1"
hint="Blank = unlimited"
></q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn @click="addPromoCodeToDialog">Add Promo Code</q-btn>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
>Save Promo Codes</q-btn
>
<q-btn
flat
color="grey"
class="q-ml-auto"
@click="resetPromoCodesDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="ticketWaveDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="saveTicketWave" class="q-gutter-md">
<div
class="text-subtitle1"
v-text="
ticketWaveDialog.editingWaveId
? 'Edit Ticket Wave'
: 'Add Ticket Wave'
"
></div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.title"
type="text"
label="Wave title"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.opening_date"
type="date"
label="Ticket opening date"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.closing_date"
type="date"
label="Ticket closing date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
filled
dense
v-model="ticketWaveDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="ticketWaveDialog.data.amount_tickets"
type="number"
label="Amount of tickets"
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="ticketWaveDialog.data.price_per_ticket"
type="number"
:label="'Price (' + ticketWaveDialog.data.currency + ') *'"
:step="ticketWaveDialog.data.currency != 'sats' ? '0.01' : '1'"
:mask="ticketWaveDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="ticketWaveDialog.data.currency == null"
></q-input>
</div>
</div>
<q-toggle
v-model="ticketWaveDialog.data.use_ticket_image"
label="Use ticket image"
left-label
></q-toggle>
<div
v-if="ticketWaveDialog.data.use_ticket_image"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-btn
unelevated
color="primary"
type="a"
:href="templateDownloadUrl()"
download="ticket.jpg"
>
Download template
<q-tooltip>400/733 jpg</q-tooltip>
</q-btn>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
:loading="isUploadingTicketTemplate"
@click="triggerTicketImageUpload('dialog')"
>Replace</q-btn
>
</div>
<div
v-if="ticketWaveDialog.data.ticket_image_id"
class="col-12 text-caption"
>
Custom ticket template uploaded.
</div>
</div>
<q-toggle
v-model="ticketWaveDialog.data.allow_fiat"
label="Allow fiat checkout"
left-label
hint="Lets attendees pay through a configured fiat provider using this wave currency."
></q-toggle>
<q-select
v-if="
ticketWaveDialog.data.allow_fiat &&
['sat', 'sats'].includes(
(ticketWaveDialog.data.currency || '').toLowerCase()
)
"
filled
dense
v-model="ticketWaveDialog.data.fiat_currency"
label="Fiat checkout currency"
:options="
currencies.filter(
c => !['sat', 'sats'].includes((c || '').toLowerCase())
)
"
></q-select>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">{{
ticketWaveDialog.editingWaveId
? 'Update Ticket Wave'
: 'Save Ticket Wave'
}}</q-btn>
<q-btn
flat
color="grey"
class="q-ml-auto"
@click="resetTicketWaveDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<input
ref="ticketImageUpload"
type="file"
accept="image/png,image/jpeg,image/webp"
style="display: none"
@change="handleTicketImageSelected"
/>
</div>
</template>

View file

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

View file

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

View file

@ -3,7 +3,15 @@ from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from ..models import Event, EventExtra, PromoCode, PublicEvent, Ticket
from ..models import (
Event,
EventExtra,
PromoCode,
PublicEvent,
Ticket,
TicketWave,
ensure_ticket_waves,
)
from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses
@ -24,6 +32,15 @@ def _event(currency="sat", price=1000.0, codes=None) -> Event:
)
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(
@ -103,7 +120,7 @@ def test_remaining_uses():
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, {})
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() == {
@ -120,7 +137,7 @@ def test_fiat_totals_round_to_cents_and_scale_by_quantity():
price=19.99,
codes=[PromoCode(code="THIRD", discount_percent=33)],
)
totals = basket_totals(event, ["THIRD"], 3, {})
totals = basket_totals(event, ["THIRD"], 3, {}, _wave(event))
assert totals.subtotal == 59.97
assert totals.total == 40.18
assert totals.discount == 19.79
@ -135,7 +152,10 @@ def test_first_applicable_code_wins():
]
)
assert (
basket_totals(event, ["NOPE", "B", "A"], 1, {}).discounts_applied[0].code == "B"
basket_totals(event, ["NOPE", "B", "A"], 1, {}, _wave(event))
.discounts_applied[0]
.code
== "B"
)
@ -154,7 +174,7 @@ def test_inactive_unknown_zero_and_exhausted_codes_are_absent():
(["TWO"], {"TWO": 2}, 1),
(["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity
):
totals = basket_totals(event, codes, qty, usage)
totals = basket_totals(event, codes, qty, usage, _wave(event))
assert totals.discounts_applied == []
assert totals.total == totals.subtotal and totals.discount == 0
@ -166,10 +186,33 @@ def test_unlimited_and_partially_used_codes_apply():
PromoCode(code="INF", discount_percent=10),
]
)
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}).total == 500
assert basket_totals(event, ["INF"], 10, {"INF": 999}).total == 9000
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, {}).total == 0
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

@ -2,6 +2,7 @@ import asyncio
from datetime import datetime, timezone
from http import HTTPStatus
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
@ -16,17 +17,20 @@ from fastapi import (
)
from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user
from lnbits.core.crud.assets import get_public_asset
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Account, User, WalletTypeInfo
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_admin,
check_user_exists,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import urlsafe_short_hash
from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
@ -34,6 +38,8 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat,
)
from lnbits.utils.nostr import normalize_public_key
from loguru import logger
from PIL import Image
from .crud import (
create_event,
@ -53,6 +59,7 @@ from .crud import (
get_tickets_by_event,
get_tickets_by_payment_hash,
get_tickets_by_user_id,
get_tickets_paginated,
purge_unpaid_tickets,
update_event,
update_settings,
@ -68,9 +75,13 @@ from .models import (
PublicEvent,
PublicTicket,
Ticket,
TicketFilters,
TicketPaymentRequest,
TicketResendResult,
TicketWave,
effective_payment_methods,
ensure_ticket_waves,
get_active_ticket_waves,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .promo import (
@ -88,19 +99,57 @@ from .qr import (
ticket_card_filename,
)
from .services import (
create_satspay_charge,
event_promo_usage,
fetch_watchonly_config,
fetch_watchonly_wallets,
get_satspay_charge,
hydrate_promo_usage,
refund_tickets,
resend_ticket_email_notification,
send_ticket_notification_in_background,
set_ticket_paid,
)
from .tasks import deregister_payment_listener, register_payment_listener
from .tasks import (
deregister_payment_listener,
payment_listeners,
register_payment_listener,
)
events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
qr_api_router = APIRouter(prefix="/api/v1")
promo_api_router = APIRouter(prefix="/api/v1/promo")
tickets_filters = parse_filters(TicketFilters)
async def _get_watchonly_status(wallet) -> dict[str, Any]:
try:
config = await fetch_watchonly_config(wallet.inkey)
network = config.get("network")
if not network:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Watchonly extension returned an invalid network.",
)
wallets = await fetch_watchonly_wallets(wallet.inkey, network)
except HTTPException:
raise
except Exception as exc:
return {
"available": False,
"message": f"Watchonly extension is not reachable: {exc!s}",
"network": None,
"wallets": [],
"mempool_endpoint": None,
}
return {
"available": True,
"message": None,
"network": network,
"wallets": wallets,
"mempool_endpoint": config.get("mempool_endpoint"),
}
def _is_fiat_currency(currency: str | None) -> bool:
@ -109,8 +158,6 @@ def _is_fiat_currency(currency: str | None) -> bool:
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
@events_api_router.get("")
async def api_events(
all_wallets: bool = Query(False),
@ -247,6 +294,13 @@ async def api_get_settings_public(
return {"auto_approve": settings.auto_approve}
@events_api_router.get("/onchain/status")
async def api_onchain_status(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict[str, Any]:
return await _get_watchonly_status(wallet.wallet)
@events_api_router.get("/{event_id}", response_model=PublicEvent)
async def api_get_event(event_id: str) -> Event:
"""Public event detail used by display.vue.
@ -273,28 +327,44 @@ async def api_get_event(event_id: str) -> Event:
closing_date = event.closing_date or event.event_end_date or event.event_start_date
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
# may carry a time component since v1.3.0-aio.3 / our start-end-time
# feature).
# feature). Upstream v1.6.8 parses closing_date with a bare
# strptime("%Y-%m-%d") here; that raises ValueError on any event of ours
# whose closing date carries a time, which is most of them.
try:
closing_dt = datetime.fromisoformat(closing_date)
except ValueError:
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
if closing_dt.tzinfo is None:
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
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 = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True
)
if event.amount_tickets < 1:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
if event.extra.conditional and not is_min_tickets_met and is_sales_closed:
event.canceled = True
await update_event(event)
await refund_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
if not is_window_open:
if not active_waves:
raise HTTPException(
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
status_code=HTTPStatus.GONE,
detail=(
"Ticket closing date has passed."
if is_sales_closed
else "No ticket wave is currently open."
),
)
return event
@ -536,6 +606,31 @@ async def api_tickets_by_user(
return await get_tickets_by_user_id(user_id)
@tickets_api_router.get(
"/paginated",
summary="Get paginated list of tickets",
openapi_extra=generate_filter_params_openapi(TicketFilters),
response_model=Page[Ticket],
)
async def api_tickets_paginated(
all_wallets: bool = Query(False),
filters: Filters = Depends(tickets_filters),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Page[Ticket]:
wallet_ids = [key_info.wallet.id]
if all_wallets:
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
if not filters.sortby:
filters.sortby = "time"
if not filters.direction:
filters.direction = "desc"
return await get_tickets_paginated(wallet_ids, filters)
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
async def api_get_ticket(ticket_id: str) -> Ticket:
ticket = await get_ticket(ticket_id)
@ -597,6 +692,36 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
return data.frontend_url.rstrip("/")
def _resolve_ticket_wave(event: Event, ticket_wave_id: str | None) -> TicketWave:
"""The wave a purchase or a price preview is priced against.
Shared by the purchase and promo-validate endpoints so the two cannot
drift: whatever wave the preview quoted is the wave the invoice charges.
Selection is upstream v1.6.8's — an explicit id must be an OPEN wave, a
single open wave is implied, and an ambiguous choice is an error rather
than a silent pick.
"""
active_waves = get_active_ticket_waves(event)
if not active_waves:
raise HTTPException(
status_code=HTTPStatus.GONE, detail="No ticket wave is currently open."
)
if ticket_wave_id:
wave = next((wave for wave in active_waves if wave.id == ticket_wave_id), None)
if not wave:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid ticket wave selected.",
)
return wave
if len(active_waves) == 1:
return active_waves[0]
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Please select a ticket wave.",
)
async def _issue_free_tickets(
*,
event: Event,
@ -607,6 +732,7 @@ async def _issue_free_tickets(
promo_code: str | None,
nostr_identifier: str | None,
frontend_root: str,
selected_wave: TicketWave,
) -> TicketPaymentRequest:
"""Issue `quantity` free tickets without minting an invoice.
@ -635,6 +761,11 @@ async def _issue_free_tickets(
ticket_id=row_id,
extra={
"applied_promo_code": promo_code,
# Free tickets consume wave stock exactly like paid ones —
# `set_ticket_paid` runs on this path too — so they must name
# their wave or the decrement lands on the primary wave.
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"nostr_identifier": nostr_identifier,
"ticket_base_url": frontend_root,
"sats_paid": 0,
@ -690,11 +821,16 @@ async def api_ticket_create(
refund_address = data.refund_address
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
payment_method = (data.payment_method or "lightning").lower()
if payment_method not in {"lightning", "fiat"}:
if payment_method not in {"lightning", "fiat", "onchain"}:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Unsupported payment method.",
)
# npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this
# with a hard "Only NIP-05 Nostr identifiers are supported." rejection —
# true for upstream, false here: `_send_nostr_ticket_notification` sends
# bare pubkeys via `send_nostr_dm`. That rejection merged in outside any
# conflict marker, so it would have silently dropped npub checkout.
if nostr_identifier and "@" not in nostr_identifier:
try:
nostr_identifier = normalize_public_key(nostr_identifier)
@ -703,6 +839,8 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.",
) from exc
selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id)
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
frontend_root = _resolve_frontend_root(data, request)
@ -733,9 +871,18 @@ async def api_ticket_create(
),
)
extra["promo_code"] = promo.code
price = basket_totals(event, [promo.code], quantity, usage).total
# Priced off `selected_wave`, not `event`: since v1.6.8 the price and
# currency live on the wave, and the event-level fields are a roll-up
# of the PRIMARY wave (`sync_event_ticket_waves`). Pricing off the
# event charges every buyer the first wave's price whichever wave they
# actually picked. Upstream prices one ticket; `basket_totals` keeps
# our quantity + promo arithmetic, so the "Apply" preview and the
# invoice still agree.
price = basket_totals(event, [promo.code], quantity, usage, selected_wave).total
else:
price = round_amount(event.price_per_ticket * quantity, event.currency)
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).
# Short-circuit before any invoice / fiat-provider logic: no Lightning
@ -751,6 +898,16 @@ async def api_ticket_create(
promo_code=promo_code,
nostr_identifier=nostr_identifier,
frontend_root=frontend_root,
selected_wave=selected_wave,
)
# Fiat is a per-wave opt-in since v1.6.8. `effective_payment_methods`
# below reads `event.allow_fiat`, which is only the PRIMARY wave's, so
# this check is what actually protects a non-fiat wave.
if payment_method == "fiat" and not selected_wave.allow_fiat:
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
@ -762,23 +919,25 @@ async def api_ticket_create(
detail="Payment method not enabled for this event.",
)
if _is_fiat_currency(event.currency):
if _is_fiat_currency(selected_wave.currency):
extra["fiat"] = True
extra["currency"] = event.currency
extra["currency"] = selected_wave.currency
extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency)
if payment_method != "fiat":
price = await fiat_amount_as_satoshis(price, event.currency)
price = await fiat_amount_as_satoshis(price, selected_wave.currency)
invoice_unit = event.currency
invoice_unit = selected_wave.currency
fiat_amount = price
fiat_provider = None
onchain_amount_sat = None
if payment_method == "fiat":
if _is_fiat_currency(event.currency):
invoice_unit = event.currency
if _is_fiat_currency(selected_wave.currency):
invoice_unit = selected_wave.currency
else:
invoice_unit = event.fiat_currency
invoice_unit = selected_wave.fiat_currency
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
extra["fiat"] = True
extra["currency"] = invoice_unit
@ -797,6 +956,80 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="No fiat payment provider configured for this event.",
)
elif payment_method == "onchain":
onchain_amount_sat = int(price)
wallet_record = await get_wallet(event.wallet)
if not wallet_record:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Event wallet does not exist.",
)
if not event.extra.onchain_enabled:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Onchain payments are not enabled for this event.",
)
if not event.extra.onchain_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="No onchain wallet configured for this event.",
)
ticket_id = urlsafe_short_hash()
base_url = str(request.base_url).rstrip("/")
# Use internal address for webhook — SatsPay calls it server-side and
# cannot reach the public domain from within the container.
internal_base = f"http://{settings.host}:{settings.port}"
webhook_url = (
f"{internal_base}/events/api/v1/tickets/{ticket_id}/satspay-webhook"
)
complete_url = f"{base_url}/events/ticket/{ticket_id}"
try:
charge = await create_satspay_charge(
api_key=wallet_record.inkey,
data={
"amount": onchain_amount_sat,
"description": f"Ticket for {event.name}",
"name": name,
"onchainwallet": event.extra.onchain_wallet_id,
"zeroconf": event.extra.onchain_zeroconf,
"fasttrack": event.extra.onchain_fasttrack,
"webhook": webhook_url,
"completelink": complete_url,
"completelinktext": "View your ticket",
"time": 1440,
},
)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to create SatsPay charge: {exc}",
) from exc
await create_ticket(
payment_hash=ticket_id,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
extra={
"applied_promo_code": promo_code,
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"refund_address": refund_address,
"nostr_identifier": nostr_identifier,
"ticket_base_url": base_url,
"sats_paid": onchain_amount_sat,
"onchain": True,
"satspay_charge_id": charge["id"],
},
)
return TicketPaymentRequest(
payment_hash=ticket_id,
onchain_amount_sat=onchain_amount_sat,
satspay_charge_url=f"/satspay/{charge['id']}",
)
else:
invoice_unit = "sat"
@ -854,6 +1087,12 @@ async def api_ticket_create(
ticket_id=row_id,
extra={
"applied_promo_code": promo_code,
# Which wave this ticket came from. `set_ticket_paid` debits
# the wave named here and falls back to waves[0] when it is
# absent, so omitting it would take every sale off the
# primary wave no matter what the buyer bought.
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"refund_address": refund_address,
"nostr_identifier": nostr_identifier,
"ticket_base_url": frontend_root,
@ -955,9 +1194,78 @@ async def api_ticket_delete(
await delete_ticket(ticket_id)
@tickets_api_router.post("/{ticket_id}/satspay-webhook")
async def api_ticket_satspay_webhook(ticket_id: str) -> None:
ticket = await get_ticket(ticket_id)
if not ticket:
logger.warning(f"SatsPay webhook: ticket {ticket_id} does not exist.")
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
if ticket.paid:
logger.warning(f"SatsPay webhook: ticket {ticket_id} already paid.")
return
if not ticket.extra.satspay_charge_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Not a SatsPay ticket."
)
wallet = await get_wallet(ticket.wallet)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket wallet does not exist."
)
try:
charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=f"Could not verify charge: {exc}"
) from exc
if not charge.get("paid"):
logger.warning(
f"SatsPay webhook for ticket {ticket_id}: charge"
f" {ticket.extra.satspay_charge_id} not paid."
)
return
ticket = await set_ticket_paid(ticket)
send_ticket_notification_in_background(ticket)
for queue in payment_listeners.get(ticket_id, []):
queue.put_nowait(ticket)
@tickets_api_router.put("/{payment_hash}/onchain-confirm")
async def api_ticket_onchain_confirm(
payment_hash: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Ticket:
ticket = await get_ticket(payment_hash)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
if ticket.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
if ticket.paid:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Ticket already paid."
)
if not ticket.extra.onchain:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Ticket is not an onchain payment.",
)
ticket = await set_ticket_paid(ticket)
send_ticket_notification_in_background(ticket)
for queue in payment_listeners.get(payment_hash, []):
queue.put_nowait(ticket)
return ticket
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
async def api_ticket_resend_email(
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
ticket_id: str,
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> TicketResendResult:
ticket = await get_ticket(ticket_id)
if not ticket:
@ -975,16 +1283,13 @@ async def api_ticket_resend_email(
)
try:
return await resend_ticket_email_notification(ticket)
return await resend_ticket_email_notification(
ticket, str(request.base_url).rstrip("/")
)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Failed to resend ticket email.",
) from exc
@tickets_api_router.put("/register/{ticket_id}")
@ -1095,28 +1400,76 @@ async def api_event_ticket_stats(
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_qr(ticket_id: str):
"""PNG of the ticket's scan payload (`ticket://<id>`), branded with the
instance QR logo. Anonymous by design — it is what the ticket email
embeds — and the id is the same bearer token the ticket page exposes.
Port of upstream v1.6.8 without ticket-image compositing."""
"""PNG of the ticket's scan payload (`ticket://<id>`).
Two shapes behind one route. Before the v1.6.8 merge this endpoint
existed on both sides — ours was "upstream's, without ticket-image
compositing", theirs added the compositing but dropped the logo. They
are the same endpoint, so they are merged rather than registered twice
(FastAPI serves whichever route is declared first, so the second copy
would have been silently unreachable):
- wave opted into `use_ticket_image`: upstream's composite — the QR
pasted onto the organiser's uploaded template, or the bundled
default. QR size and paste coordinates are upstream's and are tied
to each other; no logo, because the template carries the branding.
- otherwise: our standalone QR at 300px with the instance logo.
Anonymous by design — it is what the ticket email links to — and the id
is the same bearer token the ticket page exposes.
"""
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
event = await get_event(ticket.event)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
headers = {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
}
waves = ensure_ticket_waves(event)
wave = next(
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
waves[0],
)
if not wave.use_ticket_image:
logo = await load_qr_logo()
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
return StreamingResponse(
BytesIO(image_png_bytes(image)),
media_type="image/png",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
},
headers=headers,
)
background_bytes = None
if wave.ticket_image_id:
asset = await get_public_asset(wave.ticket_image_id)
if asset:
background_bytes = asset.data
if background_bytes:
ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA")
else:
default_template = (
Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg"
)
ticket_image = Image.open(default_template).convert("RGBA")
ticket_image.paste(make_qr_png(f"ticket://{ticket_id}", size=157), (122, 505))
output = BytesIO()
ticket_image.save(output, format="PNG")
output.seek(0)
return StreamingResponse(output, media_type="image/png", headers=headers)
@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_card(ticket_id: str):
@ -1162,4 +1515,7 @@ async def api_validate_promo_codes(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
usage = await event_promo_usage(event_id) if event.extra.promo_codes else {}
return basket_totals(event, data.codes, data.quantity, usage)
# 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)