diff --git a/crud.py b/crud.py index caa0a2f..70a0f5d 100644 --- a/crud.py +++ b/crud.py @@ -1,10 +1,19 @@ import json from datetime import datetime, timedelta, timezone +from typing import cast -from lnbits.db import Database +from lnbits.db import Database, Filters, Page from lnbits.helpers import urlsafe_short_hash -from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra +from .models import ( + CreateEvent, + Event, + EventsSettings, + Ticket, + TicketExtra, + TicketFilters, + sync_event_ticket_waves, +) db = Database("ext_events") @@ -155,6 +164,31 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: return [Ticket(**_parse_ticket_row(row)) for row in rows] +async def get_tickets_paginated( + wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None +) -> Page[Ticket]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + wallet_where = [] + values = {} + for idx, wallet_id in enumerate(wallet_ids): + key = f"wallet_id_{idx}" + wallet_where.append(f":{key}") + values[key] = wallet_id + + where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"] + + return await db.fetch_page( + "SELECT * FROM events.ticket", + where=where, + values=values, + filters=filters, + model=Ticket, + table_name="events.ticket", + ) + + async def delete_ticket(payment_hash: str) -> None: await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) @@ -184,44 +218,55 @@ async def create_event(data: CreateEvent) -> Event: if not data.closing_date: data.closing_date = data.event_end_date event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) + event = cast(Event, sync_event_ticket_waves(event)) await db.insert("events.events", event) return event async def update_event(event: Event) -> Event: + event = cast(Event, sync_event_ticket_waves(event)) await db.update("events.events", event) return event async def get_event(event_id: str) -> Event | None: - return await db.fetchone( + event = await db.fetchone( "SELECT * FROM events.events WHERE id = :id", {"id": event_id}, Event, ) + return cast(Event, sync_event_ticket_waves(event)) if event else None async def get_events(wallet_ids: str | list[str]) -> list[Event]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) - return await db.fetchall( + events = await db.fetchall( f"SELECT * FROM events.events WHERE wallet IN ({q})", model=Event, ) + return [cast(Event, sync_event_ticket_waves(event)) for event in events] async def get_all_events() -> list[Event]: """All events, no wallet filter. Admin-only callers.""" - return await db.fetchall( + events = await db.fetchall( "SELECT * FROM events.events ORDER BY time DESC", model=Event, ) + # Wave-sync on read, exactly as upstream's `get_event` / `get_events` + # do. These four getters are fork-only, so upstream's v1.6.8 diff + # never reached them — and two of them publish: `get_all_events` + # backs /republish-all and `get_events_pending_republish` drives the + # #55 sweep. Without this they would emit the stale roll-up rather + # than the current per-wave figures. + return [cast(Event, sync_event_ticket_waves(e)) for e in events] async def get_public_events() -> list[Event]: """Approved, non-canceled events for the public listing.""" - return await db.fetchall( + events = await db.fetchall( """ SELECT * FROM events.events WHERE status = 'approved' AND canceled = FALSE @@ -229,14 +274,28 @@ async def get_public_events() -> list[Event]: """, model=Event, ) + # Wave-sync on read, exactly as upstream's `get_event` / `get_events` + # do. These four getters are fork-only, so upstream's v1.6.8 diff + # never reached them — and two of them publish: `get_all_events` + # backs /republish-all and `get_events_pending_republish` drives the + # #55 sweep. Without this they would emit the stale roll-up rather + # than the current per-wave figures. + return [cast(Event, sync_event_ticket_waves(e)) for e in events] async def get_pending_events() -> list[Event]: """Proposed events awaiting admin approval.""" - return await db.fetchall( + events = await db.fetchall( "SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC", model=Event, ) + # Wave-sync on read, exactly as upstream's `get_event` / `get_events` + # do. These four getters are fork-only, so upstream's v1.6.8 diff + # never reached them — and two of them publish: `get_all_events` + # backs /republish-all and `get_events_pending_republish` drives the + # #55 sweep. Without this they would emit the stale roll-up rather + # than the current per-wave figures. + return [cast(Event, sync_event_ticket_waves(e)) for e in events] async def get_events_pending_republish() -> list[Event]: @@ -249,7 +308,7 @@ async def get_events_pending_republish() -> list[Event]: Ordered oldest-first so a backlog drains in the order it accrued. """ - return await db.fetchall( + events = await db.fetchall( """ SELECT * FROM events.events WHERE nostr_publish_pending = TRUE @@ -257,6 +316,13 @@ async def get_events_pending_republish() -> list[Event]: """, model=Event, ) + # Wave-sync on read, exactly as upstream's `get_event` / `get_events` + # do. These four getters are fork-only, so upstream's v1.6.8 diff + # never reached them — and two of them publish: `get_all_events` + # backs /republish-all and `get_events_pending_republish` drives the + # #55 sweep. Without this they would emit the stale roll-up rather + # than the current per-wave figures. + return [cast(Event, sync_event_ticket_waves(e)) for e in events] async def get_settings() -> EventsSettings: diff --git a/docs/rebase-playbook.md b/docs/rebase-playbook.md new file mode 100644 index 0000000..adbf6f3 --- /dev/null +++ b/docs/rebase-playbook.md @@ -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*' .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.. -- '*.py' | grep -E "^\+(def |class |async def )" +# fields whose meaning was redefined +git show :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.. > /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` diff --git a/models.py b/models.py index 8e7ec22..ed75db8 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,9 @@ import json -from datetime import datetime +from datetime import date, datetime from urllib.parse import urlsplit +from uuid import uuid4 +from lnbits.db import FilterModel from pydantic import BaseModel, EmailStr, Field, root_validator, validator PAYMENT_METHODS = ("lightning", "fiat") @@ -41,6 +43,20 @@ class PromoCode(BaseModel): return v +class TicketWave(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex[:8]) + title: str = "Primary wave" + opening_date: str + closing_date: str + currency: str = "sat" + use_ticket_image: bool = False + ticket_image_id: str | None = None + allow_fiat: bool = False + fiat_currency: str = "GBP" + amount_tickets: int = Field(default=0, ge=0) + price_per_ticket: float = Field(default=0, ge=0) + + class EventExtraBase(BaseModel): """Everything in `extra` that is safe to show anyone. `EventExtra` adds the organizer-only promo codes on top; `PublicEventExtra` is this base, @@ -76,6 +92,20 @@ class EventExtraBase(BaseModel): class EventExtra(EventExtraBase): promo_codes: list[PromoCode] = Field(default_factory=list) + # Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The + # event-level `currency` / `allow_fiat` / `amount_tickets` / + # `price_per_ticket` fields become derived values (see + # `sync_event_ticket_waves`), which is why fork code that reads them + # needs auditing — aiolabs/events#61. + ticket_waves: list[TicketWave] = Field(default_factory=list) + # Upstream's SatsPay-backed on-chain config. Removed in the commit + # that follows this merge — we use native lnbits on-chain instead + # (aiolabs/events#41). Kept here so the removal is a legible diff + # against what upstream shipped rather than an invisible omission. + onchain_enabled: bool = False + onchain_wallet_id: str | None = None + onchain_zeroconf: bool = False + onchain_fasttrack: bool = False PublicEventExtra = EventExtraBase @@ -181,11 +211,17 @@ def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> lis class PromoValidateRequest(BaseModel): - """Upstream v2 shape. v2 sends `items` (ticket types); this fork has one - price per event, so a plain `quantity` replaces it.""" + """Upstream v2 shape. v2 sends `items` (ticket types); this fork prices a + plain `quantity` against one ticket wave. + + `ticket_wave_id` may be omitted when exactly one wave is open, matching + how the purchase endpoint resolves it — the preview has to price the same + wave the invoice will, and since v1.6.8 price and currency are per-wave. + """ codes: list[str] = Field(default_factory=list) quantity: int = Field(default=1, ge=1, le=10) + ticket_wave_id: str | None = None class BasketDiscount(BaseModel): @@ -211,6 +247,8 @@ class EventsSettings(BaseModel): class TicketExtra(BaseModel): applied_promo_code: str | None = None + ticket_wave_id: str | None = None + ticket_wave_title: str | None = None sats_paid: int | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -218,12 +256,16 @@ class TicketExtra(BaseModel): email_notification_sent: bool = False nostr_notification_sent: bool = False refunded: bool = False + onchain: bool = False + onchain_address: str | None = None + satspay_charge_id: str | None = None class CreateTicket(BaseModel): name: str | None = None email: EmailStr | None = None user_id: str | None = None # LNbits user id (alternative to name+email) + ticket_wave_id: str | None = None promo_code: str | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -325,3 +367,100 @@ class TicketPaymentRequest(BaseModel): # the door). Buyers fetch these after payment to render N QRs in # My Tickets. ticket_ids: list[str] = Field(default_factory=list) + onchain_amount_sat: int | None = None + satspay_charge_url: str | None = None + + +class TicketFilters(FilterModel): + __search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012 + __sort_fields__ = [ # noqa: RUF012 + "time", + "event", + "name", + "email", + "registered", + "id", + ] + + event: str | None = None + name: str | None = None + email: str | None = None + registered: bool | None = None + paid: bool | None = None + id: str | None = None + + +def _parse_date(value: str) -> date: + """Date component of `value`. + + Upstream only ever produces bare `YYYY-MM-DD` here, so its version is a + plain `strptime(value, "%Y-%m-%d")`. In this fork `event_end_date` may + carry a time (start/end times, v1.3.0-aio.3) and `create_event` defaults + `closing_date` to it, so a wave derived from an event inherits the full + ISO datetime and upstream's parser raises + `ValueError: unconverted data remains: T18:00:00` — on the purchase path, + the public event gate, and the promo preview. + """ + return date.fromisoformat(value[:10]) + + +def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]: + ticket_waves = list(getattr(event.extra, "ticket_waves", []) or []) + if ticket_waves: + return ticket_waves + + # `TicketWave` requires both dates; `Event.closing_date` is Optional in + # this fork (it defaults from event_end_date at create time), so fall + # back the same way `create_event` does rather than handing None to a + # required field. + closing_date = event.closing_date or event.event_end_date or event.event_start_date + + fallback_opening_date = None + event_time = getattr(event, "time", None) + if event_time: + fallback_opening_date = event_time.date().isoformat() + if not fallback_opening_date: + fallback_opening_date = closing_date + + return [ + TicketWave( + id="primary", + title="Primary wave", + opening_date=fallback_opening_date, + closing_date=closing_date, + currency=event.currency, + allow_fiat=event.allow_fiat, + fiat_currency=event.fiat_currency, + amount_tickets=getattr(event, "amount_tickets", 0), + price_per_ticket=event.price_per_ticket, + ) + ] + + +def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent: + ticket_waves = ensure_ticket_waves(event) + event.extra.ticket_waves = ticket_waves + + primary_wave = ticket_waves[0] + event.closing_date = max(wave.closing_date for wave in ticket_waves) + event.currency = primary_wave.currency + event.allow_fiat = primary_wave.allow_fiat + event.fiat_currency = primary_wave.fiat_currency + event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves) + event.price_per_ticket = primary_wave.price_per_ticket + + return event + + +def get_active_ticket_waves( + event: Event | PublicEvent, today: date | None = None +) -> list[TicketWave]: + current_day = today or datetime.utcnow().date() + return [ + wave + for wave in ensure_ticket_waves(event) + if _parse_date(wave.opening_date) + <= current_day + <= _parse_date(wave.closing_date) + and wave.amount_tickets > 0 + ] diff --git a/promo.py b/promo.py index 4bfa8ff..d35dff2 100644 --- a/promo.py +++ b/promo.py @@ -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 ) diff --git a/services.py b/services.py index 311b7e2..026121b 100644 --- a/services.py +++ b/services.py @@ -9,7 +9,9 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formataddr, formatdate, make_msgid from html import escape +from typing import Any +import httpx from lnbits.core.models.users import UserNotifications from lnbits.core.services.nostr import send_nostr_dm from lnbits.core.services.notifications import send_user_notification @@ -26,7 +28,13 @@ from .crud import ( update_event, update_ticket, ) -from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult +from .models import ( + Event, + NotificationDeliveryResult, + Ticket, + TicketResendResult, + ensure_ticket_waves, +) from .nostr_hooks import publish_or_delete_nostr_event from .promo import promo_usage from .qr import ( @@ -37,6 +45,59 @@ from .qr import ( ticket_card_filename, ) + +async def fetch_watchonly_config(api_key: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet", + headers={"X-API-KEY": api_key}, + params={"network": network}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def get_satspay_charge(api_key: str, charge_id: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge/{charge_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.post( + url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge", + headers={"X-API-KEY": api_key}, + json=data, + ) + resp.raise_for_status() + return resp.json() + + DEFAULT_NOSTR_RELAYS = [ "wss://relay.damus.io", "wss://relay.primal.net", @@ -70,7 +131,25 @@ 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 - event.amount_tickets -= 1 + # Debit the wave the buyer actually bought from. v1.6.8 moved + # inventory onto waves; the event-level counter is only the + # fallback for events that predate them, and is itself a derived + # roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are + # kept — ours decremented unconditionally and could go negative. + ticket_waves = event.extra.ticket_waves or [] + if ticket_waves: + selected_wave = next( + ( + wave + for wave in ticket_waves + if wave.id == ticket.extra.ticket_wave_id + ), + ticket_waves[0], + ) + if selected_wave.amount_tickets > 0: + selected_wave.amount_tickets -= 1 + elif event.amount_tickets > 0: + event.amount_tickets -= 1 # Flag inside this same write: the counters and "the relay does # not know about them yet" land atomically, so a crash between # here and the publish still leaves the drift discoverable. @@ -116,10 +195,17 @@ async def _send_ticket_notification(ticket: Ticket) -> None: await _deliver_ticket_notifications(ticket, event) -async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult: +async def resend_ticket_email_notification( + ticket: Ticket, base_url: str | None = None +) -> TicketResendResult: """Organizer-triggered re-delivery of the ticket email. Bypasses the per-event `email_notifications` opt-in (the organizer asked explicitly) - but still needs the instance mailer and an address on the ticket.""" + but still needs the instance mailer and an address on the ticket. + + `base_url` is upstream v1.6.8's: it re-points the ticket link at the + caller's frontend, which matters for us specifically because our + `ticket_base_url` can differ from `lnbits_baseurl` (separate web app). + """ event = await get_event(ticket.event) if not event: raise ValueError("Event does not exist.") @@ -127,7 +213,12 @@ async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult raise ValueError("Email notifications are not enabled.") if not ticket.email: raise ValueError("Ticket does not have an email address.") + if base_url: + ticket.extra.ticket_base_url = base_url.rstrip("/") + # email-only: this is the *email* resend endpoint. Upstream calls + # `_deliver_ticket_notifications(ticket, event)` unqualified, which in + # our fork would also fire a Nostr DM the organiser did not ask for. return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False) @@ -163,17 +254,35 @@ def _ticket_details(ticket: Ticket, event: Event) -> str: def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str: - return ( + """Text part of the ticket mail. + + Carries up to two images, which are NOT the same thing (see the note on + `_ticket_card_url` vs `_ticket_image_url`): our rendered QR card, always + present, and the organiser's uploaded template, only when the buyer's + wave opted into it. They had the same label before the v1.6.8 merge + because only one of them existed; now that both can appear the labels + have to distinguish them. + """ + message = ( f"{base_message}\n\n{_ticket_details(ticket, event)}" - f"\n\nTicket image: {_ticket_image_url(ticket)}" + f"\n\nTicket card: {_ticket_card_url(ticket)}" ) + ticket_image_url = _ticket_image_url(ticket, event) + if ticket_image_url: + message = f"{message}\n\nTicket image: {ticket_image_url}" + return message def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str: - """HTML twin of the text part. Deliberately no : 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 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 — the surrounding + ticket details keep the mail from being image-only either way. + """ text_message = _ticket_delivery_message(ticket, event, base_message) html = escape(text_message) html = re.sub( @@ -181,7 +290,17 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) lambda m: f'{m.group(1)}', html, ) - return f"

{html.replace(chr(10), '
')}

" + html_message = f"

{html.replace(chr(10), '
')}

" + + ticket_image_url = _ticket_image_url(ticket, event) + if not ticket_image_url: + return html_message + + return ( + f"{html_message}" + f'

Ticket image

' + ) def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: @@ -200,7 +319,16 @@ async def _deliver_ticket_notifications( ) -> TicketResendResult: """Send the ticket by every configured channel and report per-channel outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's - opt-ins when not None; the instance-level prerequisites always apply.""" + opt-ins when not None; the instance-level prerequisites always apply. + + Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery` + check that errors with "Only NIP-05 Nostr identifiers are supported." + That guard is NOT taken here: it encodes upstream's limitation, not ours. + `_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to + core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard + would silently refuse identifiers we deliver to today — and say so with + a message that would be false for this fork. + """ subject, text_message, html_message = _ticket_notification_payload(ticket, event) updated = False @@ -376,16 +504,36 @@ def _ticket_url(ticket: Ticket) -> str: return f"{base_url}/events/ticket/{ticket.id}" -def _ticket_image_url(ticket: Ticket) -> str: - """The ticket card PNG is served by THIS extension on the LNbits host, so - it is built from `lnbits_baseurl` even when `ticket_base_url` points at - a separate web app (deviation from upstream, which assumes both are the - same host).""" +def _ticket_card_url(ticket: Ticket) -> str: + """Our rendered ticket-card PNG — always available, and served by THIS + extension on the LNbits host, so it is built from `lnbits_baseurl` even + when `ticket_base_url` points at a separate web app (deviation from + upstream, which assumes both are the same host).""" return ( f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}" ) +def _ticket_image_url(ticket: Ticket, event: Event) -> str | None: + """Upstream's organiser-uploaded ticket template, opted into per wave. + + Distinct from `_ticket_card_url`: that is our own rendered card and is + always attached, this is a template the organiser uploads and enables + on a specific wave. They shared a name before the v1.6.8 merge, which + made them look like one feature. + """ + waves = ensure_ticket_waves(event) + wave = next( + (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), + waves[0], + ) + if not wave.use_ticket_image: + return None + + base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/") + return f"{base_url}/events/api/v1/qr/{ticket.id}" + + async def refund_tickets(event_id: str): """ Refund tickets for an event that has not met the minimum ticket requirement. diff --git a/static/image/ticket.jpg b/static/image/ticket.jpg new file mode 100644 index 0000000..e05d931 Binary files /dev/null and b/static/image/ticket.jpg differ diff --git a/static/js/display.js b/static/js/display.js index f687512..03c5f9f 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -13,6 +13,7 @@ window.PageEventsDisplay = { email: '', refund: '', nostr_identifier: '', + ticket_wave_id: null, payment_method: 'lightning' } }, @@ -46,26 +47,68 @@ window.PageEventsDisplay = { paymentMethods() { // Mirrors `effective_payment_methods` on the backend: an explicit // extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat. + // Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat` + // is only the PRIMARY wave's, so prefer the active wave when there is + // one — otherwise a later fiat-enabled wave would not offer fiat. const explicit = this.event?.extra?.payment_methods || [] if (explicit.length) return explicit - return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])] + const allowFiat = this.selectedTicketWave + ? this.selectedTicketWave.allow_fiat + : this.event?.allow_fiat + return ['lightning', ...(allowFiat ? ['fiat'] : [])] + }, + activeTicketWaves() { + const today = new Date().toISOString().slice(0, 10) + return (this.event?.extra?.ticket_waves || []).filter( + wave => + wave.amount_tickets > 0 && + wave.opening_date <= today && + wave.closing_date >= today + ) + }, + selectedTicketWave() { + return ( + this.activeTicketWaves.find( + wave => wave.id === this.formDialog.data.ticket_wave_id + ) || + this.activeTicketWaves[0] || + null + ) + }, + showTicketWaveSelector() { + return this.activeTicketWaves.length > 1 }, allowFiatCheckout() { return this.paymentMethods.includes('fiat') }, paymentMethodOptions() { + // Options come from `paymentMethods` — the same list the backend + // validates against in `effective_payment_methods` — rather than being + // re-derived from per-method booleans. v1.6.8 added a second copy of + // this computed that built the list from `allow_fiat`/`onchain_enabled` + // instead; because it was defined later in the object it silently won + // the merge, and it offers a "Bitcoin" option for any event with + // `extra.onchain_enabled` even though the backend rejects `onchain` + // unless it is in `extra.payment_methods` (views_api: "not in + // effective_payment_methods"). Labels below are upstream's; the source + // of the list is ours. + const labels = { + lightning: 'Lightning', + fiat: this.fiatCheckoutLabel, + onchain: 'Bitcoin' + } return this.paymentMethods.map(method => ({ value: method, - label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning' + label: labels[method] || method })) }, fiatCheckoutLabel() { if (!this.allowFiatCheckout) return 'Fiat' const unit = ['sat', 'sats'].includes( - (this.event?.currency || '').toLowerCase() + (this.selectedTicketWave?.currency || '').toLowerCase() ) - ? this.event?.fiat_currency - : this.event?.currency + ? this.selectedTicketWave?.fiat_currency + : this.selectedTicketWave?.currency return `Fiat (${(unit || 'GBP').toUpperCase()})` }, allowEmailNotifications() { @@ -82,6 +125,16 @@ window.PageEventsDisplay = { 'GET', `/events/api/v1/events/${this.eventId}` ) + const activeWaves = (data.extra?.ticket_waves || []).filter(wave => { + const today = new Date().toISOString().slice(0, 10) + return ( + wave.amount_tickets > 0 && + wave.opening_date <= today && + wave.closing_date >= today + ) + }) + this.formDialog.data.ticket_wave_id = + activeWaves.length === 1 ? activeWaves[0].id : null return data } catch (error) { this.eventErrorLabel = 'Event unavailable.' @@ -94,6 +147,10 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' + this.formDialog.data.ticket_wave_id = + this.activeTicketWaves.length === 1 + ? this.activeTicketWaves[0].id + : null this.formDialog.data.promo_code = '' this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning' @@ -108,6 +165,13 @@ window.PageEventsDisplay = { this.paymentWebsocket.close() this.paymentWebsocket = null } + this.paymentReq = null + this.receive = { + show: false, + status: 'pending', + paymentReq: null, + isFiat: false + } }, nameValidation(val) { const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g @@ -130,6 +194,10 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' + this.formDialog.data.ticket_wave_id = + this.activeTicketWaves.length === 1 + ? this.activeTicketWaves[0].id + : null this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning' Quasar.Notify.create({ @@ -160,12 +228,26 @@ window.PageEventsDisplay = { { name: this.formDialog.data.name, email: this.formDialog.data.email, + ticket_wave_id: this.formDialog.data.ticket_wave_id || null, promo_code: this.formDialog.data.promo_code || null, refund_address: this.formDialog.data.refund || null, nostr_identifier: this.formDialog.data.nostr_identifier || null, - payment_method: this.formDialog.data.payment_method + // Gate matches the template's (`paymentMethods.length > 1`). + // v1.6.8 gated on `showPaymentMethodSelector` + // (`allowFiatCheckout || allowOnchain`), a different condition: + // where the two disagreed the buyer's visible choice was + // silently replaced with 'lightning'. + payment_method: + this.paymentMethods.length > 1 + ? this.formDialog.data.payment_method + : this.paymentMethods[0] || 'lightning' } ) + if (data.satspay_charge_url) { + window.location.href = data.satspay_charge_url + return + } + const isFiat = Boolean(data.is_fiat) this.paymentReq = isFiat ? data.fiat_payment_request || null @@ -197,7 +279,7 @@ window.PageEventsDisplay = { const url = new URL(window.location) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/api/v1/ws/${paymentHash}` + url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` url.search = '' url.hash = '' @@ -206,7 +288,7 @@ window.PageEventsDisplay = { ws.onmessage = event => { const data = JSON.parse(event.data) - if (data.pending === false) { + if (data.paid === true) { this.paymentSuccess(paymentHash) ws.close() } @@ -215,8 +297,12 @@ window.PageEventsDisplay = { console.error('WebSocket error:', error) } ws.onclose = () => { - if (this.paymentWebsocket === ws) { - this.paymentWebsocket = null + if (this.paymentWebsocket !== ws) return + this.paymentWebsocket = null + if (this.receive.show) { + setTimeout(() => { + if (this.receive.show) this.paymentWatcher(paymentHash) + }, 3000) } } } diff --git a/static/js/display.vue b/static/js/display.vue index 5a2ed68..8388997 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -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." > @@ -89,6 +89,21 @@ lazy-rules :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" > +
Submit this.shortenId(row.id) + }, {name: 'name', align: 'left', label: 'Name', field: 'name'}, { name: 'event_start_date', @@ -39,12 +47,6 @@ window.PageEvents = { label: 'End date', field: 'event_end_date' }, - { - name: 'closing_date', - align: 'left', - label: 'Ticket close', - field: 'closing_date' - }, { name: 'canceled', align: 'left', @@ -129,8 +131,14 @@ window.PageEvents = { } }, ticketsTable: { + loading: false, columns: [ - {name: 'event', align: 'left', label: 'Event', field: 'event'}, + { + name: 'event', + align: 'left', + label: 'Event', + field: row => this.shortenId(row.event) + }, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'email', align: 'left', label: 'Email', field: 'email'}, { @@ -152,6 +160,12 @@ window.PageEvents = { label: 'Registered', field: 'registered' }, + { + name: 'nostr', + align: 'left', + label: 'Nostr', + field: row => row.extra?.nostr_identifier || '' + }, { name: 'promo_code', align: 'left', @@ -161,7 +175,11 @@ window.PageEvents = { {name: 'id', align: 'left', label: 'ID', field: 'id'} ], pagination: { - rowsPerPage: 10 + sortBy: 'time', + descending: true, + page: 1, + rowsPerPage: 10, + rowsNumber: 10 } }, // Rails an organizer can enable per event. Mirrors the webapp's @@ -218,12 +236,44 @@ window.PageEvents = { categories: [], extra: { payment_methods: ['lightning'], + ticket_waves: [], promo_codes: [], notification_subject: '', notification_body: '' } } - } + }, + ticketWaveDialog: { + show: false, + eventId: null, + wallet: null, + editingWaveId: null, + data: { + id: null, + title: '', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + }, + promoCodesDialog: { + show: false, + data: { + id: null, + wallet: null, + name: '', + extra: { + promo_codes: [] + } + } + }, + onchainWallets: [] } }, computed: { @@ -248,19 +298,196 @@ window.PageEvents = { } }, methods: { + shortenId(value) { + if (!value) return '' + return value.length > 4 ? `${value.slice(0, 4)}...` : value + }, + async loadOnchainWallets() { + const wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + if (!wallet) return + try { + const {data} = await LNbits.api.request( + 'GET', + '/events/api/v1/events/onchain/status', + wallet.adminkey + ) + this.onchainWallets = data.available ? data.wallets || [] : [] + } catch { + this.onchainWallets = [] + } + }, + async confirmOnchainTicket(ticket) { + const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet}) + if (!wallet) return + try { + await LNbits.api.request( + 'PUT', + `/events/api/v1/tickets/${ticket.id}/onchain-confirm`, + wallet.adminkey + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Onchain payment confirmed.', + icon: null + }) + await this.getTickets() + await this.getAllTickets() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + primaryTicketWave(data = this.formDialog.data) { + if (!data.extra) data.extra = {} + if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) { + data.extra.ticket_waves = [ + { + id: 'primary', + title: 'Primary wave', + opening_date: data.closing_date || '', + closing_date: data.closing_date || '', + currency: data.currency || 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: Boolean(data.allow_fiat), + fiat_currency: data.fiat_currency || 'GBP', + amount_tickets: data.amount_tickets || 0, + price_per_ticket: data.price_per_ticket || 0 + } + ] + } + return data.extra.ticket_waves[0] + }, + syncPrimaryWaveFromForm(data = this.formDialog.data) { + const primaryWave = this.primaryTicketWave(data) + primaryWave.title = primaryWave.title || 'Primary wave' + primaryWave.opening_date = primaryWave.opening_date || '' + primaryWave.closing_date = data.closing_date || '' + primaryWave.currency = data.currency || 'sats' + primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image) + primaryWave.ticket_image_id = primaryWave.ticket_image_id || null + primaryWave.allow_fiat = Boolean(data.allow_fiat) + primaryWave.fiat_currency = data.fiat_currency || 'GBP' + primaryWave.amount_tickets = Number(data.amount_tickets || 0) + primaryWave.price_per_ticket = Number(data.price_per_ticket || 0) + return primaryWave + }, + hydrateEventForm(data) { + const formData = { + ...data, + extra: { + ...(data.extra || {}), + ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])] + } + } + const primaryWave = this.primaryTicketWave(formData) + formData.currency = primaryWave.currency || formData.currency || 'sats' + formData.allow_fiat = Boolean(primaryWave.allow_fiat) + formData.fiat_currency = primaryWave.fiat_currency || 'GBP' + formData.amount_tickets = primaryWave.amount_tickets + formData.price_per_ticket = primaryWave.price_per_ticket + formData.closing_date = + primaryWave.closing_date || formData.closing_date || '' + return formData + }, isFiatCurrency(currency) { return !['sat', 'sats'].includes((currency || '').toLowerCase()) }, - getTickets() { - LNbits.api - .request( + normalizePromoCodes(promoCodes = []) { + return promoCodes + .filter(code => code.code?.trim() !== '') + .map(code => ({ + ...code, + code: code.code.trim().toUpperCase(), + // fork-only: blank / 0 = unlimited; used_count is derived server-side + max_uses: code.max_uses ? Number(code.max_uses) : null + })) + }, + templateDownloadUrl() { + return '/events/static/image/ticket.jpg' + }, + async uploadAssetFile(file) { + const form = new FormData() + form.append('file', file) + form.append('public_asset', 'true') + const {data} = await LNbits.api.request( + 'POST', + '/api/v1/assets?public_asset=true', + null, + form + ) + return data.id + }, + triggerTicketImageUpload(target) { + this.ticketImageUploadTarget = target + this.$refs.ticketImageUpload.value = null + this.$refs.ticketImageUpload.click() + }, + async handleTicketImageSelected(event) { + const file = event.target.files?.[0] + if (!file || !this.ticketImageUploadTarget) return + + this.isUploadingTicketTemplate = true + try { + const assetId = await this.uploadAssetFile(file) + if (this.ticketImageUploadTarget === 'primary') { + const wave = this.primaryTicketWave() + wave.use_ticket_image = true + wave.ticket_image_id = assetId + } else if (this.ticketImageUploadTarget === 'dialog') { + this.ticketWaveDialog.data.use_ticket_image = true + this.ticketWaveDialog.data.ticket_image_id = assetId + } + Quasar.Notify.create({ + type: 'positive', + message: 'Ticket template uploaded.', + icon: null + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.isUploadingTicketTemplate = false + this.ticketImageUploadTarget = null + } + }, + soldTicketsForWave(eventId, waveId) { + return this.allPaidTickets.filter( + ticket => + ticket.event === eventId && + ticket.paid && + (ticket.extra?.ticket_wave_id === waveId || + (!ticket.extra?.ticket_wave_id && waveId === 'primary')) + ).length + }, + async getAllTickets() { + try { + const {data} = await LNbits.api.request( 'GET', '/events/api/v1/tickets?all_wallets=true', this.g.user.wallets[0].adminkey ) - .then(response => { - this.tickets = response.data.filter(e => e.paid) - }) + this.allPaidTickets = data.filter(ticket => ticket.paid) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async getTickets(props) { + try { + this.ticketsTable.loading = true + const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props) + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/tickets/paginated?all_wallets=true&${params}`, + this.g.user.wallets[0].adminkey + ) + this.tickets = data.data + this.ticketsTable.pagination.rowsNumber = data.total + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.ticketsTable.loading = false + } }, deleteTicket(ticketId) { const tickets = _.findWhere(this.tickets, {id: ticketId}) @@ -275,10 +502,9 @@ window.PageEvents = { '/events/api/v1/tickets/' + ticketId, wallet.adminkey ) - .then(response => { - this.tickets = _.reject(this.tickets, function (obj) { - return obj.id == ticketId - }) + .then(async () => { + await this.getTickets() + await this.getAllTickets() }) .catch(LNbits.utils.notifyApiError) }) @@ -296,14 +522,30 @@ window.PageEvents = { wallet.adminkey ) .then(response => { + const result = response.data this.tickets = this.tickets.map(obj => - obj.id === ticket.id ? response.data : obj + obj.id === ticket.id ? result.ticket : obj ) - Quasar.Notify.create({ - type: 'positive', - message: 'Ticket email resent.', - icon: null - }) + + if (result.email?.attempted) { + Quasar.Notify.create({ + type: result.email.sent ? 'positive' : 'negative', + message: result.email.sent + ? 'Ticket email resent.' + : `Ticket email failed: ${result.email.error || 'Unknown error.'}`, + icon: null + }) + } + + if (result.nostr?.attempted) { + Quasar.Notify.create({ + type: result.nostr.sent ? 'positive' : 'negative', + message: result.nostr.sent + ? 'Ticket Nostr DM resent.' + : `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`, + icon: null + }) + } }) .catch(LNbits.utils.notifyApiError) .finally(() => { @@ -313,7 +555,7 @@ window.PageEvents = { }) }, exportticketsCSV() { - LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) + LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets) }, getEvents() { LNbits.api @@ -520,16 +762,14 @@ window.PageEvents = { data.location = (data.location || '').trim() || null data.categories = data.categories || [] data.closing_date = data.closing_date || null - + // Fold the form's day+time pairs first, then let upstream's helper + // mirror the form fields onto the primary wave — order matters, the + // sync reads closing_date/currency/amount_tickets off `data`. + this.syncPrimaryWaveFromForm(data) if (data.extra?.promo_codes) { - data.extra.promo_codes = data.extra.promo_codes - .filter(code => code.code?.trim() !== '') - .map(code => ({ - ...code, - code: code.code.trim().toUpperCase(), - // blank / 0 = unlimited; used_count is derived server-side - max_uses: code.max_uses ? Number(code.max_uses) : null - })) + data.extra.promo_codes = this.normalizePromoCodes( + data.extra.promo_codes + ) } const methods = data.extra?.payment_methods || [] if (methods.length === 0) { @@ -549,6 +789,7 @@ window.PageEvents = { } else if (!data.allow_fiat) { data.fiat_currency = 'GBP' } + this.syncPrimaryWaveFromForm(data) if (data.id) { this.updateEvent(wallet, data) @@ -561,10 +802,14 @@ window.PageEvents = { if (data && data.id) { const start = this.splitDateTime(data.event_start_date) const end = this.splitDateTime(data.event_end_date) + // Seed from upstream's hydrator (it materialises ticket_waves and + // mirrors the primary wave onto the flat form fields), then layer + // our fork-only fields on top. + const hydrated = this.hydrateEventForm(data) this.formDialog.data = { - ...data, + ...hydrated, extra: { - ...(data.extra || {}), + ...(hydrated.extra || {}), // Events created before extra.payment_methods existed carry an // empty list; show the rails the backend actually accepts for // them (Lightning always, fiat when allow_fiat). @@ -596,6 +841,25 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, + onchain_enabled: false, + onchain_wallet_id: null, + onchain_zeroconf: false, + onchain_fasttrack: false, + ticket_waves: [ + { + id: 'primary', + title: 'Primary wave', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + ], promo_codes: [], notification_subject: '', notification_body: '' @@ -603,6 +867,12 @@ window.PageEvents = { } } this.formDialog.show = true + if ( + this.formDialog.data.wallet && + this.formDialog.data.extra?.onchain_enabled + ) { + this.loadOnchainWallets() + } }, resetEventDialog() { this.formDialog.show = false @@ -614,8 +884,27 @@ window.PageEvents = { categories: [], extra: { payment_methods: ['lightning'], + conditional: false, + min_tickets: 1, email_notifications: false, nostr_notifications: false, + onchain_enabled: false, + onchain_wallet_id: null, + ticket_waves: [ + { + id: 'primary', + title: 'Primary wave', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + ], promo_codes: [], notification_subject: '', notification_body: '' @@ -636,6 +925,179 @@ window.PageEvents = { const link = _.findWhere(this.events, {id: formId}) this.openEventDialog(link) }, + openTicketWaveDialog(event, wave = null) { + const primaryWave = (event.extra?.ticket_waves || [])[0] || {} + const isEditing = Boolean(wave) + this.ticketWaveDialog = { + show: true, + eventId: event.id, + wallet: event.wallet, + editingWaveId: wave?.id || null, + data: { + id: wave?.id || null, + title: wave?.title || '', + opening_date: wave?.opening_date || '', + closing_date: wave?.closing_date || '', + currency: + wave?.currency || primaryWave.currency || event.currency || 'sats', + use_ticket_image: Boolean(wave?.use_ticket_image), + ticket_image_id: wave?.ticket_image_id || null, + allow_fiat: isEditing + ? Boolean(wave?.allow_fiat) + : Boolean(primaryWave.allow_fiat ?? event.allow_fiat), + fiat_currency: + wave?.fiat_currency || + primaryWave.fiat_currency || + event.fiat_currency || + 'GBP', + amount_tickets: wave?.amount_tickets || 0, + price_per_ticket: + wave?.price_per_ticket || + primaryWave.price_per_ticket || + event.price_per_ticket || + 0 + } + } + }, + resetTicketWaveDialog() { + this.ticketWaveDialog = { + show: false, + eventId: null, + wallet: null, + editingWaveId: null, + data: { + id: null, + title: '', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + } + }, + saveTicketWave() { + const event = _.findWhere(this.events, { + id: this.ticketWaveDialog.eventId + }) + const wallet = _.findWhere(this.g.user.wallets, { + id: this.ticketWaveDialog.wallet + }) + if (!event || !wallet) return + + const payload = { + ...event, + extra: { + ...event.extra, + ticket_waves: (event.extra?.ticket_waves || []).map(existingWave => + existingWave.id === this.ticketWaveDialog.editingWaveId + ? {...this.ticketWaveDialog.data} + : existingWave + ) + } + } + + if (!this.ticketWaveDialog.editingWaveId) { + payload.extra.ticket_waves.push({...this.ticketWaveDialog.data}) + } + + if (payload.extra?.promo_codes) { + payload.extra.promo_codes = this.normalizePromoCodes( + payload.extra.promo_codes + ) + } + + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + payload.id, + wallet.adminkey, + payload + ) + .then(response => { + this.events = this.events.map(item => + item.id === payload.id ? response.data : item + ) + Quasar.Notify.create({ + type: 'positive', + message: this.ticketWaveDialog.editingWaveId + ? 'Ticket wave updated.' + : 'Ticket wave added.', + icon: null + }) + this.resetTicketWaveDialog() + }) + .catch(LNbits.utils.notifyApiError) + }, + openPromoCodesDialog(event) { + this.promoCodesDialog.data = { + ...event, + extra: { + ...event.extra, + promo_codes: [...(event.extra?.promo_codes || [])] + } + } + this.promoCodesDialog.show = true + }, + resetPromoCodesDialog() { + this.promoCodesDialog.show = false + this.promoCodesDialog.data = { + id: null, + wallet: null, + name: '', + extra: { + promo_codes: [] + } + } + }, + addPromoCodeToDialog() { + this.promoCodesDialog.data.extra.promo_codes.push({ + code: '', + discount_percent: 0, + active: true, + // fork-only: null = unlimited uses + max_uses: null + }) + }, + savePromoCodes() { + const data = this.promoCodesDialog.data + const wallet = _.findWhere(this.g.user.wallets, { + id: data.wallet + }) + if (!wallet) return + + const payload = { + ...data, + extra: { + ...data.extra, + promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || []) + } + } + + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + data.id, + wallet.adminkey, + payload + ) + .then(response => { + this.events = this.events.map(event => + event.id === data.id ? response.data : event + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Promo codes updated.', + icon: null + }) + this.resetPromoCodesDialog() + }) + .catch(LNbits.utils.notifyApiError) + }, updateEvent(wallet, data) { LNbits.api .request( @@ -702,6 +1164,7 @@ window.PageEvents = { async created() { if (this.g.user.wallets.length) { this.getTickets() + this.getAllTickets() this.getEvents() this.getSettings() this.getPendingEvents() diff --git a/static/js/index.vue b/static/js/index.vue index fc7db77..86e082a 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -243,45 +243,102 @@
-
Promo codes
+
+
Ticket waves
+ +
+
+
+
+ + + +
+
+
+ +
+
Promo codes
+ +
- No promo codes for this event. + No active promo codes for this event.
-
-
+
+
- - +
-
- Discount: - % -
-
- Status: - -
@@ -308,9 +365,11 @@ dense flat :rows="tickets" + :loading="ticketsTable.loading" row-key="id" :columns="ticketsTable.columns" v-model:pagination="ticketsTable.pagination" + @request="getTickets" > diff --git a/static/js/ticket.js b/static/js/ticket.js index 82fbd6d..6ff55b6 100644 --- a/static/js/ticket.js +++ b/static/js/ticket.js @@ -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 + }) } } diff --git a/static/js/ticket.vue b/static/js/ticket.vue index 3c932e1..23a8dc4 100644 --- a/static/js/ticket.vue +++ b/static/js/ticket.vue @@ -36,4 +36,53 @@
+ + +
+ Ticket QR +
+
+ + diff --git a/tests/test_promo.py b/tests/test_promo.py index a4067c3..b3bb77e 100644 --- a/tests/test_promo.py +++ b/tests/test_promo.py @@ -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 diff --git a/views_api.py b/views_api.py index 2d596d5..b361ac3 100644 --- a/views_api.py +++ b/views_api.py @@ -2,6 +2,7 @@ import asyncio from datetime import datetime, timezone from http import HTTPStatus from io import BytesIO +from pathlib import Path from typing import Any from urllib.parse import urlsplit @@ -16,17 +17,20 @@ from fastapi import ( ) from fastapi.responses import StreamingResponse from lnbits.core.crud import get_user +from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import Account, User, WalletTypeInfo from lnbits.core.models.payments import CreateInvoice from lnbits.core.services import create_payment_request +from lnbits.db import Filters, Page from lnbits.decorators import ( check_admin, check_user_exists, + parse_filters, require_admin_key, require_invoice_key, ) -from lnbits.helpers import urlsafe_short_hash +from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, @@ -34,6 +38,8 @@ from lnbits.utils.exchange_rates import ( satoshis_amount_as_fiat, ) from lnbits.utils.nostr import normalize_public_key +from loguru import logger +from PIL import Image from .crud import ( create_event, @@ -53,6 +59,7 @@ from .crud import ( get_tickets_by_event, get_tickets_by_payment_hash, get_tickets_by_user_id, + get_tickets_paginated, purge_unpaid_tickets, update_event, update_settings, @@ -68,9 +75,13 @@ from .models import ( PublicEvent, PublicTicket, Ticket, + TicketFilters, TicketPaymentRequest, TicketResendResult, + TicketWave, effective_payment_methods, + ensure_ticket_waves, + get_active_ticket_waves, ) from .nostr_hooks import publish_or_delete_nostr_event from .promo import ( @@ -88,19 +99,57 @@ from .qr import ( ticket_card_filename, ) from .services import ( + create_satspay_charge, event_promo_usage, + fetch_watchonly_config, + fetch_watchonly_wallets, + get_satspay_charge, hydrate_promo_usage, refund_tickets, resend_ticket_email_notification, send_ticket_notification_in_background, set_ticket_paid, ) -from .tasks import deregister_payment_listener, register_payment_listener +from .tasks import ( + deregister_payment_listener, + payment_listeners, + register_payment_listener, +) events_api_router = APIRouter(prefix="/api/v1/events") tickets_api_router = APIRouter(prefix="/api/v1/tickets") qr_api_router = APIRouter(prefix="/api/v1") promo_api_router = APIRouter(prefix="/api/v1/promo") +tickets_filters = parse_filters(TicketFilters) + + +async def _get_watchonly_status(wallet) -> dict[str, Any]: + try: + config = await fetch_watchonly_config(wallet.inkey) + network = config.get("network") + if not network: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Watchonly extension returned an invalid network.", + ) + wallets = await fetch_watchonly_wallets(wallet.inkey, network) + except HTTPException: + raise + except Exception as exc: + return { + "available": False, + "message": f"Watchonly extension is not reachable: {exc!s}", + "network": None, + "wallets": [], + "mempool_endpoint": None, + } + return { + "available": True, + "message": None, + "network": network, + "wallets": wallets, + "mempool_endpoint": config.get("mempool_endpoint"), + } def _is_fiat_currency(currency: str | None) -> bool: @@ -109,8 +158,6 @@ def _is_fiat_currency(currency: str | None) -> bool: # Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared # before any "/{event_id}" route or FastAPI matches them as a path parameter. - - @events_api_router.get("") async def api_events( all_wallets: bool = Query(False), @@ -247,6 +294,13 @@ async def api_get_settings_public( return {"auto_approve": settings.auto_approve} +@events_api_router.get("/onchain/status") +async def api_onchain_status( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> dict[str, Any]: + return await _get_watchonly_status(wallet.wallet) + + @events_api_router.get("/{event_id}", response_model=PublicEvent) async def api_get_event(event_id: str) -> Event: """Public event detail used by display.vue. @@ -273,28 +327,44 @@ async def api_get_event(event_id: str) -> Event: closing_date = event.closing_date or event.event_end_date or event.event_start_date # Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date # may carry a time component since v1.3.0-aio.3 / our start-end-time - # feature). + # feature). Upstream v1.6.8 parses closing_date with a bare + # strptime("%Y-%m-%d") here; that raises ValueError on any event of ours + # whose closing date carries a time, which is most of them. try: closing_dt = datetime.fromisoformat(closing_date) except ValueError: closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d") if closing_dt.tzinfo is None: closing_dt = closing_dt.replace(tzinfo=timezone.utc) - is_window_open = datetime.now(timezone.utc) < closing_dt + + now = datetime.now(timezone.utc) + today = now.date() + active_waves = get_active_ticket_waves(event, today) + # `is_sales_closed` is upstream's name for `not is_window_open`; the + # comparison stays ours (instant-precise) rather than upstream's + # whole-day `today > closing_date.date()`. Upstream's form keeps sales + # open for the whole of the closing day, which for our datetime-bearing + # closing dates would extend every existing event's sales window. + is_sales_closed = now >= closing_dt is_min_tickets_met = ( event.sold >= event.extra.min_tickets if event.extra.conditional else True ) if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") - if event.extra.conditional and not is_min_tickets_met and not is_window_open: + if event.extra.conditional and not is_min_tickets_met and is_sales_closed: event.canceled = True await update_event(event) await refund_tickets(event_id) raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") - if not is_window_open: + if not active_waves: raise HTTPException( - status_code=HTTPStatus.GONE, detail="Ticket closing date has passed." + status_code=HTTPStatus.GONE, + detail=( + "Ticket closing date has passed." + if is_sales_closed + else "No ticket wave is currently open." + ), ) return event @@ -536,6 +606,31 @@ async def api_tickets_by_user( return await get_tickets_by_user_id(user_id) +@tickets_api_router.get( + "/paginated", + summary="Get paginated list of tickets", + openapi_extra=generate_filter_params_openapi(TicketFilters), + response_model=Page[Ticket], +) +async def api_tickets_paginated( + all_wallets: bool = Query(False), + filters: Filters = Depends(tickets_filters), + key_info: WalletTypeInfo = Depends(require_admin_key), +) -> Page[Ticket]: + wallet_ids = [key_info.wallet.id] + + if all_wallets: + user = await get_user(key_info.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + if not filters.sortby: + filters.sortby = "time" + if not filters.direction: + filters.direction = "desc" + + return await get_tickets_paginated(wallet_ids, filters) + + @tickets_api_router.get("/{ticket_id}", response_model=PublicTicket) async def api_get_ticket(ticket_id: str) -> Ticket: ticket = await get_ticket(ticket_id) @@ -597,6 +692,36 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str: return data.frontend_url.rstrip("/") +def _resolve_ticket_wave(event: Event, ticket_wave_id: str | None) -> TicketWave: + """The wave a purchase or a price preview is priced against. + + Shared by the purchase and promo-validate endpoints so the two cannot + drift: whatever wave the preview quoted is the wave the invoice charges. + Selection is upstream v1.6.8's — an explicit id must be an OPEN wave, a + single open wave is implied, and an ambiguous choice is an error rather + than a silent pick. + """ + active_waves = get_active_ticket_waves(event) + if not active_waves: + raise HTTPException( + status_code=HTTPStatus.GONE, detail="No ticket wave is currently open." + ) + if ticket_wave_id: + wave = next((wave for wave in active_waves if wave.id == ticket_wave_id), None) + if not wave: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid ticket wave selected.", + ) + return wave + if len(active_waves) == 1: + return active_waves[0] + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Please select a ticket wave.", + ) + + async def _issue_free_tickets( *, event: Event, @@ -607,6 +732,7 @@ async def _issue_free_tickets( promo_code: str | None, nostr_identifier: str | None, frontend_root: str, + selected_wave: TicketWave, ) -> TicketPaymentRequest: """Issue `quantity` free tickets without minting an invoice. @@ -635,6 +761,11 @@ async def _issue_free_tickets( ticket_id=row_id, extra={ "applied_promo_code": promo_code, + # Free tickets consume wave stock exactly like paid ones — + # `set_ticket_paid` runs on this path too — so they must name + # their wave or the decrement lands on the primary wave. + "ticket_wave_id": selected_wave.id, + "ticket_wave_title": selected_wave.title, "nostr_identifier": nostr_identifier, "ticket_base_url": frontend_root, "sats_paid": 0, @@ -690,11 +821,16 @@ async def api_ticket_create( refund_address = data.refund_address nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None payment_method = (data.payment_method or "lightning").lower() - if payment_method not in {"lightning", "fiat"}: + if payment_method not in {"lightning", "fiat", "onchain"}: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Unsupported payment method.", ) + # npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this + # with a hard "Only NIP-05 Nostr identifiers are supported." rejection — + # true for upstream, false here: `_send_nostr_ticket_notification` sends + # bare pubkeys via `send_nostr_dm`. That rejection merged in outside any + # conflict marker, so it would have silently dropped npub checkout. if nostr_identifier and "@" not in nostr_identifier: try: nostr_identifier = normalize_public_key(nostr_identifier) @@ -703,6 +839,8 @@ async def api_ticket_create( status_code=HTTPStatus.BAD_REQUEST, detail="Invalid Nostr identifier.", ) from exc + + selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id) extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} frontend_root = _resolve_frontend_root(data, request) @@ -733,9 +871,18 @@ async def api_ticket_create( ), ) extra["promo_code"] = promo.code - price = basket_totals(event, [promo.code], quantity, usage).total + # Priced off `selected_wave`, not `event`: since v1.6.8 the price and + # currency live on the wave, and the event-level fields are a roll-up + # of the PRIMARY wave (`sync_event_ticket_waves`). Pricing off the + # event charges every buyer the first wave's price whichever wave they + # actually picked. Upstream prices one ticket; `basket_totals` keeps + # our quantity + promo arithmetic, so the "Apply" preview and the + # invoice still agree. + price = basket_totals(event, [promo.code], quantity, usage, selected_wave).total else: - price = round_amount(event.price_per_ticket * quantity, event.currency) + price = round_amount( + selected_wave.price_per_ticket * quantity, selected_wave.currency + ) # Free tickets (final charge 0 — a free event or a 100%-off promo). # Short-circuit before any invoice / fiat-provider logic: no Lightning @@ -751,6 +898,16 @@ async def api_ticket_create( promo_code=promo_code, nostr_identifier=nostr_identifier, frontend_root=frontend_root, + selected_wave=selected_wave, + ) + + # Fiat is a per-wave opt-in since v1.6.8. `effective_payment_methods` + # below reads `event.allow_fiat`, which is only the PRIMARY wave's, so + # this check is what actually protects a non-fiat wave. + if payment_method == "fiat" and not selected_wave.allow_fiat: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Fiat payments are not enabled for this ticket wave.", ) # Organizer-controlled rails (extra.payment_methods; legacy events fall @@ -762,23 +919,25 @@ async def api_ticket_create( detail="Payment method not enabled for this event.", ) - if _is_fiat_currency(event.currency): + if _is_fiat_currency(selected_wave.currency): extra["fiat"] = True - extra["currency"] = event.currency + extra["currency"] = selected_wave.currency extra["fiatAmount"] = price - extra["rate"] = await get_fiat_rate_satoshis(event.currency) + extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency) if payment_method != "fiat": - price = await fiat_amount_as_satoshis(price, event.currency) + price = await fiat_amount_as_satoshis(price, selected_wave.currency) - invoice_unit = event.currency + invoice_unit = selected_wave.currency fiat_amount = price fiat_provider = None + onchain_amount_sat = None + if payment_method == "fiat": - if _is_fiat_currency(event.currency): - invoice_unit = event.currency + if _is_fiat_currency(selected_wave.currency): + invoice_unit = selected_wave.currency else: - invoice_unit = event.fiat_currency + invoice_unit = selected_wave.fiat_currency fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit) extra["fiat"] = True extra["currency"] = invoice_unit @@ -797,6 +956,80 @@ async def api_ticket_create( status_code=HTTPStatus.BAD_REQUEST, detail="No fiat payment provider configured for this event.", ) + elif payment_method == "onchain": + onchain_amount_sat = int(price) + wallet_record = await get_wallet(event.wallet) + if not wallet_record: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Event wallet does not exist.", + ) + if not event.extra.onchain_enabled: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Onchain payments are not enabled for this event.", + ) + if not event.extra.onchain_wallet_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No onchain wallet configured for this event.", + ) + + ticket_id = urlsafe_short_hash() + base_url = str(request.base_url).rstrip("/") + # Use internal address for webhook — SatsPay calls it server-side and + # cannot reach the public domain from within the container. + internal_base = f"http://{settings.host}:{settings.port}" + webhook_url = ( + f"{internal_base}/events/api/v1/tickets/{ticket_id}/satspay-webhook" + ) + complete_url = f"{base_url}/events/ticket/{ticket_id}" + try: + charge = await create_satspay_charge( + api_key=wallet_record.inkey, + data={ + "amount": onchain_amount_sat, + "description": f"Ticket for {event.name}", + "name": name, + "onchainwallet": event.extra.onchain_wallet_id, + "zeroconf": event.extra.onchain_zeroconf, + "fasttrack": event.extra.onchain_fasttrack, + "webhook": webhook_url, + "completelink": complete_url, + "completelinktext": "View your ticket", + "time": 1440, + }, + ) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to create SatsPay charge: {exc}", + ) from exc + + await create_ticket( + payment_hash=ticket_id, + wallet=event.wallet, + event=event.id, + name=name, + email=email, + extra={ + "applied_promo_code": promo_code, + "ticket_wave_id": selected_wave.id, + "ticket_wave_title": selected_wave.title, + "refund_address": refund_address, + "nostr_identifier": nostr_identifier, + "ticket_base_url": base_url, + "sats_paid": onchain_amount_sat, + "onchain": True, + "satspay_charge_id": charge["id"], + }, + ) + + return TicketPaymentRequest( + payment_hash=ticket_id, + onchain_amount_sat=onchain_amount_sat, + satspay_charge_url=f"/satspay/{charge['id']}", + ) else: invoice_unit = "sat" @@ -854,6 +1087,12 @@ async def api_ticket_create( ticket_id=row_id, extra={ "applied_promo_code": promo_code, + # Which wave this ticket came from. `set_ticket_paid` debits + # the wave named here and falls back to waves[0] when it is + # absent, so omitting it would take every sale off the + # primary wave no matter what the buyer bought. + "ticket_wave_id": selected_wave.id, + "ticket_wave_title": selected_wave.title, "refund_address": refund_address, "nostr_identifier": nostr_identifier, "ticket_base_url": frontend_root, @@ -955,9 +1194,78 @@ async def api_ticket_delete( await delete_ticket(ticket_id) +@tickets_api_router.post("/{ticket_id}/satspay-webhook") +async def api_ticket_satspay_webhook(ticket_id: str) -> None: + ticket = await get_ticket(ticket_id) + if not ticket: + logger.warning(f"SatsPay webhook: ticket {ticket_id} does not exist.") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + if ticket.paid: + logger.warning(f"SatsPay webhook: ticket {ticket_id} already paid.") + return + if not ticket.extra.satspay_charge_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Not a SatsPay ticket." + ) + wallet = await get_wallet(ticket.wallet) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket wallet does not exist." + ) + try: + charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=f"Could not verify charge: {exc}" + ) from exc + if not charge.get("paid"): + logger.warning( + f"SatsPay webhook for ticket {ticket_id}: charge" + f" {ticket.extra.satspay_charge_id} not paid." + ) + return + + ticket = await set_ticket_paid(ticket) + send_ticket_notification_in_background(ticket) + for queue in payment_listeners.get(ticket_id, []): + queue.put_nowait(ticket) + + +@tickets_api_router.put("/{payment_hash}/onchain-confirm") +async def api_ticket_onchain_confirm( + payment_hash: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Ticket: + ticket = await get_ticket(payment_hash) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + if ticket.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.") + if ticket.paid: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Ticket already paid." + ) + if not ticket.extra.onchain: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Ticket is not an onchain payment.", + ) + ticket = await set_ticket_paid(ticket) + send_ticket_notification_in_background(ticket) + for queue in payment_listeners.get(payment_hash, []): + queue.put_nowait(ticket) + return ticket + + @tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) async def api_ticket_resend_email( - ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) + ticket_id: str, + request: Request, + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> TicketResendResult: ticket = await get_ticket(ticket_id) if not ticket: @@ -975,16 +1283,13 @@ async def api_ticket_resend_email( ) try: - return await resend_ticket_email_notification(ticket) + return await resend_ticket_email_notification( + ticket, str(request.base_url).rstrip("/") + ) except ValueError as exc: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) ) from exc - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to resend ticket email.", - ) from exc @tickets_api_router.put("/register/{ticket_id}") @@ -1095,28 +1400,76 @@ async def api_event_ticket_stats( @qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse) async def api_ticket_qr(ticket_id: str): - """PNG of the ticket's scan payload (`ticket://`), 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://`). + + 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." + ) - 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 = { + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + } + + waves = ensure_ticket_waves(event) + wave = next( + (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), + waves[0], ) + if not wave.use_ticket_image: + logo = await load_qr_logo() + image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo) + return StreamingResponse( + BytesIO(image_png_bytes(image)), + media_type="image/png", + headers=headers, + ) + + background_bytes = None + if wave.ticket_image_id: + asset = await get_public_asset(wave.ticket_image_id) + if asset: + background_bytes = asset.data + + if background_bytes: + ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA") + else: + default_template = ( + Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg" + ) + ticket_image = Image.open(default_template).convert("RGBA") + + ticket_image.paste(make_qr_png(f"ticket://{ticket_id}", size=157), (122, 505)) + output = BytesIO() + ticket_image.save(output, format="PNG") + output.seek(0) + return StreamingResponse(output, media_type="image/png", headers=headers) + @qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse) async def api_ticket_card(ticket_id: str): @@ -1162,4 +1515,7 @@ async def api_validate_promo_codes( status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." ) usage = await event_promo_usage(event_id) if event.extra.promo_codes else {} - return basket_totals(event, data.codes, data.quantity, usage) + # Same resolver the purchase endpoint uses, so the quote and the charge + # are priced against the same wave. + wave = _resolve_ticket_wave(event, data.ticket_wave_id) + return basket_totals(event, data.codes, data.quantity, usage, wave)