Merge pull request 'Rebase onto upstream v1.6.8' (#63) from rebase/upstream-v1.6.8 into main
Some checks failed
lint.yml / Merge pull request 'Rebase onto upstream v1.6.8' (#63) from rebase/upstream-v1.6.8 into main (push) Failing after 0s

Reviewed-on: #63
This commit is contained in:
padreug 2026-09-28 22:02:54 +00:00
commit 317a474816
21 changed files with 2379 additions and 307 deletions

View file

@ -139,12 +139,22 @@ def events_start():
Quiet by design: on a healthy instance the query returns nothing
and this logs nothing. It only speaks up when there is drift.
"""
from .crud import get_events_pending_republish
from .crud import flag_wave_transitions, get_events_pending_republish
from .nostr_hooks import publish_or_delete_nostr_event
await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY)
while True:
try:
# Wave boundaries are time-driven, so nothing else flags
# them. Done here rather than on a timer of its own: the
# boundary is day-granular, so one sweep interval of
# staleness is immaterial (aiolabs/events#61).
moved = await flag_wave_transitions()
if moved:
logger.info(
f"[EVENTS] Republish sweep: {moved} event(s) changed "
f"ticket wave"
)
pending = await get_events_pending_republish()
if pending:
total = len(pending)

125
crud.py
View file

@ -1,10 +1,20 @@
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,
advertised_wave_key,
sync_event_ticket_waves,
)
db = Database("ext_events")
@ -155,6 +165,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 +219,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 +275,70 @@ 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 flag_wave_transitions() -> int:
"""Flag events whose advertised ticket wave has moved on.
Every republish this extension performs is *sale*-driven. A wave
boundary is a *date* boundary, so when early bird closes at midnight
nothing fires and the relay keeps serving the closed wave's price until
the next ticket happens to sell (aiolabs/events#61).
Rather than add a scheduler and a second publish path, this compares the
wave a row would advertise now against the one its last successful
publish did (`nostr_published_wave_id`) and sets `nostr_publish_pending`
on a mismatch — handing the work to the existing reconciliation sweep,
which already retries, survives restarts and logs.
Rows with NULL `nostr_published_wave_id` are skipped: that means "never
published, or published before the column existed", which is no evidence
of drift. Flagging them would republish the whole table on first boot
after the upgrade.
Returns the number of rows newly flagged.
"""
events = await db.fetchall(
"""
SELECT * FROM events.events
WHERE nostr_published_wave_id IS NOT NULL
AND nostr_publish_pending = FALSE
AND canceled = FALSE
AND status = 'approved'
""",
model=Event,
)
flagged = 0
for event in events:
event = cast(Event, sync_event_ticket_waves(event))
if advertised_wave_key(event) == event.nostr_published_wave_id:
continue
event.nostr_publish_pending = True
await update_event(event)
flagged += 1
return flagged
async def get_events_pending_republish() -> list[Event]:
@ -249,7 +351,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 +359,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`

View file

@ -5,7 +5,7 @@ Running log of fork features that are shaped so they could be offered to
in an upstream-compatible form; strike it when the PR merges upstream.
| Feature | Where | Upstream target | Readiness |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
@ -18,3 +18,4 @@ in an upstream-compatible form; strike it when the PR merges upstream.
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 |
| NIP-52 tags describe the **active** ticket wave (cheapest open wave; `tickets_available: 0` when none is open), plus `nostr_published_wave_id` so the reconciliation sweep republishes at wave boundaries | `nostr_publisher.py`, `crud.py` `flag_wave_transitions`, `migrations_fork.py` m004 | lnbits/events #46 (rides with the NIP-52 publishing PR) | **deviation, deliberate** — upstream has waves but publishes no calendar event, so there is nothing upstream to match. Several waves can be open at once and a publisher cannot ask which one the buyer wants (upstream's purchase path errors with "Please select a ticket wave"), so it advertises the cheapest — the price a buyer can actually obtain. Rationale and the rejected alternatives: aiolabs/events#61 |

View file

@ -156,3 +156,32 @@ async def m003_event_nostr_publish_pending(db):
"ALTER TABLE events.events "
"ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE",
)
async def m004_event_nostr_published_wave(db):
"""
Add `events.nostr_published_wave_id` — which ticket wave the last
successful NIP-52 publish advertised.
Since upstream v1.6.8 price and inventory live on time-boxed waves, so
what a calendar event should advertise changes at a *date* boundary.
Every republish we have is sale-driven, and no sale happens at
midnight when early bird ends — so without this the relay keeps
serving the closed wave's price until the next ticket sells
(aiolabs/events#61).
Recording the advertised wave makes that drift detectable with the
machinery already in place: the reconciliation sweep compares this
against the wave that would be advertised now and sets
`nostr_publish_pending`, reusing the existing retry path rather than
adding a scheduler.
NULL on existing rows means "never published, or published before this
column existed". The sweep treats NULL as "no evidence of drift" and
leaves it alone, so an upgrade does not stampede the signer with a
full-table republish; the first ordinary publish fills it in.
"""
await _alter_add_column_safe(
db,
"ALTER TABLE events.events ADD COLUMN nostr_published_wave_id TEXT",
)

183
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,12 @@ 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)
PublicEventExtra = EventExtraBase
@ -129,6 +151,12 @@ class Event(BaseModel):
# True means the relay's copy may be behind this row — see
# migrations_fork.m003 and the sweep in __init__.events_start.
nostr_publish_pending: bool = False
# Which wave the last successful publish advertised (see
# `advertised_wave_key`). NULL = never published; "" = published while
# nothing was on sale. The sweep compares this against the current key
# to catch wave boundaries, which are time-driven and so fire no
# sale-triggered republish (aiolabs/events#61).
nostr_published_wave_id: str | None = None
@validator("categories", pre=True)
def parse_categories(cls, v):
@ -164,28 +192,44 @@ class PublicEvent(BaseModel):
return v or []
def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]:
def effective_payment_methods(
event: "Event | PublicEvent | CreateEvent",
wave: "TicketWave | None" = None,
) -> list[str]:
"""Rails a buyer may pick for `event`.
Explicit `extra.payment_methods` wins; an empty list falls back to the
pre-#payment-methods rule so events created before the field existed
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
Pass `wave` when the answer is about one specific ticket wave. Fiat is a
per-wave opt-in since v1.6.8 and `event.allow_fiat` is only the PRIMARY
wave's, so without it a publisher can advertise a fiat rail for an
advertised wave that does not accept fiat — which the purchase endpoint
then rejects (aiolabs/events#61). Callers asking the event-level
question ("which rails did the organiser enable at all") leave it unset.
"""
explicit = list(getattr(event.extra, "payment_methods", []) or [])
if explicit:
return explicit
methods = ["lightning"]
if event.allow_fiat:
if wave.allow_fiat if wave is not None else event.allow_fiat:
methods.append("fiat")
return methods
class PromoValidateRequest(BaseModel):
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork 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 +255,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 +264,18 @@ class TicketExtra(BaseModel):
email_notification_sent: bool = False
nostr_notification_sent: bool = False
refunded: bool = False
# On-chain vocabulary, populated once native lnbits on-chain lands
# (aiolabs/events#41). Upstream's field names, minus the SatsPay charge
# id — SatsPay is the implementation we are not adopting.
onchain: bool = False
onchain_address: str | None = None
class CreateTicket(BaseModel):
name: str | None = None
email: EmailStr | None = None
user_id: str | None = None # LNbits user id (alternative to name+email)
ticket_wave_id: str | None = None
promo_code: str | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
@ -325,3 +377,124 @@ 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
class TicketFilters(FilterModel):
__search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012
__sort_fields__ = [ # noqa: RUF012
"time",
"event",
"name",
"email",
"registered",
"id",
]
event: str | None = None
name: str | None = None
email: str | None = None
registered: bool | None = None
paid: bool | None = None
id: str | None = None
def _parse_date(value: str) -> date:
"""Date component of `value`.
Upstream only ever produces bare `YYYY-MM-DD` here, so its version is a
plain `strptime(value, "%Y-%m-%d")`. In this fork `event_end_date` may
carry a time (start/end times, v1.3.0-aio.3) and `create_event` defaults
`closing_date` to it, so a wave derived from an event inherits the full
ISO datetime and upstream's parser raises
`ValueError: unconverted data remains: T18:00:00` — on the purchase path,
the public event gate, and the promo preview.
"""
return date.fromisoformat(value[:10])
def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]:
ticket_waves = list(getattr(event.extra, "ticket_waves", []) or [])
if ticket_waves:
return ticket_waves
# `TicketWave` requires both dates; `Event.closing_date` is Optional in
# this fork (it defaults from event_end_date at create time), so fall
# back the same way `create_event` does rather than handing None to a
# required field.
closing_date = event.closing_date or event.event_end_date or event.event_start_date
fallback_opening_date = None
event_time = getattr(event, "time", None)
if event_time:
fallback_opening_date = event_time.date().isoformat()
if not fallback_opening_date:
fallback_opening_date = closing_date
return [
TicketWave(
id="primary",
title="Primary wave",
opening_date=fallback_opening_date,
closing_date=closing_date,
currency=event.currency,
allow_fiat=event.allow_fiat,
fiat_currency=event.fiat_currency,
amount_tickets=getattr(event, "amount_tickets", 0),
price_per_ticket=event.price_per_ticket,
)
]
def advertised_ticket_wave(event: "Event | PublicEvent") -> "TicketWave | None":
"""The wave a public listing should describe, or None when nothing is
on sale (sold out, between waves, or not yet open).
Several waves can be open at once. The purchase endpoint refuses to
guess; a publisher has no one to ask, so it advertises the CHEAPEST
open wave — the price a buyer is actually able to obtain.
Shared by the NIP-52 publisher and the wave-transition detector so the
two cannot disagree about which wave is currently being advertised.
"""
active = get_active_ticket_waves(event)
if not active:
return None
return min(active, key=lambda wave: wave.price_per_ticket)
def advertised_wave_key(event: "Event | PublicEvent") -> str:
"""Stable key for what a publish advertised. Empty string means "nothing
on sale", which is a real published state and distinct from NULL in
`nostr_published_wave_id` (never published)."""
wave = advertised_ticket_wave(event)
return wave.id if wave else ""
def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent:
ticket_waves = ensure_ticket_waves(event)
event.extra.ticket_waves = ticket_waves
primary_wave = ticket_waves[0]
event.closing_date = max(wave.closing_date for wave in ticket_waves)
event.currency = primary_wave.currency
event.allow_fiat = primary_wave.allow_fiat
event.fiat_currency = primary_wave.fiat_currency
event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves)
event.price_per_ticket = primary_wave.price_per_ticket
return event
def get_active_ticket_waves(
event: Event | PublicEvent, today: date | None = None
) -> list[TicketWave]:
current_day = today or datetime.utcnow().date()
return [
wave
for wave in ensure_ticket_waves(event)
if _parse_date(wave.opening_date)
<= current_day
<= _parse_date(wave.closing_date)
and wave.amount_tickets > 0
]

View file

@ -8,7 +8,7 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models).
from loguru import logger
from .crud import update_event
from .models import Event
from .models import Event, advertised_wave_key
from .nostr_publisher import publish_event_to_nostr
@ -72,6 +72,11 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -
if not delete:
event.nostr_event_id = nostr_event.id
event.nostr_event_created_at = nostr_event.created_at
# Record which wave this publish advertised so the sweep can
# notice a wave boundary later (aiolabs/events#61). Written in
# the same update as the cleared flag, so the two can never
# disagree about what is on the relay.
event.nostr_published_wave_id = advertised_wave_key(event)
await update_event(event)
return True
except Exception as exc:

View file

@ -17,7 +17,12 @@ from datetime import datetime, timezone
from lnbits.core.signers import NostrSigner
from loguru import logger
from .models import Event, effective_payment_methods
from .models import (
Event,
advertised_ticket_wave,
effective_payment_methods,
ensure_ticket_waves,
)
from .nostr.event import NostrEvent
from .nostr_timestamp import monotonic_created_at
@ -46,11 +51,13 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
start - unix timestamp (31923) or YYYY-MM-DD (31922)
end - same encoding (optional)
image, location, t (categories) - optional
tickets_available - current remaining capacity (always emitted; 0 = sold out)
tickets_sold - running paid-count (always emitted; clients can
derive original_capacity = available + sold)
tickets_price - price_per_ticket (always emitted; 0 means free)
tickets_currency - the currency string
tickets_available - remaining capacity of the advertised wave
(always emitted; 0 = nothing on sale now)
tickets_sold - running paid-count across all waves (always
emitted)
tickets_price - the advertised wave's price (always emitted;
0 means free)
tickets_currency - the advertised wave's currency
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
Content: event.info
@ -103,21 +110,52 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
# the detail page and the purchase both returned 410 (aiolabs/events#34).
# Clients that must still handle an absent tag — a NIP-52 event from
# some other publisher — are unaffected; we simply never omit it.
tags.append(["tickets_available", str(event.amount_tickets)])
#
# Since v1.6.8 price, currency and inventory belong to a ticket WAVE.
# The event-level fields `sync_event_ticket_waves` derives are the
# PRIMARY wave's price/currency and the SUM of every wave's stock, so
# publishing them would keep advertising the early-bird price after
# early bird closed, and count stock in waves that have not opened yet
# (aiolabs/events#61). Advertise the wave a buyer can actually buy from.
#
# Several waves can be open at once. The purchase endpoint refuses to
# guess ("Please select a ticket wave"); a publisher has no one to ask,
# so it advertises the CHEAPEST open wave — the price a buyer is able to
# obtain right now. Deviation recorded in docs/upstream-candidates.md.
open_wave = advertised_ticket_wave(event)
# Nothing open: sold out, between waves, or not yet on sale. Fall back
# to the primary wave so price/currency still describe the event,
# paired with the zero availability below.
advertised = open_wave or ensure_ticket_waves(event)[0]
# Always emitted, including zero — see #34 above. With no open wave
# there is nothing to sell regardless of what the waves hold, so this
# is 0 rather than the wave's stock.
available = advertised.amount_tickets if open_wave else 0
tags.append(["tickets_available", str(available)])
# Event-level: total paid across every wave, which is what "sold" means
# to a reader of the card.
tags.append(["tickets_sold", str(event.sold)])
tags.append(["tickets_price", str(event.price_per_ticket)])
tags.append(["tickets_currency", event.currency])
tags.append(["tickets_price", str(advertised.price_per_ticket)])
tags.append(["tickets_currency", advertised.currency])
# Fiat-checkout config — only emitted when allow_fiat is on so
# clients can branch the buy UI without re-reading the schema.
if event.allow_fiat:
if advertised.allow_fiat:
tags.append(["tickets_allow_fiat", "true"])
if event.fiat_currency:
tags.append(["tickets_fiat_currency", event.fiat_currency])
if advertised.fiat_currency:
tags.append(["tickets_fiat_currency", advertised.fiat_currency])
# Rails the organizer accepts, resolved through the same helper the
# ticket endpoint enforces with, so a client can render exactly the
# buttons that will be accepted (e.g. a card-only event) without a REST
# round-trip. Comma-separated, lowercase.
tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))])
tags.append(
[
"tickets_payment_methods",
# Scoped to the advertised wave so this cannot contradict
# `tickets_allow_fiat` above — they are the same fact.
",".join(effective_payment_methods(event, advertised)),
]
)
# NIP-52 calendar events are replaceable: this d-tag is republished
# whenever inventory changes (a ticket sells). Use a strictly-monotonic

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

@ -26,7 +26,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 (
@ -70,6 +76,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 +140,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 +158,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 +199,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 +235,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 +264,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 +449,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,62 @@ 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, so a rail the server
// would reject can never be offered. The `|| method` fallback covers
// a method with no label yet (on-chain, once #41 lands).
const labels = {
lightning: 'Lightning',
fiat: this.fiatCheckoutLabel
}
return this.paymentMethods.map(method => ({
value: method,
label: 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 +119,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 +141,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 +159,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 +188,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,10 +222,19 @@ 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'
}
)
const isFiat = Boolean(data.is_fiat)
@ -197,7 +268,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 +277,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 +286,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,11 +236,42 @@ 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: []
}
}
}
}
},
@ -248,19 +297,160 @@ window.PageEvents = {
}
},
methods: {
shortenId(value) {
if (!value) return ''
return value.length > 4 ? `${value.slice(0, 4)}...` : value
},
primaryTicketWave(data = this.formDialog.data) {
if (!data.extra) data.extra = {}
if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
data.extra.ticket_waves = [
{
id: 'primary',
title: 'Primary wave',
opening_date: data.closing_date || '',
closing_date: data.closing_date || '',
currency: data.currency || 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: Boolean(data.allow_fiat),
fiat_currency: data.fiat_currency || 'GBP',
amount_tickets: data.amount_tickets || 0,
price_per_ticket: data.price_per_ticket || 0
}
]
}
return data.extra.ticket_waves[0]
},
syncPrimaryWaveFromForm(data = this.formDialog.data) {
const primaryWave = this.primaryTicketWave(data)
primaryWave.title = primaryWave.title || 'Primary wave'
primaryWave.opening_date = primaryWave.opening_date || ''
primaryWave.closing_date = data.closing_date || ''
primaryWave.currency = data.currency || 'sats'
primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image)
primaryWave.ticket_image_id = primaryWave.ticket_image_id || null
primaryWave.allow_fiat = Boolean(data.allow_fiat)
primaryWave.fiat_currency = data.fiat_currency || 'GBP'
primaryWave.amount_tickets = Number(data.amount_tickets || 0)
primaryWave.price_per_ticket = Number(data.price_per_ticket || 0)
return primaryWave
},
hydrateEventForm(data) {
const formData = {
...data,
extra: {
...(data.extra || {}),
ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])]
}
}
const primaryWave = this.primaryTicketWave(formData)
formData.currency = primaryWave.currency || formData.currency || 'sats'
formData.allow_fiat = Boolean(primaryWave.allow_fiat)
formData.fiat_currency = primaryWave.fiat_currency || 'GBP'
formData.amount_tickets = primaryWave.amount_tickets
formData.price_per_ticket = primaryWave.price_per_ticket
formData.closing_date =
primaryWave.closing_date || formData.closing_date || ''
return formData
},
isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
},
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 +465,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 +485,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 +518,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 +725,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 +752,7 @@ window.PageEvents = {
} else if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
}
this.syncPrimaryWaveFromForm(data)
if (data.id) {
this.updateEvent(wallet, data)
@ -561,10 +765,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 +804,21 @@ window.PageEvents = {
min_tickets: 1,
email_notifications: false,
nostr_notifications: false,
ticket_waves: [
{
id: 'primary',
title: 'Primary wave',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
],
promo_codes: [],
notification_subject: '',
notification_body: ''
@ -614,8 +837,25 @@ window.PageEvents = {
categories: [],
extra: {
payment_methods: ['lightning'],
conditional: false,
min_tickets: 1,
email_notifications: false,
nostr_notifications: false,
ticket_waves: [
{
id: 'primary',
title: 'Primary wave',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
],
promo_codes: [],
notification_subject: '',
notification_body: ''
@ -636,6 +876,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 +1115,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">
@ -597,6 +656,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 +766,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 +853,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 +886,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 +894,8 @@
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
</div>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
@ -863,7 +912,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 +926,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

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

View file

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

View file

@ -2,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,7 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat,
)
from lnbits.utils.nostr import normalize_public_key
from PIL import Image
from .crud import (
create_event,
@ -53,6 +58,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 +74,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 (
@ -95,12 +105,16 @@ from .services import (
send_ticket_notification_in_background,
set_ticket_paid,
)
from .tasks import deregister_payment_listener, register_payment_listener
from .tasks import (
deregister_payment_listener,
register_payment_listener,
)
events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
qr_api_router = APIRouter(prefix="/api/v1")
promo_api_router = APIRouter(prefix="/api/v1/promo")
tickets_filters = parse_filters(TicketFilters)
def _is_fiat_currency(currency: str | None) -> bool:
@ -109,8 +123,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),
@ -273,28 +285,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 +564,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 +650,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 +690,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 +719,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,
@ -695,6 +784,11 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="Unsupported payment method.",
)
# npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this
# with a hard "Only NIP-05 Nostr identifiers are supported." rejection —
# true for upstream, false here: `_send_nostr_ticket_notification` sends
# bare pubkeys via `send_nostr_dm`. That rejection merged in outside any
# conflict marker, so it would have silently dropped npub checkout.
if nostr_identifier and "@" not in nostr_identifier:
try:
nostr_identifier = normalize_public_key(nostr_identifier)
@ -703,6 +797,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 +829,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 +856,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 +877,24 @@ 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
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
@ -854,6 +970,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,
@ -957,7 +1079,9 @@ async def api_ticket_delete(
@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 +1099,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 +1216,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 +1331,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)