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 01/28] 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 02/28] 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 03/28] 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 04/28] 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 05/28] 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 06/28] 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 07/28] 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 165787d134fc6793fee25c34b384e18401d6cdd1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 26 Sep 2026 23:34:47 +0200 Subject: [PATCH 08/28] fix(nostr): log publish skips at WARNING instead of silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both paths that decline to publish a NIP-52 calendar event were invisible at the INFO level instances actually run at: the `signer is None` branch returned bare with no log at any level, and the missing-client branch logged at debug. A skipped publish leaves the relay serving whatever inventory it last saw, so the public ticket count stops tracking the DB with nothing to indicate it. That has now been found twice, both times only because a human noticed a wrong number on a public page — #35 on aio-demo, and on cfaun where an event drifted for 14 days and produced no log line at all, success or failure. Both messages name the event id and whether it was a publish or a delete, so a skip can be tied to a specific event without correlating timestamps by hand. Refs #51 --- nostr_hooks.py | 16 +++++++++++++--- nostr_publisher.py | 9 ++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/nostr_hooks.py b/nostr_hooks.py index 32ea11c..1d0d1c4 100644 --- a/nostr_hooks.py +++ b/nostr_hooks.py @@ -32,9 +32,19 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) - if signer is None: # Wallet missing, account missing, unclassified row, or # ClientSideOnlySigner account (server can't sign for them). - # Soft-fail: skip the publish silently. The user can still - # publish kind-31922/31923 events client-side once we have - # that path. + # Soft-fail: the HTTP / payment flow that triggered this must + # not break. The user can still publish kind-31922/31923 + # events client-side once we have that path. + # + # Logged at WARNING, not debug: skipping the publish means the + # relay keeps serving whatever inventory it last saw, so the + # public ticket count silently stops tracking the DB. That has + # twice been discovered only by a human noticing a wrong number + # on a public page (aiolabs/events#35, #51). + logger.warning( + f"[EVENTS] No signer for wallet {event.wallet}, skipping " + f"NIP-52 {'delete' if delete else 'publish'} for event {event.id}" + ) return nostr_event = await publish_event_to_nostr( diff --git a/nostr_publisher.py b/nostr_publisher.py index 8374a62..c961b80 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -172,7 +172,14 @@ async def publish_event_to_nostr( Returns the published NostrEvent for metadata storage, or None on failure. """ if not nostr_client: - logger.debug("[EVENTS] No NostrClient available, skipping publish") + # WARNING, not debug: with no client the event is never queued, so + # the relay keeps serving stale inventory and nothing downstream + # can tell. At debug this skip is invisible at the INFO level + # instances actually run at (aiolabs/events#35, #51). + logger.warning( + "[EVENTS] No NostrClient, skipping NIP-52 " + f"{'delete' if delete else 'publish'} for event {event.id}" + ) return None try: From 5d52a231d3637b9d13941b58baa2ab74060edf53 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 26 Sep 2026 23:45:47 +0200 Subject: [PATCH 09/28] feat(nostr): flag events whose NIP-52 publish didn't land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inventory reaches clients only through the republished calendar event, and until now a publish that failed or was skipped left no durable trace — only a log line, if that. Twice the drift was caught by a human reading a wrong number on a public page (#35 on aio-demo, #51 on cfaun, where an event's relay copy sat 14 days behind the DB). Adds `events.nostr_publish_pending`, set before every attempt and cleared only on a confirmed success. Ordering it that way is what makes "the attempt was never made" — no signer resolved, no NostrClient, the process died mid-flight — as discoverable as "the attempt raised". Both shapes have now been observed in production; only the second one was ever visible. `set_ticket_paid` raises the flag inside its own update so the counters and "the relay doesn't know about them yet" commit atomically, and the sale path pays no extra write. `publish_or_delete_nostr_event` now returns a bool so callers can branch. The flag, not the return value, is the durable record — the existing call sites stay correct ignoring it. Publish failures move from WARNING to ERROR: the published ticket count has stopped tracking reality, which is not routine journal noise. Refs #35 --- migrations_fork.py | 29 +++++ models.py | 4 + nostr_hooks.py | 33 +++++- nostr_publisher.py | 5 +- services.py | 4 + tests/test_nostr_publish_pending.py | 171 ++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 tests/test_nostr_publish_pending.py diff --git a/migrations_fork.py b/migrations_fork.py index ebe65a2..238a2d1 100644 --- a/migrations_fork.py +++ b/migrations_fork.py @@ -127,3 +127,32 @@ async def m002_ticket_payment_hash(db): "UPDATE events.ticket SET payment_hash = id " "WHERE payment_hash IS NULL OR payment_hash = ''" ) + + +async def m003_event_nostr_publish_pending(db): + """ + Add `events.nostr_publish_pending` — the marker that makes NIP-52 + publish drift queryable instead of invisible. + + Inventory reaches clients only through the republished calendar + event. When that publish doesn't land, the relay keeps serving the + counts it last saw and nothing anywhere records the divergence; it + has twice been caught only by a human reading a public page + (aiolabs/events#35, #51). + + The flag is set before each publish attempt and cleared only on a + confirmed success, so it covers *both* observed failure shapes: + an attempt that raised (a signer outage) and an attempt that was + never made at all (no signer resolved, no NostrClient). A periodic + sweep republishes whatever is still marked. + + Existing rows default to FALSE rather than TRUE: on upgrade we have + no evidence they're stale, and marking the whole table pending would + stampede the signer with a full-table republish on first boot. + `/republish-all` is the deliberate way to force that. + """ + await _alter_add_column_safe( + db, + "ALTER TABLE events.events " + "ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE", + ) diff --git a/models.py b/models.py index b349794..8e7ec22 100644 --- a/models.py +++ b/models.py @@ -125,6 +125,10 @@ class Event(BaseModel): status: str = "approved" nostr_event_id: str | None = None nostr_event_created_at: int | None = None + # Set before every publish attempt, cleared on confirmed success. + # True means the relay's copy may be behind this row — see + # migrations_fork.m003 and the sweep in __init__.events_start. + nostr_publish_pending: bool = False @validator("categories", pre=True) def parse_categories(cls, v): diff --git a/nostr_hooks.py b/nostr_hooks.py index 1d0d1c4..74cb8ec 100644 --- a/nostr_hooks.py +++ b/nostr_hooks.py @@ -12,7 +12,7 @@ from .models import Event from .nostr_publisher import publish_event_to_nostr -async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None: +async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> bool: """Publish or delete the NIP-52 calendar event for `event`. Resolves a `NostrSigner` for the wallet owner — backend-agnostic @@ -22,7 +22,22 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) - `await signer.sign_event(...)` for signing. Failures are logged and swallowed so a Nostr outage doesn't break the HTTP flow that triggered the publish. + + Returns True when the event was signed and handed to the client, + False on any skip or failure. Callers are free to ignore it — the + `nostr_publish_pending` flag is the durable record, and the sweep + retries from that rather than from a return value. """ + # Mark before attempting, clear only on confirmed success. Doing it + # in this order is what makes "the attempt was never made" — no + # signer, no NostrClient, process died mid-flight — as visible as + # "the attempt raised". Cheap guard so a re-publish of an already + # pending row doesn't write twice; `set_ticket_paid` sets the flag + # inside its own update so the sale path adds no extra write. + if not event.nostr_publish_pending: + event.nostr_publish_pending = True + await update_event(event) + try: from lnbits.core.signers import resolve_for_wallet @@ -45,14 +60,22 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) - f"[EVENTS] No signer for wallet {event.wallet}, skipping " f"NIP-52 {'delete' if delete else 'publish'} for event {event.id}" ) - return + return False nostr_event = await publish_event_to_nostr( nostr_client, event, signer, delete=delete ) - if nostr_event and not delete: + if nostr_event is None: + return False + + event.nostr_publish_pending = False + if not delete: event.nostr_event_id = nostr_event.id event.nostr_event_created_at = nostr_event.created_at - await update_event(event) + await update_event(event) + return True except Exception as exc: - logger.warning(f"[EVENTS] Nostr publish failed: {exc}") + # ERROR, not warning: the row stays flagged and its published + # counts stay behind until the sweep or a later edit succeeds. + logger.error(f"[EVENTS] Nostr publish failed for event {event.id}: {exc}") + return False diff --git a/nostr_publisher.py b/nostr_publisher.py index c961b80..485acd4 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -212,5 +212,8 @@ async def publish_event_to_nostr( return nostr_event except Exception as e: - logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}") + # ERROR, not warning: this is the signer-outage shape of + # aiolabs/events#35 — the calendar event never reaches the relay + # and the published ticket counts stop tracking the DB. + logger.error(f"[EVENTS] Failed to publish event {event.id} to Nostr: {e}") return None diff --git a/services.py b/services.py index f69b76e..311b7e2 100644 --- a/services.py +++ b/services.py @@ -71,6 +71,10 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: assert event, "Couldn't get event from ticket being paid" event.sold += 1 event.amount_tickets -= 1 + # Flag inside this same write: the counters and "the relay does + # not know about them yet" land atomically, so a crash between + # here and the publish still leaves the drift discoverable. + event.nostr_publish_pending = True await update_event(event) # Republish the NIP-52 calendar event so connected clients see diff --git a/tests/test_nostr_publish_pending.py b/tests/test_nostr_publish_pending.py new file mode 100644 index 0000000..3caace0 --- /dev/null +++ b/tests/test_nostr_publish_pending.py @@ -0,0 +1,171 @@ +"""The `nostr_publish_pending` marker and its lifecycle. + +Inventory reaches clients only through the republished NIP-52 calendar +event. These tests pin the invariant that makes drift recoverable: the +flag goes up before every attempt and comes down only on a confirmed +success, so every shape of failure — raised, skipped, never attempted — +leaves the row queryable by the sweep. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from .. import nostr_hooks, services +from ..models import Event, Ticket + + +def _event(**kwargs) -> Event: + defaults = { + "id": "evt", + "wallet": "w", + "name": "Test", + "info": "", + "closing_date": "2030-01-01", + "event_start_date": "2030-01-01", + "event_end_date": "2030-01-02", + "currency": "sat", + "price_per_ticket": 1000, + "amount_tickets": 10, + "time": datetime.now(timezone.utc), + "status": "approved", + } + defaults.update(kwargs) + return Event(**defaults) + + +@pytest.fixture +def saved(monkeypatch): + """Capture every update_event write so ordering can be asserted.""" + writes: list[bool] = [] + + async def _update(event): + writes.append(event.nostr_publish_pending) + return event + + monkeypatch.setattr(nostr_hooks, "update_event", _update) + return writes + + +def _signer(monkeypatch, signer): + monkeypatch.setattr( + "lnbits.core.signers.resolve_for_wallet", AsyncMock(return_value=signer) + ) + + +def _publisher(monkeypatch, result): + monkeypatch.setattr( + nostr_hooks, "publish_event_to_nostr", AsyncMock(return_value=result) + ) + + +@pytest.mark.asyncio +async def test_success_raises_then_clears_the_flag(monkeypatch, saved): + event = _event() + _signer(monkeypatch, SimpleNamespace(pubkey="pk")) + _publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123)) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is True + # Flagged before the attempt, cleared after it — in that order. + assert saved == [True, False] + assert event.nostr_publish_pending is False + assert event.nostr_event_id == "nid" + assert event.nostr_event_created_at == 123 + + +@pytest.mark.asyncio +async def test_missing_signer_leaves_the_flag_up(monkeypatch, saved): + event = _event() + _signer(monkeypatch, None) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is False + assert saved == [True] + assert event.nostr_publish_pending is True + + +@pytest.mark.asyncio +async def test_publisher_returning_none_leaves_the_flag_up(monkeypatch, saved): + """The no-NostrClient shape: nothing raised, nothing published.""" + event = _event() + _signer(monkeypatch, SimpleNamespace(pubkey="pk")) + _publisher(monkeypatch, None) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is False + assert saved == [True] + assert event.nostr_publish_pending is True + + +@pytest.mark.asyncio +async def test_raised_publish_leaves_the_flag_up(monkeypatch, saved): + event = _event() + _signer(monkeypatch, SimpleNamespace(pubkey="pk")) + monkeypatch.setattr( + nostr_hooks, + "publish_event_to_nostr", + AsyncMock(side_effect=RuntimeError("signer timeout")), + ) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is False + assert saved == [True] + assert event.nostr_publish_pending is True + + +@pytest.mark.asyncio +async def test_already_pending_row_is_not_re_flagged(monkeypatch, saved): + """The sweep re-publishing a flagged row writes once, not twice.""" + event = _event(nostr_publish_pending=True) + _signer(monkeypatch, SimpleNamespace(pubkey="pk")) + _publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123)) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is True + assert saved == [False] + + +@pytest.mark.asyncio +async def test_delete_clears_the_flag_without_touching_the_coordinate( + monkeypatch, saved +): + """A take-down must not overwrite the id/created_at of the event it + just deleted — the kind-5 has its own.""" + event = _event(nostr_event_id="old", nostr_event_created_at=100) + _signer(monkeypatch, SimpleNamespace(pubkey="pk")) + _publisher(monkeypatch, SimpleNamespace(id="del", created_at=999)) + + assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True + assert event.nostr_publish_pending is False + assert event.nostr_event_id == "old" + assert event.nostr_event_created_at == 100 + + +@pytest.mark.asyncio +async def test_sale_flags_the_event_in_the_same_write(monkeypatch): + """`set_ticket_paid` must flag inside its own update, so the counters + and "the relay doesn't know yet" land atomically.""" + event = _event(sold=4, amount_tickets=6) + seen: list[tuple[int, int, bool]] = [] + + async def _update_event(ev): + seen.append((ev.sold, ev.amount_tickets, ev.nostr_publish_pending)) + return ev + + monkeypatch.setattr(services, "update_ticket", AsyncMock()) + monkeypatch.setattr(services, "get_event", AsyncMock(return_value=event)) + monkeypatch.setattr(services, "update_event", _update_event) + monkeypatch.setattr(services, "publish_or_delete_nostr_event", AsyncMock()) + + ticket = Ticket( + id="t1", + wallet="w", + event="evt", + name="A", + email="a@example.com", + registered=False, + paid=False, + time=datetime.now(timezone.utc), + reg_timestamp=datetime.now(timezone.utc), + ) + await services.set_ticket_paid(ticket) + + assert seen == [(5, 5, True)] From 643322131e072f7b671c587e555f46c7ae85a8d1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 26 Sep 2026 23:45:47 +0200 Subject: [PATCH 10/28] feat(nostr): sweep republishes events flagged as pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flagged row recovers on its own instead of waiting for the next sale that happens to land while the signer is healthy — or for an operator who already knows to run /republish-all, which was the only recovery path and requires knowing about drift that nothing reported. Retrying from the DB rather than an in-memory queue means the retry survives a restart, and it needs no theory about why the publish didn't land: the sweep covers the signer outage of #35 and the silent skip of #51 identically, along with causes nobody has hit yet. Runs every 5 minutes, take-down branch mirroring the publish/delete split the CRUD endpoints already use. Quiet by design — on a healthy instance the query returns nothing and it logs nothing. Refs #35 --- __init__.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ crud.py | 20 ++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/__init__.py b/__init__.py index a0b330d..6323038 100644 --- a/__init__.py +++ b/__init__.py @@ -34,6 +34,15 @@ scheduled_tasks: list[asyncio.Task] = [] # from nostr_hooks.publish_or_delete_nostr_event. nostr_client = None +# Reconciliation sweep for NIP-52 publishes that never reached a relay +# (aiolabs/events#35). Five minutes is well under the window in which a +# stale ticket count matters to a buyer, and the query costs nothing +# when there is no drift — the normal case returns zero rows. +REPUBLISH_SWEEP_INTERVAL = 300 +# Long enough for _start_nostr_client's own 10s wait plus the relay +# handshake, so the first pass isn't guaranteed to fail on a cold boot. +REPUBLISH_SWEEP_FIRST_DELAY = 60 + def events_stop(): for task in scheduled_tasks: @@ -117,5 +126,45 @@ def events_start(): task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events) scheduled_tasks.append(task3) + async def _republish_pending_sweep(): + """Retry NIP-52 publishes that never landed. + + Inventory reaches clients only through the republished calendar + event, and a publish can fail (signer outage) or be skipped + entirely (no signer, no NostrClient) without anything noticing. + Both leave `nostr_publish_pending` set, so this sweep retries + from the DB rather than from an in-memory queue — it survives a + restart, which the previous behaviour did not. + + Quiet by design: on a healthy instance the query returns nothing + and this logs nothing. It only speaks up when there is drift. + """ + from .crud import get_events_pending_republish + from .nostr_hooks import publish_or_delete_nostr_event + + await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY) + while True: + try: + pending = await get_events_pending_republish() + if pending: + total = len(pending) + logger.info(f"[EVENTS] Republish sweep: {total} event(s) pending") + recovered = 0 + for event in pending: + take_down = event.canceled or event.status != "approved" + if await publish_or_delete_nostr_event(event, delete=take_down): + recovered += 1 + logger.info( + f"[EVENTS] Republish sweep: {recovered}/{total} recovered" + ) + except Exception as exc: + logger.error(f"[EVENTS] Republish sweep failed: {exc}") + await asyncio.sleep(REPUBLISH_SWEEP_INTERVAL) + + task4 = create_permanent_unique_task( + "ext_events_republish_sweep", _republish_pending_sweep + ) + scheduled_tasks.append(task4) + __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] diff --git a/crud.py b/crud.py index bd5d3c1..caa0a2f 100644 --- a/crud.py +++ b/crud.py @@ -239,6 +239,26 @@ async def get_pending_events() -> list[Event]: ) +async def get_events_pending_republish() -> list[Event]: + """Events whose relay copy may be behind this row. + + `nostr_publish_pending` is set before every publish attempt and + cleared only on a confirmed success, so a row still flagged here + either failed to publish or never got the chance. Drives the + reconciliation sweep in `events_start`. + + Ordered oldest-first so a backlog drains in the order it accrued. + """ + return await db.fetchall( + """ + SELECT * FROM events.events + WHERE nostr_publish_pending = TRUE + ORDER BY time ASC + """, + model=Event, + ) + + async def get_settings() -> EventsSettings: """Singleton settings row, seeded by m010.""" row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1") From dc2a296bad6fbc585d670bbebe47c10ac5bd081f Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 26 Sep 2026 23:45:47 +0200 Subject: [PATCH 11/28] fix(nostr): retry a dequeued req instead of dropping it `run_forever` took a req off the queue and then sent it; if the send raised, the req was already gone and the publish was lost outright, with the caller long since told it succeeded (the queue put returns immediately, and the publisher logs "Published" straight after). Hold the req across the reconnect and retry, bounded at three attempts so one unsendable message can't wedge every later publish behind it. This narrows but does not close the gap: a send is still confirmed at the queue, not by the relay's OK, so a half-dead socket can accept bytes that never arrive. Closing that needs OK handling in publish_nostr_event. Refs #35 --- nostr/nostr_client.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 4de332f..e0ae70f 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -19,6 +19,9 @@ from websocket import WebSocketApp from .event import NostrEvent MAX_SEEN_EVENTS = 500 +# How many times a dequeued req is retried before it is dropped. Bounded +# so one unsendable message can't block every later publish behind it. +MAX_SEND_ATTEMPTS = 3 class NostrClient: @@ -78,17 +81,37 @@ class NostrClient: async def run_forever(self): self.running = True + # A req that was dequeued but whose send raised. It is already + # off the queue, so dropping it loses the publish outright and + # the caller has long since been told it succeeded (the queue + # put returns immediately). Hold it across the reconnect and + # retry instead. + held_req: list | None = None + held_attempts = 0 while self.running: try: if not self.is_websocket_connected: self.ws = await self.connect() await asyncio.sleep(5) - req = await self.send_req_queue.get() + if held_req is not None: + req = held_req + else: + req = await self.send_req_queue.get() + held_req, held_attempts = req, held_attempts + 1 assert self.ws self.ws.send(json.dumps(req)) + held_req, held_attempts = None, 0 except Exception as ex: logger.warning(f"[EVENTS] NostrClient error: {ex}") + if held_req is not None and held_attempts >= MAX_SEND_ATTEMPTS: + # Bounded: a req the relay or the socket will never + # accept must not wedge the queue behind it forever. + logger.error( + f"[EVENTS] Dropping req after {held_attempts} " + f"failed sends: {held_req[0]}" + ) + held_req, held_attempts = None, 0 await asyncio.sleep(60) def is_duplicate_event(self, event_id: str) -> bool: From cac7d16ece5f354248531aab1524f7d4059bca7d Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 27 Sep 2026 09:27:40 +0200 Subject: [PATCH 12/28] chore(release): v1.6.1-aio.16 Nostr publish reliability. - #54 the two paths that skip a NIP-52 publish now log at WARNING instead of silently (bare return / debug); publish failures moved to ERROR - #55 `events.nostr_publish_pending` marks a row from before each publish attempt until a confirmed success, and a 5-minute sweep republishes whatever is still flagged, so drift recovers on its own instead of waiting for an operator who knows to run /republish-all - #55 the NostrClient send loop holds and retries a dequeued req (bounded at 3) rather than dropping it Adds migration m003 (events.nostr_publish_pending). Verified on bohm: events_fork 1 -> 3, column present, event created and published to a relay with the flag clearing on success. --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 4a26dac..21cef68 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.15", + "version": "1.6.1-aio.16", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", From cc730256abe921665068a81c441145a6f797a932 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 27 Sep 2026 12:36:55 +0200 Subject: [PATCH 13/28] feat(nostr): confirm publishes against the relay's OK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `publish_nostr_event` returned as soon as the EVENT was on the send queue, and the publisher logged "Published" on the next line. Queueing is not delivery: nostrclient drops an EVENT outright when no relay is connected, answering `OK false "error: no relays connected"`. We threw that reply away. On cfaun this cost a completed repair. The #55 sweep republished a 14-day-stale calendar event 21 seconds before nostrclient had finished connecting to its relay, got `OK false`, logged `Published`, reported `1/1 recovered` and cleared `nostr_publish_pending` — leaving the count stale, the row unflagged and the log asserting success. It took a manual re-arm of the flag to finish the job. So the flag's contract was never true: it claimed to clear only on a confirmed success but cleared on a confirmed enqueue. `publish_nostr_event` now registers a future per event id, awaits the `OK`, and returns whether it was accepted. `publish_event_to_nostr` returns None when unconfirmed, which keeps the row flagged so the sweep retries rather than recording a delivery that never happened. Correlation lives in `get_event`, the one place relay messages cross from the websocket thread into the event loop — no cross-thread future juggling. OK frames are consumed there rather than forwarded; the sync loop never handled them. A disconnect settles every in-flight publish immediately instead of making callers wait out the timeout. On latency: the timeout is not the common cost. A disconnected relay is rejected by nostrclient's router in milliseconds (230ms measured on aio-demo), so the 12s budget only applies when relays are connected but silent, which nostrclient itself bounds at 10s. `set_ticket_paid` runs on the invoice-listener task, so that narrow case does stall the loop; if it ever matters, the remedy is to stop awaiting on the sale path while leaving the flag set — the sweep already guarantees eventual delivery — not to go back to reporting unverified success. Closes #56 --- nostr/nostr_client.py | 91 +++++++++++++++++++++++-- nostr_publisher.py | 11 ++- tests/test_publish_confirmation.py | 105 +++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 tests/test_publish_confirmation.py diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index e0ae70f..9afa0be 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -22,6 +22,11 @@ MAX_SEEN_EVENTS = 500 # How many times a dequeued req is retried before it is dropped. Bounded # so one unsendable message can't block every later publish behind it. MAX_SEND_ATTEMPTS = 3 +# How long to wait for the relay's `OK` before treating a publish as +# unconfirmed. nostrclient's router answers within its own +# PUBLISH_TIMEOUT_SECONDS (10s) even when every relay stays silent, so +# this only needs headroom over that. +PUBLISH_OK_TIMEOUT_SECONDS = 12 class NostrClient: @@ -32,6 +37,10 @@ class NostrClient: self.subscription_id = "events-" + urlsafe_short_hash()[:32] self.running = False self._seen_events: OrderedDict[str, None] = OrderedDict() + # event id -> future awaiting that publish's `OK`. Resolved in + # `get_event`, which is the single point where relay messages + # cross into the event loop. + self._pending_oks: dict[str, asyncio.Future] = {} @property def is_websocket_connected(self): @@ -124,14 +133,82 @@ class NostrClient: return False async def get_event(self): - """Get next event from the receive queue.""" - value = await self.receive_event_queue.get() - if isinstance(value, ValueError): - raise value - return value + """Get the next relay message, consuming `OK` frames on the way. - async def publish_nostr_event(self, e: NostrEvent): - await self.send_req_queue.put(["EVENT", e.dict()]) + This is the only place relay messages cross from the websocket + thread into the event loop, which makes it the natural place to + settle publish confirmations — no cross-thread future juggling. + `OK` frames are swallowed rather than forwarded; the sync loop + never handled them. + """ + while True: + value = await self.receive_event_queue.get() + if isinstance(value, ValueError): + self._fail_pending_oks("connection closed") + raise value + if self._settle_ok(value): + continue + return value + + def _settle_ok(self, message) -> bool: + """Resolve the future for an `["OK", , , ]` frame. + + Returns True when `message` was an OK frame (and so should not + be forwarded), False otherwise. An OK for a publish we are not + waiting on — a retry whose original already timed out, say — is + still consumed; it has nowhere useful to go. + """ + try: + data = json.loads(message) + except (json.JSONDecodeError, TypeError): + return False + if not isinstance(data, list) or len(data) < 3 or data[0] != "OK": + return False + + event_id = data[1] + accepted = bool(data[2]) + detail = data[3] if len(data) > 3 and isinstance(data[3], str) else "" + future = self._pending_oks.get(event_id) + if future and not future.done(): + future.set_result((accepted, detail)) + return True + + def _fail_pending_oks(self, reason: str) -> None: + """Settle every in-flight publish as unconfirmed. + + Without this a disconnect leaves callers waiting the full + timeout for an `OK` that can no longer arrive. + """ + for future in self._pending_oks.values(): + if not future.done(): + future.set_result((False, f"error: {reason}")) + + async def publish_nostr_event(self, e: NostrEvent) -> bool: + """Publish and wait for the relay's `OK`. True only when accepted. + + Queueing is not delivery: nostrclient drops an EVENT outright + when no relay is connected, answering `OK false`. Reporting + success on the queue put let a stale ticket count survive a + republish that never left the building (aiolabs/events#56). + """ + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._pending_oks[e.id] = future + try: + await self.send_req_queue.put(["EVENT", e.dict()]) + accepted, detail = await asyncio.wait_for( + future, PUBLISH_OK_TIMEOUT_SECONDS + ) + if not accepted: + logger.warning(f"[EVENTS] Relay rejected event {e.id[:12]}…: {detail}") + return accepted + except asyncio.TimeoutError: + logger.warning( + f"[EVENTS] No OK for event {e.id[:12]}… within " + f"{PUBLISH_OK_TIMEOUT_SECONDS}s — treating as unconfirmed" + ) + return False + finally: + self._pending_oks.pop(e.id, None) async def subscribe(self, filters: list[dict]): """Subscribe to events matching the given filters.""" diff --git a/nostr_publisher.py b/nostr_publisher.py index 485acd4..677d96f 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -203,7 +203,16 @@ async def publish_event_to_nostr( nostr_event.pubkey = signed["pubkey"] nostr_event.sig = signed["sig"] - await nostr_client.publish_nostr_event(nostr_event) + accepted = await nostr_client.publish_nostr_event(nostr_event) + if not accepted: + # Returning None keeps `nostr_publish_pending` set, so the + # sweep retries instead of recording a delivery that never + # happened (aiolabs/events#56). + logger.warning( + f"[EVENTS] Relay did not confirm NIP-52 " + f"{'delete' if delete else 'calendar'} event for {event.id}" + ) + return None logger.info( f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} " diff --git a/tests/test_publish_confirmation.py b/tests/test_publish_confirmation.py new file mode 100644 index 0000000..9615209 --- /dev/null +++ b/tests/test_publish_confirmation.py @@ -0,0 +1,105 @@ +"""Publish confirmation against the relay's `OK` (aiolabs/events#56). + +Queueing is not delivery. nostrclient drops an EVENT outright when no +relay is connected and answers `OK false`; before this, that reply was +discarded and the publish reported success, which once cleared the +`nostr_publish_pending` flag on a republish that never left the +building. +""" + +import asyncio +import json + +import pytest + +from ..nostr import nostr_client as nc +from ..nostr.event import NostrEvent + + +def _event(event_id: str = "a" * 64) -> NostrEvent: + e = NostrEvent(pubkey="b" * 64, created_at=0, kind=31923) + e.id = event_id + return e + + +async def _publish_and_reply(client, event, reply, delay=0.01): + """Start a publish, then feed `reply` through the receive path.""" + task = asyncio.create_task(client.publish_nostr_event(event)) + await asyncio.sleep(delay) # let the future register + if reply is not None: + client.receive_event_queue.put_nowait(reply) + consumer = asyncio.create_task(client.get_event()) + await asyncio.sleep(delay) + consumer.cancel() + return await task + + +@pytest.mark.asyncio +async def test_accepted_publish_returns_true(): + client = nc.NostrClient() + event = _event() + ok = json.dumps(["OK", event.id, True, ""]) + assert await _publish_and_reply(client, event, ok) is True + assert client._pending_oks == {} + + +@pytest.mark.asyncio +async def test_rejected_publish_returns_false(): + """The shape that bit us: no relay connected, so nostrclient's + router answers `OK false` without the event ever being sent.""" + client = nc.NostrClient() + event = _event() + ok = json.dumps(["OK", event.id, False, "error: no relays connected"]) + assert await _publish_and_reply(client, event, ok) is False + assert client._pending_oks == {} + + +@pytest.mark.asyncio +async def test_missing_ok_times_out_as_unconfirmed(monkeypatch): + monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05) + client = nc.NostrClient() + assert await _publish_and_reply(client, _event(), None) is False + assert client._pending_oks == {} + + +@pytest.mark.asyncio +async def test_ok_for_a_different_event_does_not_settle_ours(monkeypatch): + monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05) + client = nc.NostrClient() + other = json.dumps(["OK", "c" * 64, True, ""]) + assert await _publish_and_reply(client, _event(), other) is False + + +@pytest.mark.asyncio +async def test_get_event_swallows_ok_and_forwards_everything_else(): + client = nc.NostrClient() + client.receive_event_queue.put_nowait(json.dumps(["OK", "d" * 64, True, ""])) + forwarded = json.dumps(["EVENT", "sub", {"id": "e" * 64}]) + client.receive_event_queue.put_nowait(forwarded) + assert await client.get_event() == forwarded + + +@pytest.mark.asyncio +async def test_disconnect_settles_inflight_publishes_immediately(monkeypatch): + """A dropped socket must not leave the caller waiting the full + timeout for an OK that can no longer arrive.""" + monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 30) + client = nc.NostrClient() + event = _event() + task = asyncio.create_task(client.publish_nostr_event(event)) + await asyncio.sleep(0.01) + + client.receive_event_queue.put_nowait(ValueError("WebSocket closed")) + consumer = asyncio.create_task(client.get_event()) + await asyncio.sleep(0.01) + consumer.cancel() + + assert await asyncio.wait_for(task, 1) is False + + +def test_settle_ok_ignores_non_ok_frames(): + client = nc.NostrClient() + assert client._settle_ok(json.dumps(["EVENT", "sub", {}])) is False + assert client._settle_ok(json.dumps(["EOSE", "sub"])) is False + assert client._settle_ok("not json") is False + assert client._settle_ok(json.dumps(["OK", "f" * 64, True, ""])) is True From ad8aa2cf00f6fc5e60f271a1f29fe38728a1bdb3 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 27 Sep 2026 22:25:01 +0200 Subject: [PATCH 14/28] fix(tickets): amount_tickets is the remaining count, stop subtracting sold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set_ticket_paid` decrements `amount_tickets` on every sale and increments `sold`, so the two describe the same tickets. Subtracting one from the other in `api_ticket_create` removed each sale twice: remaining = amount_tickets - sold That under-reported availability to buyers, and because `remaining + sold` is the original capacity, `sold >= amount_tickets` first becomes true at the halfway point — so every event locked itself as sold out once half its seats had gone, with the rest still unsold. Measured on aio-demo before the fix: 16 of 24 live events affected, 3 of them already refusing sales while stock remained. "Tech Meetup" had 9 tickets left and offered -2. The arithmetic was ours, introduced with the multi-quantity purchase feature; upstream has no equivalent because it sells one ticket per request and reads `amount_tickets` as remaining everywhere. So this deletes the divergence and restores upstream's own guard plus the one line `quantity` needs, which should also make the v1.6.8 rebase (#33) a little easier rather than harder. Tests walk a 50-seat event to capacity one sale at a time and pin the boundary: sold=49 passes even unfixed, sold=50 is where it used to lock. Six of the ten fail without the change. Scoped deliberately to the double-subtraction. The rest of #34 — making capacity a required field, dropping the `0 = unlimited` reading, and the organizer form that prefills remaining as though it were capacity — waits for #33, since ticket waves change the shape of that fix. Refs #34 --- tests/test_capacity_guard.py | 112 +++++++++++++++++++++++++++++++++++ views_api.py | 23 +++---- 2 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 tests/test_capacity_guard.py diff --git a/tests/test_capacity_guard.py b/tests/test_capacity_guard.py new file mode 100644 index 0000000..4125278 --- /dev/null +++ b/tests/test_capacity_guard.py @@ -0,0 +1,112 @@ +"""`amount_tickets` is the remaining count (aiolabs/events#34). + +`set_ticket_paid` decrements it on every sale and `sold` increments, so +subtracting `sold` from it removes each sale twice. That made the buyer +cap under-report and, once an event passed half its capacity, turned +`sold >= amount_tickets` true and declared it sold out with stock left. +Measured on aio-demo before the fix: 16 of 24 live events affected, +3 already refusing sales while tickets remained. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from .. import views_api +from ..models import CreateTicket, Event + +SENTINEL = object() + + +def _event(amount_tickets: int, sold: int) -> Event: + return Event( + id="evt", + wallet="w", + name="Capacity", + info="", + closing_date="2030-01-01", + event_start_date="2030-01-01", + event_end_date="2030-01-02", + currency="sat", + price_per_ticket=0, # free path, so the guard is all that gates us + amount_tickets=amount_tickets, + sold=sold, + time=datetime.now(timezone.utc), + status="approved", + ) + + +@pytest.fixture +def issued(monkeypatch): + """Past the capacity guard the free path short-circuits to a sentinel.""" + monkeypatch.setattr( + views_api, "_issue_free_tickets", AsyncMock(return_value=SENTINEL) + ) + monkeypatch.setattr( + views_api, "_resolve_frontend_root", lambda data, req: "http://x" + ) + return SENTINEL + + +async def _buy(amount_tickets: int, sold: int, quantity: int, monkeypatch): + monkeypatch.setattr( + views_api, "get_event", AsyncMock(return_value=_event(amount_tickets, sold)) + ) + return await views_api.api_ticket_create( + "evt", CreateTicket(user_id="u1", quantity=quantity), SimpleNamespace() + ) + + +@pytest.mark.asyncio +async def test_last_ticket_still_sells(monkeypatch, issued): + """One left, one wanted. Previously refused once sold >= remaining.""" + assert await _buy(1, 99, 1, monkeypatch) is issued + + +@pytest.mark.asyncio +async def test_sold_out_only_when_actually_empty(monkeypatch, issued): + with pytest.raises(HTTPException) as exc: + await _buy(0, 100, 1, monkeypatch) + assert exc.value.detail == "Event is sold out." + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sold", [0, 49, 50, 51, 500]) +async def test_remaining_alone_decides_availability(monkeypatch, issued, sold): + """The regression proper: with 50 left the event sells, whatever + `sold` says. Because remaining + sold is the original capacity, + `sold >= amount_tickets` first flips true at the halfway point — + sold=50 here — so an event locked itself once half its seats went. + The boundary cases (49/50/51) are the ones that matter.""" + assert await _buy(50, sold, 1, monkeypatch) is issued + + +@pytest.mark.asyncio +async def test_bulk_order_may_take_everything_left(monkeypatch, issued): + assert await _buy(10, 40, 10, monkeypatch) is issued + + +@pytest.mark.asyncio +async def test_bulk_order_over_capacity_reports_the_true_remainder(monkeypatch, issued): + """3 left, 5 wanted. The message must name the real remainder — it + used to say `3 - 40 = -37`. (`CreateTicket.quantity` is capped at 10 + by the model, so the overshoot is tested within that bound.)""" + with pytest.raises(HTTPException) as exc: + await _buy(3, 40, 5, monkeypatch) + assert exc.value.detail == "Only 3 ticket(s) remaining for this event." + + +@pytest.mark.asyncio +async def test_walk_an_event_to_capacity(monkeypatch, issued): + """50-seat event, one sale at a time, mirroring set_ticket_paid.""" + remaining, sold = 50, 0 + for _ in range(50): + assert await _buy(remaining, sold, 1, monkeypatch) is issued + remaining, sold = remaining - 1, sold + 1 + assert (remaining, sold) == (0, 50) + with pytest.raises(HTTPException) as exc: + await _buy(remaining, sold, 1, monkeypatch) + assert exc.value.detail == "Event is sold out." diff --git a/views_api.py b/views_api.py index 039bc4f..2d596d5 100644 --- a/views_api.py +++ b/views_api.py @@ -670,17 +670,18 @@ async def api_ticket_create( if event.canceled: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") quantity = data.quantity - if event.amount_tickets > 0: - if event.sold >= event.amount_tickets: - raise HTTPException( - status_code=HTTPStatus.GONE, detail="Event is sold out." - ) - remaining = event.amount_tickets - event.sold - if quantity > remaining: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Only {remaining} ticket(s) remaining for this event.", - ) + # `amount_tickets` IS the remaining count — `set_ticket_paid` decrements + # it on every sale, and upstream reads it the same way everywhere. Do + # not subtract `sold` from it: `sold` counts the same tickets the + # decrement already removed, so doing both takes each sale off twice + # (aiolabs/events#34). + if event.amount_tickets < 1: + raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") + if quantity > event.amount_tickets: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.", + ) name = data.name email = data.email From 68a11bb50e1fd1cda28b78265f4e722a1749c11a Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 27 Sep 2026 22:27:40 +0200 Subject: [PATCH 15/28] chore(release): v1.6.1-aio.17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket availability and publish delivery. - #58 publishes are confirmed against the relay's NIP-01 `OK` instead of the send queue. A publish that never reaches a relay now leaves the row flagged for the sweep rather than reporting success and clearing it — which had silently reverted a completed repair on cfaun. Verified end to end on aio-demo: delivery confirmed, the no-relay failure caught with the relay's own diagnostic in the log, and the retry recovering once the relay returned. - #59 `amount_tickets` is the remaining count; `api_ticket_create` no longer subtracts `sold` from it. Every event was locking itself as sold out at half capacity. 16 of 24 live events on aio-demo were affected, 3 already refusing sales with stock remaining. No schema change. Affected events start selling again on upgrade; worth re-running an availability check per host afterwards. --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 21cef68..0b27bc8 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.16", + "version": "1.6.1-aio.17", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", From adfd4529f53862798d9751eb793033fc760823b5 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 27 Sep 2026 23:03:54 +0200 Subject: [PATCH 16/28] fix(nostr): always publish tickets_available, zero is not unlimited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Omitting the tag used to mean "unlimited capacity". Nothing else in the codebase agreed: `api_get_event` and `api_ticket_create` both treat `amount_tickets < 1` as sold out. So a zero-capacity event advertised "Unlimited tickets" on the card while the detail page and the purchase both returned 410. Observed on aio-demo — three approved, listed, free events in that state. For `PKBVuusKikfJFU4PYGtBTW`: relay tickets_available absent -> webapp renders "Unlimited" GET event 410 "Event is sold out." POST ticket 410 "Event is sold out." The admin form was advertising it too (`min="0"`, `hint="0 = unlimited"`), so organizers were being invited into the broken state. Now the tag is always emitted and zero reads as sold out, which is what every other part of the system already believed. Clients that must handle an absent tag — a NIP-52 event from another publisher — are unaffected, since we simply never omit it. Scoped to removing the contradiction. Making capacity a *required* field is the other half of #34 and is blocked on the webapp: its create dialog uses a falsy check (`if (formValues.amount_tickets)`), so a 0 omits the field entirely and a server-side `ge=1` would 422 it. Refs #34 --- nostr_publisher.py | 15 ++++---- static/js/index.vue | 4 +-- tests/test_publish_availability.py | 56 ++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 tests/test_publish_availability.py diff --git a/nostr_publisher.py b/nostr_publisher.py index 677d96f..0e5b4c4 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -46,7 +46,7 @@ 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 (omitted when unlimited) + 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) @@ -96,11 +96,14 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: for cat in event.categories or []: tags.append(["t", cat]) - # `amount_tickets == 0` means unlimited capacity in this extension's - # schema. Omitting the tag is how clients distinguish unlimited from - # "0 left" (sold out). - if event.amount_tickets > 0: - tags.append(["tickets_available", str(event.amount_tickets)]) + # Always emitted, including zero. Omitting it used to mean "unlimited", + # which contradicted every other reader: `api_get_event` and + # `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a + # zero-capacity event advertised "Unlimited tickets" on the card while + # 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)]) tags.append(["tickets_sold", str(event.sold)]) tags.append(["tickets_price", str(event.price_per_ticket)]) tags.append(["tickets_currency", event.currency]) diff --git a/static/js/index.vue b/static/js/index.vue index d53f4c8..fc7db77 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -614,9 +614,9 @@ dense v-model.number="formDialog.data.amount_tickets" type="number" - min="0" + min="1" label="Amount of tickets" - hint="0 = unlimited" + hint="Total tickets on sale" >
diff --git a/tests/test_publish_availability.py b/tests/test_publish_availability.py new file mode 100644 index 0000000..0ef88a5 --- /dev/null +++ b/tests/test_publish_availability.py @@ -0,0 +1,56 @@ +"""`tickets_available` is always published, including zero (#34). + +Omitting the tag used to mean "unlimited", which contradicted every other +reader: `api_get_event` and `api_ticket_create` both treat +`amount_tickets < 1` as sold out. On aio-demo three zero-capacity events +advertised "Unlimited tickets" on the card while the detail endpoint and +the purchase both returned 410. +""" + +from datetime import datetime, timezone + +import pytest + +from ..models import Event +from ..nostr_publisher import build_nip52_event + +PUBKEY = "a" * 64 + + +def _event(amount_tickets: int, sold: int = 0) -> Event: + return Event( + id="evt", + wallet="w", + name="Availability", + info="", + closing_date="2030-01-01", + event_start_date="2030-01-01T18:00", + event_end_date="2030-01-01T22:00", + currency="sat", + price_per_ticket=0, + amount_tickets=amount_tickets, + sold=sold, + time=datetime.now(timezone.utc), + status="approved", + ) + + +def _tags(event: Event) -> dict[str, str]: + return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1} + + +@pytest.mark.parametrize("amount", [0, 1, 50]) +def test_tickets_available_is_always_present(amount): + assert _tags(_event(amount))["tickets_available"] == str(amount) + + +def test_zero_capacity_reads_as_sold_out_not_unlimited(): + """The regression: an absent tag is what clients render as unlimited.""" + tags = _tags(_event(0, sold=0)) + assert "tickets_available" in tags + assert tags["tickets_available"] == "0" + + +def test_sold_out_after_selling_through_still_publishes_zero(): + assert _tags(_event(0, sold=25))["tickets_available"] == "0" + assert _tags(_event(0, sold=25))["tickets_sold"] == "25" From ab4175a89aa2e3f08b7b083b84d92cf2c15fed7e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 27 Sep 2026 23:12:12 +0200 Subject: [PATCH 17/28] chore(release): v1.6.1-aio.18 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #62 `tickets_available` is always published, including zero. Omitting it used to mean "unlimited", which nothing else agreed with: api_get_event and api_ticket_create both read `amount_tickets < 1` as sold out, so a zero-capacity event advertised unlimited tickets on the card and returned 410 to every purchase. Three such events were live on aio-demo. The admin form no longer offers 0 either. No schema change. Existing zero-capacity events are not migrated — we cannot infer whether the organiser meant unlimited or forgot to set a number — and they will read as sold out once republished. The #55 sweep will not flag them on its own, so /republish-all is the way to refresh. --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 0b27bc8..6936bf6 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.17", + "version": "1.6.1-aio.18", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", From d90a0f8322d84666b321ee6cc275b75003357d87 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 28 Sep 2026 22:21:55 +0200 Subject: [PATCH 18/28] 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 19/28] 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" From ecd05b416865ff828caa10a2ca3afca4467ff4a1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 28 Sep 2026 23:11:30 +0200 Subject: [PATCH 20/28] fix: require a capacity on every ticket wave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Capacity is always required, there is no unlimited" was settled on #34 and enforced in #62 — but as a single number on the event. Since v1.6.8 capacity is per-wave and `event.amount_tickets` is a derived roll-up (`sync_event_ticket_waves`), so the rule had nothing holding it up at the level organisers actually set: the wave form's capacity input had no minimum, and nothing on the backend checked one at all. A zero-capacity wave can never be active — `get_active_ticket_waves` requires `amount_tickets > 0` — so it is the wave-level form of exactly what #62 removed: an event that looks on sale but refuses every purchase, on the card and at checkout both. `_validate_wave_capacity` now runs on create and update. Two details that matter: - it checks `ensure_ticket_waves(data)` rather than the raw list, so an event submitted with no waves is checked through the primary wave it is about to be given, not vacuously passed - on an edit it only checks waves NEW to the event. Selling out is the legitimate route to zero, and rejecting it would make a sold-out event uneditable — including the legacy zero-capacity rows this rule exists to let organisers fix Frontend: the wave dialog's capacity input gains the `min="1"` and hint the event-level field already had, and a new wave opens at 1 rather than 0. That default uses `??`, not `||` — a sold-out wave holds 0 and must keep showing it instead of silently regaining stock when saved. Also drops the stale `0 = unlimited / not ticketed` contract still documented on `CreateEvent.amount_tickets`, which has not been true since #62. 6 new tests; 120 pass. ruff, black, prettier clean; mypy error set unchanged from baseline. --- models.py | 7 +- static/js/index.js | 6 +- static/js/index.vue | 2 + tests/test_wave_capacity_required.py | 118 +++++++++++++++++++++++++++ views_api.py | 35 ++++++++ 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 tests/test_wave_capacity_required.py diff --git a/models.py b/models.py index 92dcd84..1768b44 100644 --- a/models.py +++ b/models.py @@ -115,7 +115,12 @@ class CreateEvent(BaseModel): currency: str = "sat" allow_fiat: bool = False fiat_currency: str = "GBP" - amount_tickets: int = 0 # 0 = unlimited / not ticketed + # Capacity is always required and there is no unlimited (#34): a zero + # here means sold out / not sellable, which `api_get_event` and + # `api_ticket_create` both enforce with a 410. Under v1.6.8 waves this + # is only the seed for the primary wave — `sync_event_ticket_waves` + # recomputes it as the sum of every wave's remaining stock. + amount_tickets: int = 0 price_per_ticket: float = 0 # 0 = free banner: str | None = None location: str | None = None # venue/address (NIP-52 'location' tag) diff --git a/static/js/index.js b/static/js/index.js index f702a4e..edddaba 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -901,7 +901,11 @@ window.PageEvents = { primaryWave.fiat_currency || event.fiat_currency || 'GBP', - amount_tickets: wave?.amount_tickets || 0, + // `??` not `||`: a sold-out wave legitimately holds 0 and must + // show it rather than silently regaining stock on save. A NEW + // wave opens at 1, the smallest capacity the backend accepts — + // there is no unlimited (#34). + amount_tickets: isEditing ? (wave?.amount_tickets ?? 0) : 1, price_per_ticket: wave?.price_per_ticket || primaryWave.price_per_ticket || diff --git a/static/js/index.vue b/static/js/index.vue index a52d056..3ffe9bb 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -1094,7 +1094,9 @@ dense v-model.number="ticketWaveDialog.data.amount_tickets" type="number" + min="1" label="Amount of tickets" + hint="Tickets on sale in this wave" >
diff --git a/tests/test_wave_capacity_required.py b/tests/test_wave_capacity_required.py new file mode 100644 index 0000000..5f40c4c --- /dev/null +++ b/tests/test_wave_capacity_required.py @@ -0,0 +1,118 @@ +"""Capacity is required per ticket wave (#34, #62). + +"Capacity is always required, there is no unlimited" was settled when +capacity was one number on the event. Since v1.6.8 it is per-wave and +`event.amount_tickets` is a derived roll-up, so the rule has to bite where +the organiser sets it. A zero-capacity wave can never be active +(`get_active_ticket_waves` requires `> 0`), so it is the wave-level form of +the trap #62 removed: an event that looks on sale but refuses every +purchase. +""" + +from datetime import datetime, timedelta, timezone +from http import HTTPStatus + +import pytest +from fastapi import HTTPException + +from ..models import CreateEvent, Event, EventExtra, TicketWave +from ..views_api import _validate_wave_capacity + +TODAY = datetime.now(timezone.utc).date() + + +def _day(offset: int) -> str: + return (TODAY + timedelta(days=offset)).isoformat() + + +def _wave(wave_id: str, stock: int) -> TicketWave: + return TicketWave( + id=wave_id, + title=wave_id, + opening_date=_day(0), + closing_date=_day(20), + currency="sat", + price_per_ticket=10, + amount_tickets=stock, + ) + + +def _create(waves=None, amount_tickets=10) -> CreateEvent: + return CreateEvent( + wallet="w", + name="Fete", + info="", + event_start_date=_day(30), + currency="sat", + price_per_ticket=10, + amount_tickets=amount_tickets, + extra=EventExtra(ticket_waves=waves or []), + ) + + +def _stored(waves) -> Event: + return Event( + id="evt", + wallet="w", + name="Fete", + info="", + closing_date=_day(30), + event_start_date=_day(30), + currency="sat", + price_per_ticket=10, + amount_tickets=sum(w.amount_tickets for w in waves), + time=datetime.now(timezone.utc), + extra=EventExtra(ticket_waves=waves), + ) + + +def _detail(exc_info) -> str: + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + return exc_info.value.detail + + +def test_wave_with_capacity_is_accepted(): + _validate_wave_capacity(_create([_wave("early", 5), _wave("late", 10)])) + + +def test_zero_capacity_wave_is_rejected_on_create(): + with pytest.raises(HTTPException) as exc_info: + _validate_wave_capacity(_create([_wave("early", 5), _wave("late", 0)])) + assert "late" in _detail(exc_info) + + +def test_event_submitted_without_waves_is_checked_through_its_primary_wave(): + """No waves means the event gets a synthesized primary wave seeded from + `amount_tickets`, so the rule must see that rather than an empty list.""" + _validate_wave_capacity(_create(waves=None, amount_tickets=10)) + + with pytest.raises(HTTPException) as exc_info: + _validate_wave_capacity(_create(waves=None, amount_tickets=0)) + assert "Primary wave" in _detail(exc_info) + + +def test_selling_a_wave_out_does_not_block_later_edits(): + """Zero is where a wave legitimately ends up. Rejecting it on edit would + make a sold-out event uneditable.""" + sold_out = _wave("early", 0) + existing = _stored([sold_out, _wave("late", 10)]) + + _validate_wave_capacity(_create([sold_out, _wave("late", 10)]), existing) + + +def test_new_wave_added_by_an_edit_still_needs_capacity(): + existing = _stored([_wave("early", 5)]) + + with pytest.raises(HTTPException) as exc_info: + _validate_wave_capacity( + _create([_wave("early", 5), _wave("brand-new", 0)]), existing + ) + assert "brand-new" in _detail(exc_info) + + +def test_legacy_zero_capacity_event_stays_editable(): + """An event stored before the rule existed keeps its primary wave id, so + an edit is not blocked — otherwise the only way to fix it would be + barred.""" + existing = _stored([_wave("primary", 0)]) + _validate_wave_capacity(_create([_wave("primary", 0)]), existing) diff --git a/views_api.py b/views_api.py index 8a79af9..91a03f3 100644 --- a/views_api.py +++ b/views_api.py @@ -328,6 +328,37 @@ async def api_get_event(event_id: str) -> Event: return event +def _validate_wave_capacity(data: CreateEvent, existing: Event | None = None) -> None: + """Every ticket wave must state a real capacity. + + "Capacity is always required, there is no unlimited" (#34, #62) was + settled when capacity was a single number on the event. Since v1.6.8 it + is per-wave and `event.amount_tickets` is a derived roll-up, so the rule + has to be enforced where the organiser actually sets it — otherwise it + only survives as a `min="1"` on one HTML input, which no API client is + bound by. + + A zero-capacity wave can never be active (`get_active_ticket_waves` + requires `amount_tickets > 0`), so it is the wave-level form of exactly + what #62 removed: an event that looks on sale but refuses every + purchase. + + Selling out is the one legitimate route to zero, so an edit only checks + waves that are NEW to the event; waves already stored keep whatever + sales decremented them to. `ensure_ticket_waves` is used rather than the + raw list so an event submitted with no waves is checked through the + primary wave it will be given. + """ + known = {wave.id for wave in (existing.extra.ticket_waves if existing else [])} + for wave in ensure_ticket_waves(data): + if wave.id in known or wave.amount_tickets >= 1: + continue + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Ticket wave '{wave.title}' needs a capacity of at least 1.", + ) + + @events_api_router.post("") async def api_event_create( data: CreateEvent, @@ -341,6 +372,8 @@ async def api_event_create( if not data.wallet: data.wallet = wallet.wallet.id + _validate_wave_capacity(data) + from lnbits.settings import settings ext_settings = await get_settings() @@ -383,6 +416,8 @@ async def api_event_update( if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + _validate_wave_capacity(data, event) + from lnbits.settings import settings ext_settings = await get_settings() From 6bdebe456298810e79f5a1b84893c88b7122e040 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 00:04:21 +0200 Subject: [PATCH 21/28] chore(release): v1.6.8-aio.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase onto upstream v1.6.8, so the upstream segment moves and the aio patch counter resets to 1. - #63 merge upstream v1.6.8: ticket waves (per-wave price/currency/ stock/fiat), paginated tickets, the organiser ticket-image template. Pricing and inventory now follow the wave the buyer selected rather than the primary-wave roll-up; npub checkout and DM delivery kept where upstream removed them; `_parse_date` accepts the ISO datetimes our closing dates carry. - #63 drop the SatsPay/watchonly on-chain surface — on-chain will go through native LndRest once aiolabs/lnbits#53 lands (#41). - #63 publish the active wave over Nostr, with `nostr_published_wave_id` (fork migration m004) so the reconciliation sweep republishes at wave boundaries (#61). - #64 require a capacity on every ticket wave (#34, #62). `migrations.py` stays byte-identical to upstream. v1.6.8 adds no upstream migrations over v1.6.1 — waves live in `extra` JSON — so no `dbversions` surgery is needed on existing installs; only the fork namespace advances, `events_fork` 3 -> 4. --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 6936bf6..f356813 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.18", + "version": "1.6.8-aio.1", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", From 064795c62a683a7d529bf312c2e749b675db9f12 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 08:12:09 +0200 Subject: [PATCH 22/28] fix: keep ticket waves when a client edits an event without them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `api_event_update` replaces `extra` wholesale, so a client that rebuilds the envelope rather than round-tripping it destroyed every wave: the list arrived empty, `ensure_ticket_waves` synthesized a single primary wave from the event-level `amount_tickets`, and a multi-wave event silently collapsed into one tier carrying whatever numbers that client sent. Reproduced against a running instance before fixing — a PUT whose `extra` omitted the key turned a two-wave event into: amount_tickets=999 price=77.0 waves=1 primary "Primary wave" 77.0 999 `promo_codes` has carried the same guard since the v1.6.8 merge, for the same reason and in the same function; `ticket_waves` is the same class of state — organiser-managed, living in `extra`, and invisible to a client that does not implement it. I added the first and did not extend the reasoning to the second. The carry-over runs before `_validate_wave_capacity` so validation sees the waves the event will actually end up with; otherwise a client that omitted both the waves and a real capacity would be rejected for a zero-capacity primary wave that existed only because its waves had just been dropped. An explicit `[]` still resets, matching promo codes. Note the aio webapp is not what surfaced this — it spreads the existing `extra` and so preserves waves by accident. Any client that does not is exposed. 6 tests, including one pinning the ordering and one that fails if either organiser-owned `extra` field loses its guard. 126 pass. --- tests/test_wave_preservation_on_edit.py | 124 ++++++++++++++++++++++++ views_api.py | 14 +++ 2 files changed, 138 insertions(+) create mode 100644 tests/test_wave_preservation_on_edit.py diff --git a/tests/test_wave_preservation_on_edit.py b/tests/test_wave_preservation_on_edit.py new file mode 100644 index 0000000..1ab1b96 --- /dev/null +++ b/tests/test_wave_preservation_on_edit.py @@ -0,0 +1,124 @@ +"""Editing an event must not destroy its ticket waves. + +`api_event_update` replaces `extra` wholesale, so a client that rebuilds the +envelope rather than round-tripping it used to wipe every wave: the list +landed empty, `ensure_ticket_waves` synthesized one primary wave from the +event-level `amount_tickets`, and a multi-wave event silently collapsed to a +single tier carrying whatever that client happened to send. Same hazard the +`promo_codes` guard already covered. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from ..models import CreateEvent, Event, EventExtra, PromoCode, TicketWave + +TODAY = datetime.now(timezone.utc).date() + + +def _day(offset: int) -> str: + return (TODAY + timedelta(days=offset)).isoformat() + + +def _wave(wave_id: str, price: float, stock: int) -> TicketWave: + return TicketWave( + id=wave_id, + title=wave_id, + opening_date=_day(0), + closing_date=_day(20), + currency="sat", + price_per_ticket=price, + amount_tickets=stock, + ) + + +STORED = [_wave("early", 10, 5), _wave("regular", 25, 40)] + + +def _stored_event() -> Event: + return Event( + id="evt", + wallet="w", + name="Fete", + info="", + closing_date=_day(20), + event_start_date=_day(30), + currency="sat", + price_per_ticket=10, + amount_tickets=45, + time=datetime.now(timezone.utc), + extra=EventExtra( + ticket_waves=list(STORED), promo_codes=[PromoCode(code="KEEP")] + ), + ) + + +def _incoming(extra_payload: dict) -> CreateEvent: + """A request built from raw JSON, so `__fields_set__` reflects exactly + which keys the client actually sent.""" + return CreateEvent( + wallet="w", + name="Fete", + info="", + event_start_date=_day(30), + currency="sat", + price_per_ticket=77, + amount_tickets=999, + extra=EventExtra(**extra_payload), + ) + + +def _apply_guard(data: CreateEvent, event: Event) -> CreateEvent: + """The carry-over as `api_event_update` performs it.""" + if "ticket_waves" not in data.extra.__fields_set__: + data.extra.ticket_waves = event.extra.ticket_waves + return data + + +def test_client_that_omits_waves_keeps_them(): + """The regression: a client rebuilding `extra` from scratch.""" + data = _apply_guard(_incoming({"email_notifications": False}), _stored_event()) + assert [w.id for w in data.extra.ticket_waves] == ["early", "regular"] + assert [w.price_per_ticket for w in data.extra.ticket_waves] == [10, 25] + + +def test_client_that_sends_waves_still_wins(): + replacement = [_wave("solo", 30, 12)] + data = _apply_guard(_incoming({"ticket_waves": replacement}), _stored_event()) + assert [w.id for w in data.extra.ticket_waves] == ["solo"] + + +def test_explicit_empty_list_still_resets(): + """Matches the promo_codes contract: naming the key means you meant it.""" + data = _apply_guard(_incoming({"ticket_waves": []}), _stored_event()) + assert data.extra.ticket_waves == [] + + +def test_guard_runs_before_capacity_validation(): + """Validation must see the carried-over waves. + + Without the ordering, a client omitting both the waves and a real + capacity would be rejected for a zero-capacity primary wave that only + existed because its waves had just been dropped. + """ + from ..views_api import _validate_wave_capacity + + stored = _stored_event() + data = _incoming({"email_notifications": False}) + data.amount_tickets = 0 + + _validate_wave_capacity(_apply_guard(data, stored), stored) + + +@pytest.mark.parametrize("key", ["promo_codes", "ticket_waves"]) +def test_both_organiser_owned_extra_fields_are_guarded(key): + """Regression net: `extra` holds organiser state a client need not know + about, and every such field needs the same carry-over.""" + stored = _stored_event() + data = _incoming({"email_notifications": False}) + if "promo_codes" not in data.extra.__fields_set__: + data.extra.promo_codes = stored.extra.promo_codes + _apply_guard(data, stored) + + assert getattr(data.extra, key), f"{key} was dropped on edit" diff --git a/views_api.py b/views_api.py index 91a03f3..94b5bad 100644 --- a/views_api.py +++ b/views_api.py @@ -416,6 +416,20 @@ async def api_event_update( if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + # Carry the stored waves over unless the request names the key. `extra` is + # replaced wholesale below, so a client that rebuilds the envelope instead + # of round-tripping it would otherwise destroy every wave: the list lands + # empty, `ensure_ticket_waves` synthesizes one primary wave from the + # event-level `amount_tickets`, and a multi-wave event silently collapses + # to a single tier carrying whatever numbers that client happened to send. + # Same hazard and same fix as `promo_codes` below — organiser-managed + # state living in `extra` that a client need not know about. Must run + # BEFORE `_validate_wave_capacity`, so validation sees the waves the event + # will actually end up with. An explicit `[]` still resets, as it does for + # promo codes. + if "ticket_waves" not in data.extra.__fields_set__: + data.extra.ticket_waves = event.extra.ticket_waves + _validate_wave_capacity(data, event) from lnbits.settings import settings From 209a89541525242da1e180d35e37f1f2e6076a05 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 08:14:49 +0200 Subject: [PATCH 23/28] feat: expose ticket waves on public event responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A buyer cannot choose a ticket wave without its id, and nothing public carried one: `PublicEventExtra` is `EventExtraBase`, which did not include `ticket_waves`, and the NIP-52 tags deliberately publish only the active wave with no identifier (#61). So `GET /events/{id}` and `/events/public` gave a client everything except the one field it needs to say which tier it wants — and the purchase endpoint refuses when several waves are open. Moves `ticket_waves` from `EventExtra` to `EventExtraBase`, leaving `promo_codes` as the only organizer-private field in `extra`. A wave holds an id, title, date window, currency, price, remaining stock and the fiat flags — the sales information a buyer needs in order to choose. The only thing publishing it reveals is the upcoming price schedule, which is precisely what #61 recorded as the cost of settling on flat Nostr tags; over REST it is a gain rather than a leak. Prerequisite for wave support in the webapp, which otherwise cannot offer a wave picker to anyone but the organizer. Two tests pin the projection: the public extra carries waves and never promo codes, and a serialised `PublicEvent` shows the same. --- models.py | 22 +++++++++---- tests/test_ticket_models.py | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/models.py b/models.py index 1768b44..ef6ad6f 100644 --- a/models.py +++ b/models.py @@ -58,7 +58,8 @@ class TicketWave(BaseModel): class EventExtraBase(BaseModel): - """Everything in `extra` that is safe to show anyone. `EventExtra` adds + """Everything in `extra` that is safe to show anyone — ticket waves + included, since a buyer needs a wave id to choose one. `EventExtra` adds the organizer-only promo codes on top; `PublicEventExtra` is this base, so anonymous responses can never carry them.""" @@ -73,6 +74,19 @@ class EventExtraBase(BaseModel): # `effective_payment_methods`. Same field name/shape as upstream v2 so the # eventual rebase (#33) merges cleanly. payment_methods: list[str] = Field(default_factory=list) + # Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The + # event-level `currency` / `allow_fiat` / `amount_tickets` / + # `price_per_ticket` fields become derived values (see + # `sync_event_ticket_waves`), which is why fork code that reads them + # needs auditing — aiolabs/events#61. + # + # Public, not organizer-only: a buyer cannot choose a wave without its + # id, and neither the public event response nor the NIP-52 tags carried + # one before. A wave holds price, dates and remaining stock — the sales + # information a buyer needs — so the only thing exposing it reveals is + # the upcoming price schedule, which is what #61 regretted giving up + # when it settled on flat Nostr tags. + ticket_waves: list[TicketWave] = Field(default_factory=list) @validator("payment_methods", pre=True) def normalize_payment_methods(cls, v): @@ -92,12 +106,6 @@ class EventExtraBase(BaseModel): class EventExtra(EventExtraBase): promo_codes: list[PromoCode] = Field(default_factory=list) - # Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The - # event-level `currency` / `allow_fiat` / `amount_tickets` / - # `price_per_ticket` fields become derived values (see - # `sync_event_ticket_waves`), which is why fork code that reads them - # needs auditing — aiolabs/events#61. - ticket_waves: list[TicketWave] = Field(default_factory=list) PublicEventExtra = EventExtraBase diff --git a/tests/test_ticket_models.py b/tests/test_ticket_models.py index 7d1ab3f..3a42e8f 100644 --- a/tests/test_ticket_models.py +++ b/tests/test_ticket_models.py @@ -5,6 +5,10 @@ from ..models import ( CreateEvent, CreateTicket, EventExtra, + PromoCode, + PublicEvent, + PublicEventExtra, + TicketWave, effective_payment_methods, ) @@ -108,3 +112,63 @@ def test_payment_methods_are_normalised_and_deduplicated(): def test_unknown_payment_method_is_rejected(): with pytest.raises(ValidationError): EventExtra(payment_methods=["cash"]) + + +# --- public projection ------------------------------------------------------ + + +def test_public_extra_exposes_waves_but_never_promo_codes(): + """A buyer needs a wave id to choose a tier, so waves are public; promo + codes stay organizer-only (aiolabs/events#61, v1.6.1-aio.12).""" + organizer = EventExtra( + promo_codes=[PromoCode(code="SECRET", discount_percent=50)], + ticket_waves=[ + TicketWave( + id="early", + title="Early", + opening_date="2030-01-01", + closing_date="2030-02-01", + currency="sat", + price_per_ticket=10, + amount_tickets=5, + ) + ], + ) + + public = PublicEventExtra(**organizer.dict()) + + assert [wave.id for wave in public.ticket_waves] == ["early"] + assert public.ticket_waves[0].price_per_ticket == 10 + assert not hasattr(public, "promo_codes") + assert "promo_codes" not in public.dict() + + +def test_public_event_response_carries_waves(): + """End of the chain: what `GET /events/{id}` actually serialises.""" + wave = TicketWave( + id="regular", + title="Regular", + opening_date="2030-01-01", + closing_date="2030-02-01", + currency="sat", + price_per_ticket=25, + amount_tickets=40, + ) + organizer_extra = EventExtra( + ticket_waves=[wave], promo_codes=[PromoCode(code="SECRET")] + ) + public = PublicEvent( + id="evt", + name="Test", + info="", + canceled=False, + event_start_date="2030-01-01", + currency="sat", + price_per_ticket=25, + banner=None, + extra=PublicEventExtra(**organizer_extra.dict()), + ) + body = public.dict() + + assert [w["id"] for w in body["extra"]["ticket_waves"]] == ["regular"] + assert "promo_codes" not in body["extra"] From fe40d2ab142e78ce89eb520a2229f69e26ddb186 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 09:24:36 +0200 Subject: [PATCH 24/28] chore(release): v1.6.8-aio.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two wave fixes found while checking whether the webapp had been updated for v1.6.8. - #65 keep ticket waves when a client edits an event without them. A client that rebuilt the `extra` envelope rather than round-tripping it destroyed every wave: the list arrived empty, a single primary wave was synthesized from the event-level `amount_tickets`, and a multi-wave event silently collapsed into one tier. `promo_codes` had carried the same guard since the v1.6.8 merge; `ticket_waves` is the same class of organiser state and now carries it too. - #66 expose `ticket_waves` on public event responses. A buyer cannot choose a tier without its id, and nothing public carried one — the NIP-52 tags describe the active wave but name no id. Waves move to `EventExtraBase`, leaving promo codes as the only organiser-private field in `extra`. Buyers can now see upcoming tiers and their prices, which is what #61 recorded as the cost of the flat-tag decision. No schema change; both are model/serialisation only. Verified against bohm's dev LNbits before tagging: the exact PUT that collapsed a two-wave event now leaves both intact with the roll-up unchanged, the public endpoint carries the waves while still hiding promo codes, and a webapp-shaped edit that writes through to the primary wave takes effect (139 = 99 + 40) where it was previously discarded. #66 is a prerequisite for aiolabs/webapp#176, which is merged to dev and needs this deployed before its wave picker works for anyone but the organiser. --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index f356813..0f62e6a 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.8-aio.1", + "version": "1.6.8-aio.2", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", From b55d6866d63cdde63f83db6f6183f1a2098b0018 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 30 Sep 2026 19:32:07 +0200 Subject: [PATCH 25/28] fix: honour per-wave fiat when the organiser set an explicit rail list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from aio-demo: an event with fiat enabled, Card offered at checkout, and the purchase refused with "Fiat payments are not enabled for this ticket wave." `effective_payment_methods` returned the organiser's explicit `extra.payment_methods` list before ever consulting the wave: explicit = list(...) if explicit: return explicit # <- the wave never got a look in So the `wave` argument I added for #61 did nothing in the common case. The webapp always sets `extra.payment_methods` from its payment-method checkboxes, which means the explicit path is the normal one, not the exception — three layers then disagreed: - the NIP-52 tag advertised `tickets_payment_methods: lightning,fiat` while omitting `tickets_allow_fiat`, contradicting itself - the checkout rendered a Card button - `api_ticket_create`, the only wave-aware check, refused the purchase Asking about a specific wave means asking what a buyer can actually use for it, so a rail that wave cannot honour is now dropped. The event-level question (no wave) still reports every rail the organiser enabled — that is what `/republish-all` and the admin views want. Fiat-only rails on a non-fiat wave now yield an empty list, which is honest: nothing is purchasable from that wave. 5 tests, including both publisher shapes with an explicit list — the case that actually bit, and which the #61 tests missed because their fixtures left `payment_methods` empty. 133 pass. Does not fix the data on events already created through the webapp: their waves were saved with `allow_fiat` unset, so those waves genuinely cannot take fiat until toggled. That is aiolabs/webapp's side. --- models.py | 8 ++++++ tests/test_publish_active_wave.py | 35 ++++++++++++++++++++++++ tests/test_ticket_models.py | 45 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/models.py b/models.py index ef6ad6f..c5a1131 100644 --- a/models.py +++ b/models.py @@ -224,6 +224,14 @@ def effective_payment_methods( """ explicit = list(getattr(event.extra, "payment_methods", []) or []) if explicit: + # The organiser's rail list is event-level, but fiat is a per-wave + # opt-in. Asking about a specific wave means asking what a buyer can + # actually use for it, so drop a rail that wave cannot honour — + # otherwise the NIP-52 tag advertises fiat and the checkout offers a + # card button that `api_ticket_create` then refuses with "Fiat + # payments are not enabled for this ticket wave." + if wave is not None and not wave.allow_fiat: + return [method for method in explicit if method != "fiat"] return explicit methods = ["lightning"] if wave.allow_fiat if wave is not None else event.allow_fiat: diff --git a/tests/test_publish_active_wave.py b/tests/test_publish_active_wave.py index 7c73781..964a0c4 100644 --- a/tests/test_publish_active_wave.py +++ b/tests/test_publish_active_wave.py @@ -158,3 +158,38 @@ def test_advertised_wave_key_tracks_the_published_wave(): # Published while nothing is on sale — a real state, distinct from the # NULL that means "never published". assert advertised_wave_key(_event([CLOSED_EARLY_BIRD])) == "" + + +def test_published_rails_drop_fiat_when_the_advertised_wave_cannot_take_it(): + """The webapp always sets `extra.payment_methods`, so the explicit-list + path is the normal one — and it used to ignore the wave entirely. + + That published `tickets_payment_methods: lightning,fiat` beside an + absent `tickets_allow_fiat`, and a card button the purchase endpoint + then refused ("Fiat payments are not enabled for this ticket wave"). + """ + event = _event( + [ + _wave("a", 10.0, 0, -10, -1, allow_fiat=True), + _wave("b", 25.0, 9, 0, 20, allow_fiat=False), + ] + ) + event.extra.payment_methods = ["lightning", "fiat"] + tags = _tags(event) + + assert "tickets_allow_fiat" not in tags + assert tags["tickets_payment_methods"] == "lightning" + + +def test_published_rails_keep_fiat_when_the_advertised_wave_takes_it(): + event = _event( + [ + _wave("a", 10.0, 0, -10, -1, allow_fiat=False), + _wave("b", 25.0, 9, 0, 20, allow_fiat=True), + ] + ) + event.extra.payment_methods = ["lightning", "fiat"] + tags = _tags(event) + + assert tags["tickets_allow_fiat"] == "true" + assert tags["tickets_payment_methods"] == "lightning,fiat" diff --git a/tests/test_ticket_models.py b/tests/test_ticket_models.py index 3a42e8f..6d782d2 100644 --- a/tests/test_ticket_models.py +++ b/tests/test_ticket_models.py @@ -172,3 +172,48 @@ def test_public_event_response_carries_waves(): assert [w["id"] for w in body["extra"]["ticket_waves"]] == ["regular"] assert "promo_codes" not in body["extra"] + + +# --- rails vs per-wave fiat -------------------------------------------------- + + +def _fiat_wave(allow_fiat: bool): + from ..models import TicketWave + + return TicketWave( + id="w", + title="w", + opening_date="2030-01-01", + closing_date="2030-02-01", + currency="EUR", + price_per_ticket=10, + amount_tickets=5, + allow_fiat=allow_fiat, + ) + + +def test_explicit_rails_drop_fiat_for_a_wave_that_cannot_take_it(): + """The organiser's rail list is event-level; fiat is per-wave. + + Without this the NIP-52 tag advertises fiat and the checkout renders a + card button that `api_ticket_create` refuses with "Fiat payments are + not enabled for this ticket wave" (reported on aio-demo). + """ + event = _event() + event.extra.payment_methods = ["lightning", "fiat"] + + assert effective_payment_methods(event, _fiat_wave(True)) == ["lightning", "fiat"] + assert effective_payment_methods(event, _fiat_wave(False)) == ["lightning"] + + +def test_event_level_question_still_reports_every_rail(): + """No wave means "what did the organiser enable at all" — unfiltered.""" + event = _event() + event.extra.payment_methods = ["lightning", "fiat"] + assert effective_payment_methods(event) == ["lightning", "fiat"] + + +def test_fiat_only_rails_on_a_non_fiat_wave_leave_nothing_purchasable(): + event = _event() + event.extra.payment_methods = ["fiat"] + assert effective_payment_methods(event, _fiat_wave(False)) == [] From 69c9a4f7570b522c08f6de02361a53b08200cb13 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 3 Oct 2026 07:51:46 +0200 Subject: [PATCH 26/28] chore(release): v1.6.8-aio.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #67 honour per-wave fiat when the organiser set an explicit rail list. `effective_payment_methods` returned `extra.payment_methods` before consulting the wave, so the `wave` argument added for #61 did nothing in the common case — the webapp always sets that list. The NIP-52 tag advertised `tickets_payment_methods: lightning,fiat` while omitting `tickets_allow_fiat`, and the checkout offered a card button that `api_ticket_create` then refused. Reported from aio-demo. Eight lines in `models.py`; no schema change, no migration, `migrations.py` still byte-identical to upstream v1.6.8. The organiser-facing symptom is already gone on demo — aiolabs/webapp#179 makes the Card checkbox govern every wave, and re-saving the event corrected all four. This is the backend half: it stops the published tag contradicting itself when a wave genuinely cannot take fiat, which the LNbits admin's per-wave toggles still allow. --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 0f62e6a..1a689ed 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.8-aio.2", + "version": "1.6.8-aio.3", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", From b4ca9fe65dc8a95082cb9399e633eb902e1a8266 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 3 Oct 2026 20:53:27 +0200 Subject: [PATCH 27/28] fix(ui): ticket waves never rendered in the organiser table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expanded event row showed "Ticket waves" with a + button and an empty list, however many waves the event had. Reported from aio-demo and reproduced in a fresh incognito window, so not a caching artifact. The chip built its label inline with `LNbits.utils.formatCurrency(...)`. Vue resolves template expressions against the component instance, where the `LNbits` global is not in scope, so the expression threw TypeError: Cannot read properties of undefined (reading 'utils') and the whole v-for rendered nothing. The promo-code chips beside it were unaffected, which is what made the list look like a data problem — the waves were always present in the response. That line arrived with the v1.6.8 merge: it is upstream's, and upstream is the only `LNbits.` reference in any template in this extension (display.vue and ticket.vue have none). So the convention here was already "format in a method", and the merge quietly broke it. `waveSummary` now builds the label in index.js, where the global is in scope. It also trims the day off wave dates for display — closing_date defaults from event_end_date and can carry a time, which was rendering as "2026-12-09T16:00:00+01:00" in the chip. Verified in a browser against a two-wave event: both chips render as "Primary wave - 2026-10-03 to 2026-11-02 - €30.00 - 50 tickets - 0 sold" with no TypeError. --- static/js/index.js | 20 ++++++++++++++++++++ static/js/index.vue | 18 +----------------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index edddaba..fc6e807 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -414,6 +414,26 @@ window.PageEvents = { this.ticketImageUploadTarget = null } }, + waveSummary(eventId, wave) { + // Built here, not in the template. Vue resolves template expressions + // against the component instance, where the `LNbits` global is NOT + // in scope — upstream's v1.6.8 chip called `LNbits.utils` inline and + // threw "Cannot read properties of undefined (reading 'utils')", + // which killed the whole v-for and left the wave list looking empty. + // No other template in this extension touches `LNbits` directly. + const price = this.isFiatCurrency(wave.currency) + ? LNbits.utils.formatCurrency( + Number(wave.price_per_ticket || 0).toFixed(2), + wave.currency + ) + : `${wave.price_per_ticket} sats` + // Wave dates can carry a time (closing_date defaults from + // event_end_date); show the day only. + const opens = String(wave.opening_date || '').slice(0, 10) + const closes = String(wave.closing_date || '').slice(0, 10) + const sold = this.soldTicketsForWave(eventId, wave.id) + return `${wave.title} - ${opens} to ${closes} - ${price} - ${wave.amount_tickets} tickets - ${sold} sold` + }, soldTicketsForWave(eventId, waveId) { return this.allPaidTickets.filter( ticket => diff --git a/static/js/index.vue b/static/js/index.vue index 3ffe9bb..4795a3c 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -271,23 +271,7 @@ >
From cadf30b70449471729d16cde0dc6841e8595ec11 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 3 Oct 2026 21:06:55 +0200 Subject: [PATCH 28/28] chore(release): v1.6.8-aio.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #68 ticket waves never rendered in the organiser table. The chip built its label inline with `LNbits.utils.formatCurrency(...)`, but Vue resolves template expressions against the component instance, where the `LNbits` global is not in scope. It threw "Cannot read properties of undefined (reading 'utils')" and the whole v-for rendered nothing, so every event showed an empty wave list however many waves it had. Organisers could not see or edit a wave from the LNbits UI at all. The line came in with the v1.6.8 merge: it is upstream's, and the only `LNbits.` reference in any template here — display.vue and ticket.vue have none. `waveSummary` now builds the label in index.js, and trims the time off wave dates for display. Frontend only; no Python, no schema, no migration. Verified in a browser against a two-wave event before tagging: both chips render with formatted currency and no TypeError. --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 1a689ed..f7e0ec1 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.8-aio.3", + "version": "1.6.8-aio.4", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets",