From 777c107c63556a5d76191b4c130dbac09f0c9352 Mon Sep 17 00:00:00 2001 From: Arc <33088785+arcbtc@users.noreply.github.com> Date: Fri, 22 May 2026 20:18:39 +0100 Subject: [PATCH 1/9] feat: UI fixes, ticket waves, ticket image (#52) * email input fixes * promo code ui fix * ticket waves * works, but horrible slop * slightly cleaner * filter most recent * make * pyright --- __init__.py | 3 +- crud.py | 45 +++- models.py | 117 +++++++- services.py | 241 ++++++++++++----- static/image/ticket.jpg | Bin 0 -> 106602 bytes static/js/display.js | 57 +++- static/js/display.vue | 18 +- static/js/index.js | 502 +++++++++++++++++++++++++++++----- static/js/index.vue | 582 ++++++++++++++++++++++++++++++---------- static/js/ticket.js | 29 +- static/js/ticket.vue | 49 ++++ views_api.py | 240 ++++++++++++++--- 12 files changed, 1569 insertions(+), 314 deletions(-) create mode 100644 static/image/ticket.jpg diff --git a/__init__.py b/__init__.py index ef37528..827b8d5 100644 --- a/__init__.py +++ b/__init__.py @@ -6,12 +6,13 @@ from loguru import logger from .crud import db from .tasks import wait_for_paid_invoices from .views import events_generic_router -from .views_api import events_api_router, tickets_api_router +from .views_api import events_api_router, qr_api_router, tickets_api_router events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext.include_router(events_generic_router) events_ext.include_router(events_api_router) events_ext.include_router(tickets_api_router) +events_ext.include_router(qr_api_router) events_static_files = [ { diff --git a/crud.py b/crud.py index 3046f0e..930915e 100644 --- a/crud.py +++ b/crud.py @@ -1,9 +1,17 @@ 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, Ticket, TicketExtra +from .models import ( + CreateEvent, + Event, + Ticket, + TicketExtra, + TicketFilters, + sync_event_ticket_waves, +) db = Database("ext_events") @@ -51,6 +59,31 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: ) +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}) @@ -75,31 +108,35 @@ async def purge_unpaid_tickets(event_id: str) -> None: async def create_event(data: CreateEvent) -> Event: event_id = urlsafe_short_hash() 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 delete_event(event_id: str) -> None: diff --git a/models.py b/models.py index fa1569f..331ad6d 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,8 @@ -from datetime import datetime +from datetime import date, datetime +from uuid import uuid4 from fastapi import Query +from lnbits.db import FilterModel from pydantic import BaseModel, EmailStr, Field, validator @@ -20,8 +22,23 @@ 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 EventExtra(BaseModel): promo_codes: list[PromoCode] = Field(default_factory=list) + ticket_waves: list[TicketWave] = Field(default_factory=list) conditional: bool = False min_tickets: int = 1 email_notifications: bool = False @@ -84,6 +101,8 @@ class PublicEvent(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 @@ -96,6 +115,7 @@ class TicketExtra(BaseModel): class CreateTicket(BaseModel): name: str email: EmailStr + ticket_wave_id: str | None = None promo_code: str | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -116,6 +136,22 @@ class Ticket(BaseModel): extra: TicketExtra = Field(default_factory=TicketExtra) +class NotificationDeliveryResult(BaseModel): + attempted: bool = False + sent: bool = False + error: str | None = None + + +class TicketResendResult(BaseModel): + ticket: Ticket + email: NotificationDeliveryResult = Field( + default_factory=NotificationDeliveryResult + ) + nostr: NotificationDeliveryResult = Field( + default_factory=NotificationDeliveryResult + ) + + class PublicTicket(BaseModel): event: str name: str @@ -131,3 +167,82 @@ class TicketPaymentRequest(BaseModel): fiat_payment_request: str | None = None fiat_provider: str | None = None is_fiat: bool = False + + +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: + return datetime.strptime(value, "%Y-%m-%d").date() + + +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 + + 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 = event.closing_date + + return [ + TicketWave( + id="primary", + title="Primary wave", + opening_date=fallback_opening_date, + closing_date=event.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/services.py b/services.py index 159bbdc..057026b 100644 --- a/services.py +++ b/services.py @@ -1,15 +1,15 @@ from __future__ import annotations +import smtplib from asyncio.tasks import create_task +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from html import escape from lnbits.core.models.users import UserNotifications -from lnbits.core.services.nostr import send_nostr_dm -from lnbits.core.services.notifications import ( - send_email_notification, - send_user_notification, -) +from lnbits.core.services.notifications import send_user_notification +from lnbits.helpers import is_valid_email_address from lnbits.settings import settings -from lnbits.utils.nostr import normalize_private_key, normalize_public_key from lnurl import execute from loguru import logger @@ -20,13 +20,13 @@ from .crud import ( update_event, update_ticket, ) -from .models import Event, Ticket - -DEFAULT_NOSTR_RELAYS = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://relay.nostr.band", -] +from .models import ( + Event, + NotificationDeliveryResult, + Ticket, + TicketResendResult, + ensure_ticket_waves, +) async def set_ticket_paid(ticket: Ticket) -> Ticket: @@ -39,7 +39,16 @@ 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 + 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 await update_event(event) return ticket @@ -55,40 +64,12 @@ async def _send_ticket_notification(ticket: Ticket) -> None: logger.warning(f"Event {ticket.event} not found for ticket notification.") return - subject, message = _ticket_notification_message(ticket, event) - updated = False - - if ( - event.extra.email_notifications - and settings.lnbits_email_notifications_enabled - and ticket.email - ): - try: - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to email ticket {ticket.id}: {exc}") - - if ( - event.extra.nostr_notifications - and settings.is_nostr_notifications_configured() - and ticket.extra.nostr_identifier - ): - try: - await _send_nostr_ticket_notification( - ticket.extra.nostr_identifier, message - ) - ticket.extra.nostr_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") - - if updated: - await update_ticket(ticket) + await _deliver_ticket_notifications(ticket, event) -async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: +async def resend_ticket_email_notification( + ticket: Ticket, base_url: str | None = None +) -> TicketResendResult: event = await get_event(ticket.event) if not event: raise ValueError("Event does not exist.") @@ -96,11 +77,10 @@ async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: 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("/") - subject, message = _ticket_notification_message(ticket, event) - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - return await update_ticket(ticket) + return await _deliver_ticket_notifications(ticket, event) def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: @@ -117,18 +97,144 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str return subject, f"{body}\n\nOpen it here: {ticket_url}" -async def _send_nostr_ticket_notification(identifier: str, message: str) -> None: - if "@" in identifier: - await send_user_notification( - UserNotifications(nostr_identifier=identifier), - message, - "text_message", - ) - return +def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str: + ticket_image_url = _ticket_image_url(ticket, event) + if not ticket_image_url: + return base_message - private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key) - public_key = normalize_public_key(identifier) - await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS) + return f"{base_message}\n\nTicket image: {ticket_image_url}" + + +def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str: + text_message = _ticket_delivery_message(ticket, event, base_message) + html_message = f"

{escape(text_message).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]: + subject, base_message = _ticket_notification_message(ticket, event) + text_message = _ticket_delivery_message(ticket, event, base_message) + html_message = _ticket_email_html_message(ticket, event, base_message) + return subject, text_message, html_message + + +def _supports_nostr_delivery(identifier: str | None) -> bool: + return bool(identifier and "@" in identifier) + + +async def _deliver_ticket_notifications( + ticket: Ticket, event: Event +) -> TicketResendResult: + subject, text_message, html_message = _ticket_notification_payload(ticket, event) + updated = False + result = TicketResendResult( + ticket=ticket, + email=NotificationDeliveryResult( + attempted=bool( + event.extra.email_notifications + and settings.lnbits_email_notifications_enabled + and ticket.email + ) + ), + nostr=NotificationDeliveryResult( + attempted=bool( + event.extra.nostr_notifications + and settings.is_nostr_notifications_configured() + and ticket.extra.nostr_identifier + ) + ), + ) + + if result.email.attempted: + try: + await _send_ticket_email_notification( + [ticket.email], text_message, subject, html_message + ) + ticket.extra.email_notification_sent = True + result.email.sent = True + updated = True + except Exception as exc: + logger.warning(f"Failed to email ticket {ticket.id}: {exc}") + result.email.error = str(exc) + + if result.nostr.attempted and not _supports_nostr_delivery( + ticket.extra.nostr_identifier + ): + result.nostr.error = "Only NIP-05 Nostr identifiers are supported." + elif result.nostr.attempted: + try: + identifier = ticket.extra.nostr_identifier + assert identifier is not None + await _send_nostr_ticket_notification(identifier, text_message) + ticket.extra.nostr_notification_sent = True + result.nostr.sent = True + updated = True + except Exception as exc: + logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") + result.nostr.error = str(exc) + + if updated: + result.ticket = await update_ticket(ticket) + return result + + +async def _send_nostr_ticket_notification(identifier: str, message: str) -> None: + await send_user_notification( + UserNotifications(nostr_identifier=identifier), + message, + "text_message", + ) + + +async def _send_ticket_email_notification( + to_emails: list[str], + message: str, + subject: str, + html_message: str | None = None, +) -> None: + if not settings.lnbits_email_notifications_enabled: + raise ValueError("Email notifications are disabled") + if not is_valid_email_address(settings.lnbits_email_notifications_email): + raise ValueError( + f"Invalid from email address: {settings.lnbits_email_notifications_email}" + ) + if not to_emails: + raise ValueError("No email addresses provided") + for email in to_emails: + if not is_valid_email_address(email): + raise ValueError(f"Invalid email address: {email}") + + msg = MIMEMultipart("alternative") + msg["From"] = settings.lnbits_email_notifications_email + msg["To"] = ", ".join(to_emails) + msg["Subject"] = subject + msg.attach(MIMEText(message, "plain")) + if html_message: + msg.attach(MIMEText(html_message, "html")) + + username = ( + settings.lnbits_email_notifications_username + or settings.lnbits_email_notifications_email + ) + with smtplib.SMTP( + settings.lnbits_email_notifications_server, + settings.lnbits_email_notifications_port, + ) as smtp_server: + smtp_server.starttls() + smtp_server.login(username, settings.lnbits_email_notifications_password) + smtp_server.sendmail( + settings.lnbits_email_notifications_email, + to_emails, + msg.as_string(), + ) def _ticket_url(ticket: Ticket) -> str: @@ -136,6 +242,19 @@ def _ticket_url(ticket: Ticket) -> str: return f"{base_url}/events/ticket/{ticket.id}" +def _ticket_image_url(ticket: Ticket, event: Event) -> str | None: + 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 0000000000000000000000000000000000000000..e05d9318a5dd38ef26268c0c516f0c057a115473 GIT binary patch literal 106602 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 + 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 Boolean(this.event?.allow_fiat) + return Boolean(this.selectedTicketWave?.allow_fiat) }, 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() { @@ -66,6 +88,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.' @@ -78,6 +110,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 = 'lightning' }, @@ -112,6 +148,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 = 'lightning' Quasar.Notify.create({ type: 'positive', @@ -141,10 +181,13 @@ 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 + payment_method: this.allowFiatCheckout + ? this.formDialog.data.payment_method + : 'lightning' } ) const isFiat = Boolean(data.is_fiat) @@ -178,7 +221,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 = '' @@ -187,7 +230,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() } diff --git a/static/js/display.vue b/static/js/display.vue index f5ac5fb..9051fbf 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -49,7 +49,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." > @@ -64,6 +64,21 @@ lazy-rules :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" > +

this.shortenId(row.id) + }, {name: 'name', align: 'left', label: 'Name', field: 'name'}, { name: 'event_start_date', @@ -22,12 +30,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', @@ -38,43 +40,21 @@ window.PageEvents = { } return 'No' } - }, - { - name: 'price_per_ticket', - align: 'left', - label: 'Price', - field: row => { - if (this.isFiatCurrency(row.currency)) { - return LNbits.utils.formatCurrency( - row.price_per_ticket.toFixed(2), - row.currency - ) - } - return row.price_per_ticket - } - }, - { - name: 'amount_tickets', - align: 'left', - label: 'No tickets', - field: 'amount_tickets' - }, - { - name: 'sold', - align: 'left', - label: 'Sold', - field: 'sold' - }, - {name: 'info', align: 'left', label: 'Info', field: 'info'}, - {name: 'banner', align: 'left', label: 'Banner', field: 'banner'} + } ], pagination: { rowsPerPage: 10 } }, 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'}, { @@ -83,6 +63,12 @@ window.PageEvents = { label: 'Registered', field: 'registered' }, + { + name: 'nostr', + align: 'left', + label: 'Nostr', + field: row => row.extra?.nostr_identifier || '' + }, { name: 'promo_code', align: 'left', @@ -92,7 +78,11 @@ window.PageEvents = { {name: 'id', align: 'left', label: 'ID', field: 'id'} ], pagination: { - rowsPerPage: 10 + sortBy: 'time', + descending: true, + page: 1, + rowsPerPage: 10, + rowsNumber: 10 } }, formDialog: { @@ -102,28 +92,198 @@ window.PageEvents = { allow_fiat: false, fiat_currency: 'GBP', extra: { + 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: [] + } + } } } }, 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() + })) + }, + 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}) @@ -138,10 +298,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) }) @@ -159,14 +318,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(() => { @@ -176,7 +351,7 @@ window.PageEvents = { }) }, exportticketsCSV() { - LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) + LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets) }, getEvents() { LNbits.api @@ -195,19 +370,18 @@ window.PageEvents = { id: this.formDialog.data.wallet }) const data = this.formDialog.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() - })) + data.extra.promo_codes = this.normalizePromoCodes( + data.extra.promo_codes + ) } if (!this.isFiatCurrency(data.currency)) { if (!data.allow_fiat) { data.fiat_currency = 'GBP' } } + this.syncPrimaryWaveFromForm(data) if (data.id) { this.updateEvent(wallet, data) @@ -218,7 +392,7 @@ window.PageEvents = { openEventDialog(data = false) { if (data && data.id) { - this.formDialog.data = {...data} + this.formDialog.data = this.hydrateEventForm(data) } else { this.formDialog.data = { currency: 'sats', @@ -229,6 +403,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: '' @@ -244,8 +433,25 @@ window.PageEvents = { allow_fiat: false, fiat_currency: 'GBP', extra: { + 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: '' @@ -266,6 +472,177 @@ 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 + }) + }, + 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( @@ -332,6 +709,7 @@ window.PageEvents = { async created() { if (this.g.user.wallets.length) { this.getTickets() + this.getAllTickets() this.getEvents() if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) { this.currencies = ['sats', ...this.g.allowedCurrencies] diff --git a/static/js/index.vue b/static/js/index.vue index fa39a4e..b90e922 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -100,45 +100,102 @@
-
Promo codes
+
+
Ticket waves
+ +
+
+
+
+ + + +
+
+
+ +
+
Promo codes
+ +
- No promo codes for this event. + No active promo codes for this event.
-
-
+
+
- - +
-
- Discount: - % -
-
- Status: - -
@@ -165,9 +222,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/views_api.py b/views_api.py index abdc1ef..49b8ec9 100644 --- a/views_api.py +++ b/views_api.py @@ -1,8 +1,11 @@ import asyncio from datetime import datetime, timezone from http import HTTPStatus +from io import BytesIO +from pathlib import Path from typing import Any +import pyqrcode # type: ignore[import-untyped] from fastapi import ( APIRouter, Depends, @@ -12,22 +15,27 @@ from fastapi import ( WebSocket, WebSocketDisconnect, ) +from fastapi.responses import StreamingResponse from lnbits.core.crud import get_user +from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import 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 ( + parse_filters, require_admin_key, require_invoice_key, ) +from lnbits.helpers import generate_filter_params_openapi from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, get_fiat_rate_satoshis, satoshis_amount_as_fiat, ) -from lnbits.utils.nostr import normalize_public_key +from PIL import Image, ImageDraw from .crud import ( create_event, @@ -39,6 +47,7 @@ from .crud import ( get_events, get_ticket, get_tickets, + get_tickets_paginated, purge_unpaid_tickets, update_event, update_ticket, @@ -50,19 +59,53 @@ from .models import ( PublicEvent, PublicTicket, Ticket, + TicketFilters, TicketPaymentRequest, + TicketResendResult, + ensure_ticket_waves, + get_active_ticket_waves, ) from .services import refund_tickets, resend_ticket_email_notification from .tasks import deregister_payment_listener, register_payment_listener events_api_router = APIRouter(prefix="/api/v1/events") tickets_api_router = APIRouter(prefix="/api/v1/tickets") +qr_api_router = APIRouter(prefix="/api/v1") +tickets_filters = parse_filters(TicketFilters) def _is_fiat_currency(currency: str | None) -> bool: return str(currency or "").lower() not in {"sat", "sats"} +def make_qr_png(data: str, size: int = 235, border: int = 4) -> Image.Image: + qr = pyqrcode.create(data) + matrix = qr.code + modules = len(matrix) + + total_modules = modules + border * 2 + box_size = max(1, size // total_modules) + img_size = total_modules * box_size + + img = Image.new("RGBA", (img_size, img_size), "white") + draw = ImageDraw.Draw(img) + + for y, row in enumerate(matrix): + for x, cell in enumerate(row): + if cell: + x0 = (x + border) * box_size + y0 = (y + border) * box_size + draw.rectangle( + [x0, y0, x0 + box_size - 1, y0 + box_size - 1], + fill="black", + ) + + if img_size != size: + img = img.resize((size, size), Image.Resampling.NEAREST) + + return img + + @events_api_router.get("") async def api_events( all_wallets: bool = Query(False), @@ -86,24 +129,29 @@ async def api_get_event(event_id: str) -> Event: ) await purge_unpaid_tickets(event_id) - is_window_open = datetime.now(timezone.utc) < datetime.strptime( - event.closing_date, "%Y-%m-%d" - ).replace(tzinfo=timezone.utc) + today = datetime.now(timezone.utc).date() + active_waves = get_active_ticket_waves(event, today) + is_sales_closed = today > datetime.strptime(event.closing_date, "%Y-%m-%d").date() 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 @@ -127,8 +175,17 @@ async def api_event_create( raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Not your event." ) - for k, v in data.dict().items(): - setattr(event, k, v) + event = Event( + **{ + **event.dict(), + **data.dict(), + "id": event.id, + "wallet": event.wallet, + "time": event.time, + "sold": event.sold, + "canceled": event.canceled, + } + ) event = await update_event(event) else: event = await create_event(data) @@ -187,6 +244,31 @@ async def api_tickets( return await get_tickets(wallet_ids) +@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) @@ -202,6 +284,71 @@ async def api_get_ticket(ticket_id: str) -> Ticket: return ticket +@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse) +async def api_ticket_qr(ticket_id: str): + 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." + ) + + waves = ensure_ticket_waves(event) + wave = next( + (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), + waves[0], + ) + + qr_img = make_qr_png(f"ticket://{ticket_id}", size=157) + output = BytesIO() + + if not wave.use_ticket_image: + qr_img.save(output, format="PNG") + output.seek(0) + return StreamingResponse( + output, + media_type="image/png", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + 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(qr_img, (122, 505)) + ticket_image.save(output, format="PNG") + output.seek(0) + + return StreamingResponse( + output, + media_type="image/png", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + @tickets_api_router.post("/{event_id}") async def api_ticket_create( event_id: str, data: CreateTicket, request: Request @@ -215,7 +362,7 @@ async def api_ticket_create( if event.canceled: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") - if event.amount_tickets > 0 and event.sold >= event.amount_tickets: + if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") name = data.name @@ -230,14 +377,36 @@ async def api_ticket_create( detail="Unsupported payment method.", ) if nostr_identifier and "@" not in nostr_identifier: - try: - nostr_identifier = normalize_public_key(nostr_identifier) - except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Only NIP-05 Nostr identifiers are supported.", + ) + active_waves = get_active_ticket_waves(event) + if not active_waves: + raise HTTPException( + status_code=HTTPStatus.GONE, detail="No ticket wave is currently open." + ) + + selected_wave = None + if data.ticket_wave_id: + selected_wave = next( + (wave for wave in active_waves if wave.id == data.ticket_wave_id), + None, + ) + if not selected_wave: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail="Invalid Nostr identifier.", - ) from exc - price = event.price_per_ticket + detail="Invalid ticket wave selected.", + ) + elif len(active_waves) == 1: + selected_wave = active_waves[0] + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Please select a ticket wave.", + ) + + price = selected_wave.price_per_ticket extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} if promo_code: @@ -249,31 +418,31 @@ async def api_ticket_create( # get the promocode promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) extra["promo_code"] = promo.code - price = event.price_per_ticket * (1 - promo.discount_percent / 100) + price = selected_wave.price_per_ticket * (1 - promo.discount_percent / 100) - if payment_method == "fiat" and not event.allow_fiat: + 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 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 @@ -314,6 +483,8 @@ async def api_ticket_create( 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": str(request.base_url).rstrip("/"), @@ -388,10 +559,12 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -@tickets_api_router.post("/{ticket_id}/resend-email") +@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: + ticket_id: str, + request: Request, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> TicketResendResult: ticket = await get_ticket(ticket_id) if not ticket: raise HTTPException( @@ -408,16 +581,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}") From 8c1538f5fcdb058542095c338fdd10a4c9abdc63 Mon Sep 17 00:00:00 2001 From: Arc Date: Fri, 22 May 2026 20:30:24 +0100 Subject: [PATCH 2/9] fix: no submit bug --- static/js/display.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/static/js/display.js b/static/js/display.js index 6b53d9d..498cd2c 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -126,6 +126,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 From 35c20eda7283310385cef8022283c6f9975e0de2 Mon Sep 17 00:00:00 2001 From: Arc Date: Fri, 22 May 2026 20:36:30 +0100 Subject: [PATCH 3/9] fix: another hot one --- static/js/display.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/display.vue b/static/js/display.vue index 9051fbf..382f25a 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -110,7 +110,7 @@ formDialog.data.name == '' || formDialog.data.email == '' || (showTicketWaveSelector && !formDialog.data.ticket_wave_id) || - Boolean(paymentReq) + (receive.show && Boolean(paymentReq)) " type="submit" >Submit Date: Wed, 3 Jun 2026 10:51:15 +0200 Subject: [PATCH 4/9] feat: onchain payments (#53) * feat: onchain payments --- models.py | 6 ++ services.py | 43 ++++++++++ static/js/display.js | 59 +++++++++++-- static/js/display.vue | 63 +++++++++++--- static/js/index.js | 49 ++++++++++- static/js/index.vue | 48 +++++++++++ views_api.py | 188 ++++++++++++++++++++++++++++++++++++++---- 7 files changed, 423 insertions(+), 33 deletions(-) diff --git a/models.py b/models.py index 331ad6d..a16f7dd 100644 --- a/models.py +++ b/models.py @@ -45,6 +45,8 @@ class EventExtra(BaseModel): nostr_notifications: bool = False notification_subject: str = "" notification_body: str = "" + onchain_enabled: bool = False + onchain_wallet_id: str | None = None class CreateEvent(BaseModel): @@ -110,6 +112,7 @@ class TicketExtra(BaseModel): email_notification_sent: bool = False nostr_notification_sent: bool = False refunded: bool = False + onchain: bool = False class CreateTicket(BaseModel): @@ -167,6 +170,9 @@ class TicketPaymentRequest(BaseModel): fiat_payment_request: str | None = None fiat_provider: str | None = None is_fiat: bool = False + onchain_address: str | None = None + onchain_mempool_endpoint: str | None = None + onchain_amount_sat: int | None = None class TicketFilters(FilterModel): diff --git a/services.py b/services.py index 057026b..84c16e0 100644 --- a/services.py +++ b/services.py @@ -5,7 +5,9 @@ from asyncio.tasks import create_task from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from html import escape +from typing import Any +import httpx from lnbits.core.models.users import UserNotifications from lnbits.core.services.notifications import send_user_notification from lnbits.helpers import is_valid_email_address @@ -29,6 +31,47 @@ from .models import ( ) +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 fetch_onchain_address(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/address/{wallet_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket diff --git a/static/js/display.js b/static/js/display.js index 498cd2c..a5cc160 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -27,7 +27,11 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false + isFiat: false, + isOnchain: false, + onchainAddress: null, + onchainAmountSat: 0, + mempoolEndpoint: null }, paymentDismissMsg: null, paymentWebsocket: null @@ -79,6 +83,32 @@ window.PageEventsDisplay = { }, allowNostrNotifications() { return Boolean(this.event?.extra?.nostr_notifications) + }, + allowOnchain() { + return Boolean(this.event?.extra?.onchain_enabled) + }, + showPaymentMethodSelector() { + return this.allowFiatCheckout || this.allowOnchain + }, + paymentMethodOptions() { + const options = [{label: 'Lightning', value: 'lightning'}] + if (this.allowFiatCheckout) { + options.push({label: this.fiatCheckoutLabel, value: 'fiat'}) + } + if (this.allowOnchain) { + options.push({label: 'Bitcoin', value: 'onchain'}) + } + return options + }, + onchainPaymentUri() { + if (!this.receive.onchainAddress) return '' + const btc = (this.receive.onchainAmountSat / 100000000).toFixed(8) + return `bitcoin:${this.receive.onchainAddress}?amount=${btc}` + }, + mempoolAddressUrl() { + if (!this.receive.onchainAddress || !this.receive.mempoolEndpoint) + return null + return `${this.receive.mempoolEndpoint}/address/${this.receive.onchainAddress}` } }, methods: { @@ -131,7 +161,11 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false + isFiat: false, + isOnchain: false, + onchainAddress: null, + onchainAmountSat: 0, + mempoolEndpoint: null } }, nameValidation(val) { @@ -169,7 +203,11 @@ window.PageEventsDisplay = { show: false, status: 'complete', paymentReq: null, - isFiat: false + isFiat: false, + isOnchain: false, + onchainAddress: null, + onchainAmountSat: 0, + mempoolEndpoint: null } this.ticketLink = { show: true, @@ -192,15 +230,18 @@ window.PageEventsDisplay = { 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.allowFiatCheckout + payment_method: this.showPaymentMethodSelector ? this.formDialog.data.payment_method : 'lightning' } ) - const isFiat = Boolean(data.is_fiat) + const isOnchain = Boolean(data.onchain_address) + const isFiat = !isOnchain && Boolean(data.is_fiat) this.paymentReq = isFiat ? data.fiat_payment_request || null - : data.payment_request + : isOnchain + ? null + : data.payment_request this.paymentHash = data.payment_hash this.paymentDismissMsg = Quasar.Notify.create({ @@ -211,7 +252,11 @@ window.PageEventsDisplay = { show: true, status: 'pending', paymentReq: this.paymentReq, - isFiat + isFiat, + isOnchain, + onchainAddress: data.onchain_address || null, + onchainAmountSat: data.onchain_amount_sat || 0, + mempoolEndpoint: data.onchain_mempool_endpoint || null } if (isFiat && this.paymentReq) { window.open(this.paymentReq, '_blank', 'noopener') diff --git a/static/js/display.vue b/static/js/display.vue index 382f25a..d1ab493 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -80,20 +80,18 @@ " >
-
+
-
+
@@ -175,6 +173,51 @@ Close
+ +
+
Pay with Bitcoin
+ +
+ {{ + (receive.onchainAmountSat / 100000000).toFixed(8) + }} + BTC +
+
+ {{ receive.onchainAddress }} +
+
+
+ Copy address + View on mempool + Close +
+
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) { @@ -403,6 +440,8 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, + onchain_enabled: false, + onchain_wallet_id: null, ticket_waves: [ { id: 'primary', @@ -425,6 +464,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 @@ -437,6 +482,8 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, + onchain_enabled: false, + onchain_wallet_id: null, ticket_waves: [ { id: 'primary', diff --git a/static/js/index.vue b/static/js/index.vue index b90e922..0d8ba76 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -265,6 +265,18 @@ > Resend ticket email + + Confirm onchain payment + @@ -602,6 +614,42 @@
+ +
+
+ Accept Bitcoin onchain payments. Requires the Watchonly + extension. +
+ + +
+
+
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"), + } + + +async def _validate_watchonly_settings( + *, + wallet, + onchain_enabled: bool, + onchain_wallet_id: str | None, +) -> dict[str, Any]: + if not onchain_enabled: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Onchain payments are not enabled for this event.", + ) + if not onchain_wallet_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No watchonly wallet configured for onchain payments.", + ) + status = await _get_watchonly_status(wallet) + if not status["available"]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=status["message"] or "Watchonly extension is not available.", + ) + try: + watch_wallet = await fetch_watchonly_wallet(wallet.inkey, onchain_wallet_id) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Cannot access watchonly wallet: {exc!s}", + ) from exc + if watch_wallet.get("network") != status["network"]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Watchonly wallet network does not match user watchonly config.", + ) + return { + "watch_wallet": watch_wallet, + "network": status["network"], + "mempool_endpoint": status["mempool_endpoint"], + } + + def _is_fiat_currency(currency: str | None) -> bool: return str(currency or "").lower() not in {"sat", "sats"} @@ -120,6 +198,13 @@ async def api_events( return await get_events(wallet_ids) +@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: event = await get_event(event_id) @@ -371,7 +456,7 @@ 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.", @@ -438,6 +523,10 @@ async def api_ticket_create( invoice_unit = selected_wave.currency fiat_amount = price fiat_provider = None + onchain_address = None + onchain_mempool_endpoint = None + onchain_amount_sat = None + if payment_method == "fiat": if _is_fiat_currency(selected_wave.currency): invoice_unit = selected_wave.currency @@ -461,20 +550,53 @@ async def api_ticket_create( status_code=HTTPStatus.BAD_REQUEST, detail="No fiat payment provider configured for this event.", ) + elif payment_method == "onchain": + invoice_unit = "sat" + 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.", + ) + validation = await _validate_watchonly_settings( + wallet=wallet_record, + onchain_enabled=event.extra.onchain_enabled, + onchain_wallet_id=event.extra.onchain_wallet_id, + ) + address_data = await fetch_onchain_address( + wallet_record.inkey, event.extra.onchain_wallet_id or "" + ) + onchain_address = address_data.get("address") + onchain_mempool_endpoint = validation.get("mempool_endpoint") else: invoice_unit = "sat" - payment = await create_payment_request( - wallet_id=event.wallet, - invoice_data=CreateInvoice( - out=False, - amount=fiat_amount if payment_method == "fiat" else price, - unit=invoice_unit, - fiat_provider=fiat_provider, - memo=f"{event_id}", - extra=extra, - ), - ) + if payment_method == "onchain": + payment = await create_payment_request( + wallet_id=event.wallet, + invoice_data=CreateInvoice( + out=False, + amount=float(onchain_amount_sat or 0), + unit="sat", + internal=True, + labels=["onchain"], + memo=f"{event_id}", + extra=extra, + ), + ) + else: + payment = await create_payment_request( + wallet_id=event.wallet, + invoice_data=CreateInvoice( + out=False, + amount=fiat_amount if payment_method == "fiat" else price, + unit=invoice_unit, + fiat_provider=fiat_provider, + memo=f"{event_id}", + extra=extra, + ), + ) await create_ticket( payment_hash=payment.payment_hash, wallet=event.wallet, @@ -488,7 +610,10 @@ async def api_ticket_create( "refund_address": refund_address, "nostr_identifier": nostr_identifier, "ticket_base_url": str(request.base_url).rstrip("/"), - "sats_paid": payment.sat, + "sats_paid": ( + onchain_amount_sat if payment_method == "onchain" else payment.sat + ), + "onchain": payment_method == "onchain", }, ) @@ -498,6 +623,9 @@ async def api_ticket_create( fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"), fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider, is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider), + onchain_address=onchain_address, + onchain_mempool_endpoint=onchain_mempool_endpoint, + onchain_amount_sat=onchain_amount_sat, ) @@ -559,6 +687,36 @@ async def api_ticket_delete( await delete_ticket(ticket_id) +@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.", + ) + payment = await get_standalone_payment(payment_hash) + if not payment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." + ) + await internal_invoice_queue_put(payment_hash) + return ticket + + @tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) async def api_ticket_resend_email( ticket_id: str, From da16b209cd6c5f36b42adbbbc77b8735dbb2c59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Thu, 4 Jun 2026 11:08:59 +0200 Subject: [PATCH 5/9] fix: onchain listeners (#54) * fix: onchain listeners --- views_api.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/views_api.py b/views_api.py index 118b01b..782fd5b 100644 --- a/views_api.py +++ b/views_api.py @@ -16,7 +16,7 @@ from fastapi import ( WebSocketDisconnect, ) from fastapi.responses import StreamingResponse -from lnbits.core.crud import get_standalone_payment, get_user +from lnbits.core.crud import get_user from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import WalletTypeInfo @@ -30,7 +30,6 @@ from lnbits.decorators import ( ) from lnbits.helpers import generate_filter_params_openapi from lnbits.settings import settings -from lnbits.tasks import internal_invoice_queue_put from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, get_fiat_rate_satoshis, @@ -73,8 +72,14 @@ from .services import ( fetch_watchonly_wallets, refund_tickets, resend_ticket_email_notification, + send_ticket_notification_in_background, + set_ticket_paid, +) +from .tasks import ( + deregister_payment_listener, + payment_listeners, + 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") @@ -708,12 +713,10 @@ async def api_ticket_onchain_confirm( status_code=HTTPStatus.BAD_REQUEST, detail="Ticket is not an onchain payment.", ) - payment = await get_standalone_payment(payment_hash) - if not payment: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." - ) - await internal_invoice_queue_put(payment_hash) + 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 From 99b43ff8519bce374c8b7f1cab75cc332750af05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Fri, 5 Jun 2026 12:12:08 +0200 Subject: [PATCH 6/9] fix: use satspay for onchain payments (#56) * use satspay * safeguard satspay * add fasttrack and 0conf --- models.py | 7 +- services.py | 15 +++- static/js/display.js | 54 ++++------- static/js/display.vue | 47 +--------- static/js/index.js | 2 + static/js/index.vue | 19 +++- views_api.py | 203 ++++++++++++++++++++++++------------------ 7 files changed, 170 insertions(+), 177 deletions(-) diff --git a/models.py b/models.py index a16f7dd..ac0d6b5 100644 --- a/models.py +++ b/models.py @@ -47,6 +47,8 @@ class EventExtra(BaseModel): notification_body: str = "" onchain_enabled: bool = False onchain_wallet_id: str | None = None + onchain_zeroconf: bool = False + onchain_fasttrack: bool = False class CreateEvent(BaseModel): @@ -113,6 +115,8 @@ class TicketExtra(BaseModel): 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): @@ -170,9 +174,8 @@ class TicketPaymentRequest(BaseModel): fiat_payment_request: str | None = None fiat_provider: str | None = None is_fiat: bool = False - onchain_address: str | None = None - onchain_mempool_endpoint: str | None = None onchain_amount_sat: int | None = None + satspay_charge_url: str | None = None class TicketFilters(FilterModel): diff --git a/services.py b/services.py index 84c16e0..fbfac2a 100644 --- a/services.py +++ b/services.py @@ -62,16 +62,27 @@ async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any] return resp.json() -async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: +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}/watchonly/api/v1/address/{wallet_id}", + 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() + + async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket diff --git a/static/js/display.js b/static/js/display.js index a5cc160..3aa4d18 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -27,11 +27,7 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false, - isOnchain: false, - onchainAddress: null, - onchainAmountSat: 0, - mempoolEndpoint: null + isFiat: false }, paymentDismissMsg: null, paymentWebsocket: null @@ -99,16 +95,6 @@ window.PageEventsDisplay = { options.push({label: 'Bitcoin', value: 'onchain'}) } return options - }, - onchainPaymentUri() { - if (!this.receive.onchainAddress) return '' - const btc = (this.receive.onchainAmountSat / 100000000).toFixed(8) - return `bitcoin:${this.receive.onchainAddress}?amount=${btc}` - }, - mempoolAddressUrl() { - if (!this.receive.onchainAddress || !this.receive.mempoolEndpoint) - return null - return `${this.receive.mempoolEndpoint}/address/${this.receive.onchainAddress}` } }, methods: { @@ -161,11 +147,7 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false, - isOnchain: false, - onchainAddress: null, - onchainAmountSat: 0, - mempoolEndpoint: null + isFiat: false } }, nameValidation(val) { @@ -203,11 +185,7 @@ window.PageEventsDisplay = { show: false, status: 'complete', paymentReq: null, - isFiat: false, - isOnchain: false, - onchainAddress: null, - onchainAmountSat: 0, - mempoolEndpoint: null + isFiat: false } this.ticketLink = { show: true, @@ -235,13 +213,15 @@ window.PageEventsDisplay = { : 'lightning' } ) - const isOnchain = Boolean(data.onchain_address) - const isFiat = !isOnchain && Boolean(data.is_fiat) + 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 - : isOnchain - ? null - : data.payment_request + : data.payment_request this.paymentHash = data.payment_hash this.paymentDismissMsg = Quasar.Notify.create({ @@ -252,11 +232,7 @@ window.PageEventsDisplay = { show: true, status: 'pending', paymentReq: this.paymentReq, - isFiat, - isOnchain, - onchainAddress: data.onchain_address || null, - onchainAmountSat: data.onchain_amount_sat || 0, - mempoolEndpoint: data.onchain_mempool_endpoint || null + isFiat } if (isFiat && this.paymentReq) { window.open(this.paymentReq, '_blank', 'noopener') @@ -291,8 +267,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 d1ab493..074bcc9 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -138,7 +138,7 @@ @@ -173,51 +173,6 @@ Close
- -
-
Pay with Bitcoin
- -
- {{ - (receive.onchainAmountSat / 100000000).toFixed(8) - }} - BTC -
-
- {{ receive.onchainAddress }} -
-
-
- Copy address - View on mempool - Close -
-
- Accept Bitcoin onchain payments. Requires the Watchonly - extension. + Accept Bitcoin onchain payments. Requires the + SatsPay and Watchonly + extensions to be installed and enabled.
+
+ + +
diff --git a/views_api.py b/views_api.py index 782fd5b..b6155e5 100644 --- a/views_api.py +++ b/views_api.py @@ -28,13 +28,14 @@ from lnbits.decorators import ( require_admin_key, require_invoice_key, ) -from lnbits.helpers import generate_filter_params_openapi +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, get_fiat_rate_satoshis, satoshis_amount_as_fiat, ) +from loguru import logger from PIL import Image, ImageDraw from .crud import ( @@ -66,10 +67,10 @@ from .models import ( get_active_ticket_waves, ) from .services import ( - fetch_onchain_address, + create_satspay_charge, fetch_watchonly_config, - fetch_watchonly_wallet, fetch_watchonly_wallets, + get_satspay_charge, refund_tickets, resend_ticket_email_notification, send_ticket_notification_in_background, @@ -116,47 +117,6 @@ async def _get_watchonly_status(wallet) -> dict[str, Any]: } -async def _validate_watchonly_settings( - *, - wallet, - onchain_enabled: bool, - onchain_wallet_id: str | None, -) -> dict[str, Any]: - if not onchain_enabled: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Onchain payments are not enabled for this event.", - ) - if not onchain_wallet_id: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="No watchonly wallet configured for onchain payments.", - ) - status = await _get_watchonly_status(wallet) - if not status["available"]: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=status["message"] or "Watchonly extension is not available.", - ) - try: - watch_wallet = await fetch_watchonly_wallet(wallet.inkey, onchain_wallet_id) - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Cannot access watchonly wallet: {exc!s}", - ) from exc - if watch_wallet.get("network") != status["network"]: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Watchonly wallet network does not match user watchonly config.", - ) - return { - "watch_wallet": watch_wallet, - "network": status["network"], - "mempool_endpoint": status["mempool_endpoint"], - } - - def _is_fiat_currency(currency: str | None) -> bool: return str(currency or "").lower() not in {"sat", "sats"} @@ -528,8 +488,6 @@ async def api_ticket_create( invoice_unit = selected_wave.currency fiat_amount = price fiat_provider = None - onchain_address = None - onchain_mempool_endpoint = None onchain_amount_sat = None if payment_method == "fiat": @@ -556,7 +514,6 @@ async def api_ticket_create( detail="No fiat payment provider configured for this event.", ) elif payment_method == "onchain": - invoice_unit = "sat" onchain_amount_sat = int(price) wallet_record = await get_wallet(event.wallet) if not wallet_record: @@ -564,44 +521,82 @@ async def api_ticket_create( status_code=HTTPStatus.NOT_FOUND, detail="Event wallet does not exist.", ) - validation = await _validate_watchonly_settings( - wallet=wallet_record, - onchain_enabled=event.extra.onchain_enabled, - onchain_wallet_id=event.extra.onchain_wallet_id, + 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("/") + webhook_url = f"{base_url}/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"], + }, ) - address_data = await fetch_onchain_address( - wallet_record.inkey, event.extra.onchain_wallet_id or "" + + return TicketPaymentRequest( + payment_hash=ticket_id, + onchain_amount_sat=onchain_amount_sat, + satspay_charge_url=f"/satspay/{charge['id']}", ) - onchain_address = address_data.get("address") - onchain_mempool_endpoint = validation.get("mempool_endpoint") else: invoice_unit = "sat" - if payment_method == "onchain": - payment = await create_payment_request( - wallet_id=event.wallet, - invoice_data=CreateInvoice( - out=False, - amount=float(onchain_amount_sat or 0), - unit="sat", - internal=True, - labels=["onchain"], - memo=f"{event_id}", - extra=extra, - ), - ) - else: - payment = await create_payment_request( - wallet_id=event.wallet, - invoice_data=CreateInvoice( - out=False, - amount=fiat_amount if payment_method == "fiat" else price, - unit=invoice_unit, - fiat_provider=fiat_provider, - memo=f"{event_id}", - extra=extra, - ), - ) + payment = await create_payment_request( + wallet_id=event.wallet, + invoice_data=CreateInvoice( + out=False, + amount=fiat_amount if payment_method == "fiat" else price, + unit=invoice_unit, + fiat_provider=fiat_provider, + memo=f"{event_id}", + extra=extra, + ), + ) await create_ticket( payment_hash=payment.payment_hash, wallet=event.wallet, @@ -615,10 +610,8 @@ async def api_ticket_create( "refund_address": refund_address, "nostr_identifier": nostr_identifier, "ticket_base_url": str(request.base_url).rstrip("/"), - "sats_paid": ( - onchain_amount_sat if payment_method == "onchain" else payment.sat - ), - "onchain": payment_method == "onchain", + "sats_paid": payment.sat, + "onchain": False, }, ) @@ -628,9 +621,6 @@ async def api_ticket_create( fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"), fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider, is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider), - onchain_address=onchain_address, - onchain_mempool_endpoint=onchain_mempool_endpoint, - onchain_amount_sat=onchain_amount_sat, ) @@ -692,6 +682,45 @@ 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, request: Request) -> 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, From f745370c33a1655fb5277a483261d87a8b529255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 16 Jun 2026 10:04:03 +0200 Subject: [PATCH 7/9] fix: issue when calling internal webhook from extern (#57) - query from localhost and port, important if behind a proxy without loopback --- views_api.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/views_api.py b/views_api.py index b6155e5..c55ffa0 100644 --- a/views_api.py +++ b/views_api.py @@ -534,9 +534,13 @@ async def api_ticket_create( ticket_id = urlsafe_short_hash() base_url = str(request.base_url).rstrip("/") - webhook_url = f"{base_url}/events/api/v1/tickets/{ticket_id}/satspay-webhook" + # 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, @@ -683,7 +687,7 @@ async def api_ticket_delete( @tickets_api_router.post("/{ticket_id}/satspay-webhook") -async def api_ticket_satspay_webhook(ticket_id: str, request: Request) -> None: +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.") From d90a0f8322d84666b321ee6cc275b75003357d87 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 28 Sep 2026 22:21:55 +0200 Subject: [PATCH 8/9] refactor: drop the SatsPay/watchonly on-chain surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.6.8 sells tickets on-chain through SatsPay charges — a per-purchase watchonly address, a hosted charge page, and a webhook back into the extension. We are not adopting that: on-chain goes through the fork's native LndRestWallet support once core can mint purpose-bound receiving addresses (aiolabs/lnbits#53). This is the parity note on #41, applied as its own commit so the merge it follows stays a faithful diff of what upstream shipped. Removed: - services: the five SatsPay/watchonly HTTP clients (and with them the only uses of httpx and typing.Any in that module) - views_api: _get_watchonly_status, GET /events/onchain/status, the SatsPay charge branch of api_ticket_create, POST /tickets/{id}/satspay-webhook, PUT /tickets/{hash}/onchain-confirm - models: EventExtra.onchain_{enabled,wallet_id,zeroconf,fasttrack}, TicketExtra.satspay_charge_id, TicketPaymentRequest.satspay_charge_url - frontend: the organiser's "Onchain payments" panel and its watchonly wallet picker, the per-ticket confirm-onchain button, the SatsPay charge-page redirect, and the wallet-status fetch behind them `onchain` is no longer an accepted payment_method — there is no rail to fulfil it until #41 lands, so accepting it could only fail later. Kept as vocabulary for #41, in upstream's field names so it needs no migration: TicketExtra.onchain / onchain_address and TicketPaymentRequest.onchain_amount_sat. 35 routes register with no duplicates; ruff, black, prettier clean; 97 tests pass; mypy error set still identical to HEAD's baseline. --- models.py | 13 +-- services.py | 55 ------------- static/js/display.js | 21 ++--- static/js/index.js | 51 +----------- static/js/index.vue | 61 -------------- views_api.py | 186 +------------------------------------------ 6 files changed, 10 insertions(+), 377 deletions(-) diff --git a/models.py b/models.py index ed75db8..bf7fd32 100644 --- a/models.py +++ b/models.py @@ -98,14 +98,6 @@ class EventExtra(EventExtraBase): # `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 @@ -256,9 +248,11 @@ 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 - satspay_charge_id: str | None = None class CreateTicket(BaseModel): @@ -368,7 +362,6 @@ class TicketPaymentRequest(BaseModel): # 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): diff --git a/services.py b/services.py index 026121b..84d5798 100644 --- a/services.py +++ b/services.py @@ -9,9 +9,7 @@ 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 @@ -45,59 +43,6 @@ 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", diff --git a/static/js/display.js b/static/js/display.js index 03c5f9f..022f051 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -83,19 +83,13 @@ window.PageEventsDisplay = { }, 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. + // 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, - onchain: 'Bitcoin' + fiat: this.fiatCheckoutLabel } return this.paymentMethods.map(method => ({ value: method, @@ -243,11 +237,6 @@ window.PageEventsDisplay = { : 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 diff --git a/static/js/index.js b/static/js/index.js index 3b4bb78..f702a4e 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -272,8 +272,7 @@ window.PageEvents = { promo_codes: [] } } - }, - onchainWallets: [] + } } }, computed: { @@ -302,42 +301,6 @@ window.PageEvents = { 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) { @@ -841,10 +804,6 @@ 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', @@ -867,12 +826,6 @@ window.PageEvents = { } } this.formDialog.show = true - if ( - this.formDialog.data.wallet && - this.formDialog.data.extra?.onchain_enabled - ) { - this.loadOnchainWallets() - } }, resetEventDialog() { this.formDialog.show = false @@ -888,8 +841,6 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, - onchain_enabled: false, - onchain_wallet_id: null, ticket_waves: [ { id: 'primary', diff --git a/static/js/index.vue b/static/js/index.vue index 86e082a..a52d056 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -408,18 +408,6 @@ > Resend ticket email - - Confirm onchain payment - @@ -909,55 +897,6 @@
- -
-
- Accept Bitcoin onchain payments. Requires the - SatsPay and Watchonly - extensions to be installed and enabled. -
- - -
- - -
-
-
-
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: return str(currency or "").lower() not in {"sat", "sats"} @@ -294,13 +259,6 @@ 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. @@ -821,7 +779,7 @@ 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", "onchain"}: + if payment_method not in {"lightning", "fiat"}: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Unsupported payment method.", @@ -931,7 +889,6 @@ async def api_ticket_create( invoice_unit = selected_wave.currency fiat_amount = price fiat_provider = None - onchain_amount_sat = None if payment_method == "fiat": if _is_fiat_currency(selected_wave.currency): @@ -956,80 +913,6 @@ 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" @@ -1194,73 +1077,6 @@ 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, From 15c2276e573226e57e2fb191bb2baf6981cce0eb Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 28 Sep 2026 22:28:09 +0200 Subject: [PATCH 9/9] feat(nostr): publish the active ticket wave, not the roll-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since upstream v1.6.8 price, currency and inventory belong to time-boxed ticket waves, and the event-level fields `sync_event_ticket_waves` derives are the PRIMARY wave's price/currency and the SUM of every wave's stock. The NIP-52 publisher read those, so as soon as an organiser created a second wave the public card would advertise the early-bird price after early bird closed and count stock in waves that had not opened. Refs #61. `build_nip52_event` now describes the wave a buyer can actually buy from: - several waves can be open at once, and a publisher has no one to ask which one the buyer wants (the purchase endpoint errors with "Please select a ticket wave"), so it advertises the CHEAPEST open wave — the price a buyer is able to obtain. Deviation recorded in docs/upstream-candidates.md. - with no open wave, `tickets_available` is 0 and never omitted: omission used to mean "unlimited", which #34/#62 removed as a concept. - `tickets_payment_methods` is scoped to the advertised wave too. It was derived from `event.allow_fiat` — the primary wave's — so it could offer a fiat rail while `tickets_allow_fiat` was absent and the purchase endpoint would refuse it. They are the same fact and now come from the same place. Wave boundaries are time-driven, and every republish we have is sale-driven, so nothing fires when early bird ends at midnight. Rather than add a scheduler, a publish records which wave it advertised (`nostr_published_wave_id`, m004) and the reconciliation sweep compares that against the wave that would be advertised now, setting `nostr_publish_pending` on a mismatch — reusing the existing retry path. NULL means "never published", which the sweep leaves alone so an upgrade does not republish the whole table on first boot. The selection rule lives in `models.advertised_ticket_wave` so the publisher and the drift detector cannot disagree about what is on the relay. 17 new tests; 114 pass. ruff, black, prettier clean; mypy error set still identical to HEAD's baseline. --- __init__.py | 12 ++- crud.py | 43 ++++++++ docs/rebase-playbook.md | 16 +-- docs/upstream-candidates.md | 29 ++--- migrations_fork.py | 29 +++++ models.py | 45 +++++++- nostr_hooks.py | 7 +- nostr_publisher.py | 64 ++++++++--- tests/test_publish_active_wave.py | 160 ++++++++++++++++++++++++++++ tests/test_wave_transition_sweep.py | 159 +++++++++++++++++++++++++++ 10 files changed, 525 insertions(+), 39 deletions(-) create mode 100644 tests/test_publish_active_wave.py create mode 100644 tests/test_wave_transition_sweep.py diff --git a/__init__.py b/__init__.py index 6323038..e443ce0 100644 --- a/__init__.py +++ b/__init__.py @@ -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) diff --git a/crud.py b/crud.py index 70a0f5d..52ce233 100644 --- a/crud.py +++ b/crud.py @@ -12,6 +12,7 @@ from .models import ( Ticket, TicketExtra, TicketFilters, + advertised_wave_key, sync_event_ticket_waves, ) @@ -298,6 +299,48 @@ async def get_pending_events() -> list[Event]: return [cast(Event, sync_event_ticket_waves(e)) for e in events] +async def flag_wave_transitions() -> int: + """Flag events whose advertised ticket wave has moved on. + + Every republish this extension performs is *sale*-driven. A wave + boundary is a *date* boundary, so when early bird closes at midnight + nothing fires and the relay keeps serving the closed wave's price until + the next ticket happens to sell (aiolabs/events#61). + + Rather than add a scheduler and a second publish path, this compares the + wave a row would advertise now against the one its last successful + publish did (`nostr_published_wave_id`) and sets `nostr_publish_pending` + on a mismatch — handing the work to the existing reconciliation sweep, + which already retries, survives restarts and logs. + + Rows with NULL `nostr_published_wave_id` are skipped: that means "never + published, or published before the column existed", which is no evidence + of drift. Flagging them would republish the whole table on first boot + after the upgrade. + + Returns the number of rows newly flagged. + """ + events = await db.fetchall( + """ + SELECT * FROM events.events + WHERE nostr_published_wave_id IS NOT NULL + AND nostr_publish_pending = FALSE + AND canceled = FALSE + AND status = 'approved' + """, + model=Event, + ) + flagged = 0 + for event in events: + event = cast(Event, sync_event_ticket_waves(event)) + if advertised_wave_key(event) == event.nostr_published_wave_id: + continue + event.nostr_publish_pending = True + await update_event(event) + flagged += 1 + return flagged + + async def get_events_pending_republish() -> list[Event]: """Events whose relay copy may be behind this row. diff --git a/docs/rebase-playbook.md b/docs/rebase-playbook.md index adbf6f3..affbce7 100644 --- a/docs/rebase-playbook.md +++ b/docs/rebase-playbook.md @@ -10,7 +10,7 @@ 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. +Git resolves _text_. Neither of these produces a conflict marker. ### A. Missed application @@ -23,14 +23,14 @@ so the invariant silently does not hold there. > `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* +> `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 +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. @@ -44,7 +44,7 @@ something false. ### 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 +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. @@ -57,7 +57,7 @@ is upstream's — the fork's version is shadowed without a single warning. > 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 +> 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 @@ -102,7 +102,7 @@ histories. ## The procedure -Run this *after* the merge resolves and *before* the release. +Run this _after_ the merge resolves and _before_ the release. ### 1. Enumerate what upstream introduced @@ -117,7 +117,7 @@ git show :models.py | sed -n '/^def sync_event_ticket_waves/,/return event/ 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 +### 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 @@ -147,7 +147,7 @@ grep -n "price_per_ticket\|amount_tickets" $(comm -23 ...) 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 +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 diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md index 56e2ee7..f2ceb98 100644 --- a/docs/upstream-candidates.md +++ b/docs/upstream-candidates.md @@ -4,17 +4,18 @@ Running log of fork features that are shaped so they could be offered to `lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here in an upstream-compatible form; strike it when the PR merges upstream. -| Feature | Where | Upstream target | Readiness | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | -| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | -| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | -| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | -| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | -| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | -| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | -| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | -| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | -| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | -| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`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 | +| Feature | Where | Upstream target | Readiness | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | +| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | +| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | +| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | +| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | +| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | +| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | +| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | +| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | +| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | +| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`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 | diff --git a/migrations_fork.py b/migrations_fork.py index 238a2d1..cfab24d 100644 --- a/migrations_fork.py +++ b/migrations_fork.py @@ -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", + ) diff --git a/models.py b/models.py index bf7fd32..92dcd84 100644 --- a/models.py +++ b/models.py @@ -151,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): @@ -186,18 +192,28 @@ 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 @@ -430,6 +446,31 @@ def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[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 diff --git a/nostr_hooks.py b/nostr_hooks.py index 74cb8ec..e0b0379 100644 --- a/nostr_hooks.py +++ b/nostr_hooks.py @@ -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: diff --git a/nostr_publisher.py b/nostr_publisher.py index 0e5b4c4..0bf5ab6 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -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 diff --git a/tests/test_publish_active_wave.py b/tests/test_publish_active_wave.py new file mode 100644 index 0000000..7c73781 --- /dev/null +++ b/tests/test_publish_active_wave.py @@ -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])) == "" diff --git a/tests/test_wave_transition_sweep.py b/tests/test_wave_transition_sweep.py new file mode 100644 index 0000000..0c57e13 --- /dev/null +++ b/tests/test_wave_transition_sweep.py @@ -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"