diff --git a/README.md b/README.md index 15748c7..f9b41f4 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,21 @@ ## Sell tickets for events and use the built-in scanner for registering attendees -Events allows you to create tickets for an event. Each ticket is in the form of a unique QR code. After registering and paying, the user gets a QR code to present at registration/entrance. +Events allows you to create events with multiple ticket types and sell tickets. Each ticket is in the form of a unique QR code. After paying, the user gets a QR code to present at registration/entrance. Events includes a shareable ticket scanner, which can be used to register attendees. +### Features + +- **Multi-ticket basket checkout** — buy multiple tickets of different types in one transaction +- **Promo codes** — percentage or fixed discounts, combinable or exclusive +- **Payment methods** — Lightning, on-chain, and fiat (Stripe, PayPal, Square, Revolut) configured per event +- **Ticket types** — multiple ticket tiers with different pricing, availability windows, and max limits +- **Email & Nostr delivery** — automatic ticket link delivery via email or Nostr DM +- **Bulk actions** — resend all ticket emails, send custom messages to all attendees +- **Ticket management** — deactivation, refunds, registration tracking +- **Fiat provider** — credentials configured at LNbits server level, authorized per user + ## Usage 1. Create an event\ diff --git a/__init__.py b/__init__.py index 827b8d5..c91748b 100644 --- a/__init__.py +++ b/__init__.py @@ -4,14 +4,12 @@ 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, qr_api_router, tickets_api_router +from .views_api import events_api_router, qr_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 = [ @@ -33,10 +31,7 @@ def events_stop(): def events_start(): - from lnbits.tasks import create_permanent_unique_task - - task = create_permanent_unique_task("ext_events", wait_for_paid_invoices) - scheduled_tasks.append(task) + pass __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] diff --git a/config.json b/config.json index f8acfca..4d4e701 100644 --- a/config.json +++ b/config.json @@ -1,12 +1,12 @@ { "id": "events", - "version": "1.6.9", + "version": "2.0.0", "name": "Events", "repo": "https://github.com/lnbits/events", "short_description": "Sell and register event tickets", "description": "", "tile": "/events/static/image/events.png", - "min_lnbits_version": "1.5.5", + "min_lnbits_version": "1.5.4", "contributors": [ { "name": "talvasconcelos", @@ -32,6 +32,11 @@ "name": "motorina0", "uri": "https://github.com/motorina0", "role": "Developer" + }, + { + "name": "Nathan Day", + "uri": "https://github.com/dadofsambonzuki", + "role": "Developer" } ], "images": [ @@ -58,5 +63,6 @@ "paid_features": "", "tags": ["Fun & Social", "Ticketing"], "donate": "", - "hidden": false + "hidden": false, + "dependencies": ["satspay"] } diff --git a/crud.py b/crud.py index 930915e..84ef430 100644 --- a/crud.py +++ b/crud.py @@ -5,19 +5,28 @@ from lnbits.helpers import urlsafe_short_hash from .models import ( + Basket, CreateEvent, Event, Ticket, TicketExtra, TicketFilters, - sync_event_ticket_waves, + TicketType, + sync_event_from_ticket_types, ) db = Database("ext_events") async def create_ticket( - payment_hash: str, wallet: str, event: str, name: str, email: str, extra: dict + payment_hash: str, + wallet: str, + event: str, + name: str, + email: str, + extra: dict, + ticket_type_id: str | None = None, + basket_id: str | None = None, ) -> Ticket: now = datetime.now(timezone.utc) ticket = Ticket( @@ -30,6 +39,8 @@ async def create_ticket( paid=False, reg_timestamp=now, time=now, + ticket_type_id=ticket_type_id, + basket_id=basket_id, extra=TicketExtra(**extra) if extra else TicketExtra(), ) await db.insert("events.ticket", ticket) @@ -108,13 +119,11 @@ 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 @@ -125,7 +134,10 @@ async def get_event(event_id: str) -> Event | None: {"id": event_id}, Event, ) - return cast(Event, sync_event_ticket_waves(event)) if event else None + if event: + ticket_types = await get_ticket_types(event_id) + event = cast(Event, sync_event_from_ticket_types(event, ticket_types)) + return event async def get_events(wallet_ids: str | list[str]) -> list[Event]: @@ -136,7 +148,11 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]: f"SELECT * FROM events.events WHERE wallet IN ({q})", model=Event, ) - return [cast(Event, sync_event_ticket_waves(event)) for event in events] + result = [] + for event in events: + ticket_types = await get_ticket_types(event.id) + result.append(cast(Event, sync_event_from_ticket_types(event, ticket_types))) + return result async def delete_event(event_id: str) -> None: @@ -149,3 +165,85 @@ async def get_event_tickets(event_id: str) -> list[Ticket]: {"event": event_id}, Ticket, ) + + +async def get_ticket_types(event_id: str) -> list[TicketType]: + return await db.fetchall( + "SELECT * FROM events.ticket_types WHERE event_id = :event_id" + " ORDER BY sort_order ASC", + {"event_id": event_id}, + TicketType, + ) + + +async def get_ticket_type(ticket_type_id: str) -> TicketType | None: + return await db.fetchone( + "SELECT * FROM events.ticket_types WHERE id = :id", + {"id": ticket_type_id}, + TicketType, + ) + + +async def create_ticket_type(data: TicketType) -> TicketType: + await db.insert("events.ticket_types", data) + return data + + +async def update_ticket_type(data: TicketType) -> TicketType: + await db.update("events.ticket_types", data) + return data + + +async def delete_ticket_type(ticket_type_id: str) -> None: + await db.execute( + "DELETE FROM events.ticket_types WHERE id = :id", {"id": ticket_type_id} + ) + + +async def delete_event_ticket_types(event_id: str) -> None: + await db.execute( + "DELETE FROM events.ticket_types WHERE event_id = :event_id", + {"event_id": event_id}, + ) + + +async def create_basket(data: Basket) -> Basket: + await db.insert("events.baskets", data) + return data + + +async def get_basket(basket_id: str) -> Basket | None: + return await db.fetchone( + "SELECT * FROM events.baskets WHERE id = :id", + {"id": basket_id}, + Basket, + ) + + +async def update_basket(basket: Basket) -> Basket: + await db.update("events.baskets", basket) + return basket + + +async def get_event_baskets(event_id: str) -> list[Basket]: + return await db.fetchall( + "SELECT * FROM events.baskets WHERE event_id = :event_id", + {"event_id": event_id}, + Basket, + ) + + +async def get_basket_tickets(basket_id: str) -> list[Ticket]: + return await db.fetchall( + "SELECT * FROM events.ticket WHERE basket_id = :basket_id", + {"basket_id": basket_id}, + Ticket, + ) + + +async def get_tickets_by_satspay_charge_id(charge_id: str) -> list[Ticket]: + return await db.fetchall( + "SELECT * FROM events.ticket WHERE json_extract(extra, '$.satspay_charge_id') = :charge_id", + {"charge_id": charge_id}, + Ticket, + ) diff --git a/description.md b/description.md index 6e22c7d..0988235 100644 --- a/description.md +++ b/description.md @@ -2,9 +2,14 @@ Sell tickets for events and manage attendee registration with a built-in QR scan Its features include: -- Creating events with ticket pricing +- Creating events with multiple ticket types and pricing - Generating unique QR code tickets after payment - Providing a shareable ticket scanner for check-in - Tracking registered and checked-in attendees +- Multi-ticket basket checkout with promo codes (percentage or fixed discount) +- Payment via Lightning, on-chain, or fiat (Stripe, PayPal, Square, Revolut) +- Email and Nostr notification delivery +- Bulk email actions (resend tickets, custom messages to all attendees) +- Ticket deactivation and refund management A complete ticketing solution for event organizers, meetup hosts, and conference planners who want to sell tickets and manage attendance with Bitcoin. diff --git a/migrations.py b/migrations.py index 512540d..23598d0 100644 --- a/migrations.py +++ b/migrations.py @@ -195,3 +195,104 @@ async def m008_add_fiat_currency(db): ALTER TABLE events.events ADD COLUMN fiat_currency TEXT NOT NULL DEFAULT 'GBP'; """) + + +async def m009_ticket_types_and_baskets(db): + from sqlalchemy.exc import OperationalError + + await db.execute(""" + CREATE TABLE IF NOT EXISTS events.ticket_types ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + image_url TEXT, + price REAL NOT NULL DEFAULT 0, + max_tickets INTEGER NOT NULL DEFAULT 0, + sold INTEGER NOT NULL DEFAULT 0, + available_from TEXT NOT NULL, + available_to TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + extra TEXT + ); + """) + await db.execute(""" + CREATE TABLE IF NOT EXISTS events.baskets ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + wallet TEXT NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL, + promo_codes TEXT, + payment_method TEXT, + fiat_provider TEXT, + nostr_identifier TEXT, + refund_address TEXT, + satspay_charge_id TEXT, + paid BOOLEAN NOT NULL DEFAULT FALSE, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """) + for alter in [ + "ALTER TABLE events.events ADD COLUMN admin_email TEXT;", + "ALTER TABLE events.events DROP COLUMN closing_date;", + "ALTER TABLE events.ticket ADD COLUMN ticket_type_id TEXT;", + "ALTER TABLE events.ticket ADD COLUMN basket_id TEXT;", + ]: + try: + await db.execute(alter) + except OperationalError: + pass + + import json + + events_rows = await db.fetchall("SELECT id, extra FROM events.events") + for row in events_rows: + event_id = row["id"] + extra_str = row["extra"] + if not extra_str: + continue + try: + extra = json.loads(extra_str) + except (json.JSONDecodeError, TypeError): + continue + ticket_waves = extra.get("ticket_waves", []) or [] + sort_order = 0 + for wave in ticket_waves: + await db.execute( + """ + INSERT INTO events.ticket_types ( + id, event_id, name, description, + image_url, price, max_tickets, sold, + available_from, available_to, + sort_order, extra + ) + VALUES (:id, :event_id, :name, :description, + :image_url, :price, :max_tickets, :sold, + :available_from, :available_to, + :sort_order, :extra) + """, + { + "id": wave.get("id", ""), + "event_id": event_id, + "name": wave.get("title", "General Admission"), + "description": "", + "image_url": wave.get("ticket_image_id"), + "price": wave.get("price_per_ticket", 0), + "max_tickets": wave.get("amount_tickets", 0), + "sold": 0, + "available_from": wave.get("opening_date", ""), + "available_to": wave.get("closing_date", ""), + "sort_order": sort_order, + "extra": "{}", + }, + ) + sort_order += 1 + + extra.pop("ticket_waves", None) + await db.execute( + "UPDATE events.events SET extra = :extra WHERE id = :id", + {"extra": json.dumps(extra), "id": event_id}, + ) diff --git a/models.py b/models.py index ac0d6b5..02f4c6c 100644 --- a/models.py +++ b/models.py @@ -1,68 +1,86 @@ -from datetime import date, datetime +from datetime import date, datetime, timezone from uuid import uuid4 from fastapi import Query from lnbits.db import FilterModel -from pydantic import BaseModel, EmailStr, Field, validator +from pydantic import BaseModel, Field, root_validator, validator class PromoCode(BaseModel): code: str - discount_percent: float = 0.0 + discount_percent: float | None = None + discount_fixed: int | None = None active: bool = True + combinable: bool = True + max_uses: int | None = None + used_count: int = 0 - # make the promo code uppercase @validator("code") def uppercase_code(cls, v): return v.upper() @validator("discount_percent") def validate_discount_percent(cls, v): - assert 0 <= v <= 100, "Discount must be between 0 and 100." + if v is not None: + assert 0 <= v <= 100, "Discount must be between 0 and 100." return v + @validator("discount_fixed") + def validate_discount_fixed(cls, v): + if v is not None: + assert v >= 0, "Fixed discount must be >= 0." + return v + + @root_validator + def validate_discount_exclusive(cls, values): + percent = values.get("discount_percent") + fixed = values.get("discount_fixed") + if percent is not None and fixed is not None: + values["discount_fixed"] = None + return values + -class TicketWave(BaseModel): +class TicketType(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) + event_id: str + name: str = "General Admission" + description: str = "" + image_url: str | None = None + price: float = Field(default=0, ge=0) + max_tickets: int = Field(default=0, ge=0) + sold: int = 0 + available_from: str + available_to: str + sort_order: int = 0 + extra: dict = Field(default_factory=dict) 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 nostr_notifications: bool = False notification_subject: str = "" notification_body: str = "" - onchain_enabled: bool = False + payment_methods: list[str] = Field(default_factory=list) + ln_wallet_id: str | None = None onchain_wallet_id: str | None = None - onchain_zeroconf: bool = False - onchain_fasttrack: bool = False + fiat_provider: str | None = None class CreateEvent(BaseModel): wallet: str name: str info: str - closing_date: str event_start_date: str event_end_date: str currency: str = "sat" allow_fiat: bool = False fiat_currency: str = "GBP" - amount_tickets: int = Query(..., ge=0) - price_per_ticket: float = Query(..., ge=0) + admin_email: str | None = None + amount_tickets: int = Query(default=0, ge=0) + price_per_ticket: float = Query(default=0, ge=0) banner: str | None = None extra: EventExtra = Field(default_factory=EventExtra) @@ -72,11 +90,11 @@ class Event(BaseModel): wallet: str name: str info: str - closing_date: str canceled: bool = False event_start_date: str event_end_date: str currency: str + admin_email: str | None = None allow_fiat: bool = False fiat_currency: str = "GBP" amount_tickets: int @@ -91,11 +109,11 @@ class PublicEvent(BaseModel): id: str name: str info: str - closing_date: str canceled: bool event_start_date: str event_end_date: str currency: str + admin_email: str | None = None allow_fiat: bool = False fiat_currency: str = "GBP" price_per_ticket: float @@ -107,6 +125,7 @@ class TicketExtra(BaseModel): applied_promo_code: str | None = None ticket_wave_id: str | None = None ticket_wave_title: str | None = None + ticket_type_id: str | None = None sats_paid: int | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -114,14 +133,14 @@ class TicketExtra(BaseModel): email_notification_sent: bool = False nostr_notification_sent: bool = False refunded: bool = False - onchain: bool = False - onchain_address: str | None = None + deactivated: bool = False satspay_charge_id: str | None = None class CreateTicket(BaseModel): name: str - email: EmailStr + email: str | None = None + ticket_type_id: str | None = None ticket_wave_id: str | None = None promo_code: str | None = None refund_address: str | None = None @@ -139,7 +158,9 @@ class Ticket(BaseModel): registered: bool paid: bool time: datetime - reg_timestamp: datetime + reg_timestamp: datetime | None = None + ticket_type_id: str | None = None + basket_id: str | None = None extra: TicketExtra = Field(default_factory=TicketExtra) @@ -161,11 +182,13 @@ class TicketResendResult(BaseModel): class PublicTicket(BaseModel): event: str + event_name: str = "" + ticket_type_name: str = "" name: str registered: bool paid: bool time: datetime - reg_timestamp: datetime + reg_timestamp: datetime | None = None class TicketPaymentRequest(BaseModel): @@ -176,11 +199,12 @@ class TicketPaymentRequest(BaseModel): is_fiat: bool = False onchain_amount_sat: int | None = None satspay_charge_url: str | None = None + basket_id: str | None = None class TicketFilters(FilterModel): - __search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012 - __sort_fields__ = [ # noqa: RUF012 + __search_fields__ = ["event", "name", "email", "id"] + __sort_fields__ = [ "time", "event", "name", @@ -197,61 +221,93 @@ class TicketFilters(FilterModel): id: str | None = None -def _parse_date(value: str) -> date: - return datetime.strptime(value, "%Y-%m-%d").date() +class BasketItem(BaseModel): + ticket_type_id: str + quantity: int = 1 -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 +class CreateBasket(BaseModel): + name: str + email: str | None = None + items: list[BasketItem] + promo_codes: list[str] = Field(default_factory=list) + payment_method: str | None = None + fiat_provider: str | None = None + nostr_identifier: str | None = None + refund_address: str | None = None - 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, - ) - ] +class BasketDiscount(BaseModel): + code: str + discount_percent: float | None = None + discount_fixed: int | None = None + amount_saved: float = 0 -def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent: - ticket_waves = ensure_ticket_waves(event) - event.extra.ticket_waves = ticket_waves +class BasketTotals(BaseModel): + subtotal: float = 0 + discount: float = 0 + total: float = 0 + discounts_applied: list[BasketDiscount] = Field(default_factory=list) - 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 +class PromoValidateRequest(BaseModel): + codes: list[str] = Field(default_factory=list) + items: list[dict] = Field(default_factory=list) -def get_active_ticket_waves( - event: Event | PublicEvent, today: date | None = None -) -> list[TicketWave]: +class Basket(BaseModel): + id: str + event_id: str + wallet: str + email: str + name: str + promo_codes: list[str] = Field(default_factory=list) + payment_method: str | None = None + fiat_provider: str | None = None + nostr_identifier: str | None = None + refund_address: str | None = None + satspay_charge_id: str | None = None + paid: bool = False + time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class BasketResponse(BaseModel): + basket: Basket + tickets: list[Ticket] + totals: BasketTotals + payment_request: TicketPaymentRequest | None = None + event_name: str = "" + event_image_url: str | None = None + event_currency: str = "sat" + event_fiat_currency: str = "GBP" + + +def _parse_date(value: str) -> date: + return datetime.strptime(value, "%Y-%m-%d").date() + + +def get_active_ticket_types( + ticket_types: list[TicketType], today: date | None = None +) -> list[TicketType]: current_day = today or datetime.utcnow().date() return [ - wave - for wave in ensure_ticket_waves(event) - if _parse_date(wave.opening_date) + tt + for tt in ticket_types + if _parse_date(tt.available_from) <= current_day - <= _parse_date(wave.closing_date) - and wave.amount_tickets > 0 + <= _parse_date(tt.available_to) + and (tt.max_tickets == 0 or tt.sold < tt.max_tickets) ] + + +def sync_event_from_ticket_types( + event: Event | CreateEvent, ticket_types: list[TicketType] +) -> Event | CreateEvent: + if not ticket_types: + return event + + event.amount_tickets = sum(tt.max_tickets for tt in ticket_types) + event.price_per_ticket = ticket_types[0].price + + return event diff --git a/pyproject.toml b/pyproject.toml index 0640a6c..10d2898 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lnbits-events" -version = "0.0.0" +version = "2.0.0" requires-python = ">=3.10,<3.13" description = "LNbits, free and open-source Lightning wallet and accounts system." authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] diff --git a/services.py b/services.py index fbfac2a..7d91ace 100644 --- a/services.py +++ b/services.py @@ -2,6 +2,7 @@ import smtplib from asyncio.tasks import create_task +from datetime import datetime from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from html import escape @@ -10,77 +11,803 @@ 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 +from lnbits.helpers import is_valid_email_address, urlsafe_short_hash from lnbits.settings import settings from lnurl import execute from loguru import logger from .crud import ( + create_basket, + create_ticket, + get_basket, + get_basket_tickets, get_event, get_event_tickets, + get_ticket, + get_ticket_type, + get_ticket_types, purge_unpaid_tickets, + update_basket, update_event, update_ticket, + update_ticket_type, ) from .models import ( + Basket, + BasketDiscount, + BasketTotals, + CreateBasket, Event, + EventExtra, NotificationDeliveryResult, Ticket, TicketResendResult, - ensure_ticket_waves, + TicketType, ) -async def fetch_watchonly_config(api_key: str) -> dict[str, Any]: +def _internal_host() -> str: + return "127.0.0.1" if settings.host in ("0.0.0.0", "::") else settings.host + + +async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config", + resp = await client.post( + url=f"http://{_internal_host()}:{settings.port}/satspay/api/v1/charge", headers={"X-API-KEY": api_key}, + json=data, ) - resp.raise_for_status() + if resp.is_error: + body = "" + try: + body = resp.json().get("detail", resp.text) + except Exception: + body = resp.text or "Unknown error" + raise ValueError(f"SatsPay error: {body}") return resp.json() -async def fetch_watchonly_wallets(api_key: str, network: str) -> list[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/wallet", + url=f"http://{_internal_host()}:{settings.port}/satspay/api/v1/charge/{charge_id}", 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}, +def _effective_discount(promo, price: float) -> float: + discount = 0 + if promo.discount_percent is not None: + discount = price * promo.discount_percent / 100 + if promo.discount_fixed is not None: + discount += promo.discount_fixed + return min(discount, price) + + +def _apply_promo_codes( + promos: list, price: float +) -> tuple[float, list[BasketDiscount]]: + discounts_applied: list[BasketDiscount] = [] + current_price = price + total_discount = 0 + + for promo in promos: + if not promo.active: + continue + if (promo.max_uses is not None + and promo.max_uses > 0 + and promo.used_count >= promo.max_uses): + continue + discount_amount = _effective_discount(promo, current_price) + if discount_amount <= 0: + continue + total_discount += discount_amount + current_price -= discount_amount + discounts_applied.append( + BasketDiscount( + code=promo.code, + discount_percent=promo.discount_percent, + discount_fixed=promo.discount_fixed, + amount_saved=discount_amount, + ) ) - resp.raise_for_status() - return resp.json() + if not promo.combinable: + break + return total_discount, discounts_applied -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}, + +async def _increment_promo_uses(event: Event, promo_codes: list[BasketDiscount]) -> None: + if not promo_codes: + return + applied_codes = {d.code for d in promo_codes} + modified = False + for promo in event.extra.promo_codes: + if promo.code in applied_codes: + promo.used_count += 1 + modified = True + if modified: + await update_event(event) + + +async def calculate_basket_total( + event: Event, items: list[dict], promo_codes: list[str] +) -> BasketTotals: + ticket_types = await get_ticket_types(event.id) + types_by_id = {tt.id: tt for tt in ticket_types} + + subtotal = 0 + for item in items: + tt = types_by_id.get(item["ticket_type_id"]) + if not tt: + continue + subtotal += float(tt.price) * item.get("quantity", 1) + + if event.currency.lower() in ("sat", "sats"): + subtotal = int(subtotal) + + promos = [ + p + for p in event.extra.promo_codes + if p.code in [c.upper() for c in promo_codes] + ] + promos.sort( + key=lambda p: _effective_discount(p, subtotal), + reverse=True, + ) + + total_discount, discounts_applied = _apply_promo_codes(promos, subtotal) + total = max(0, subtotal - total_discount) + + if event.currency.lower() in ("sat", "sats"): + subtotal = int(subtotal) + total_discount = int(total_discount) + total = int(total) + else: + subtotal = round(subtotal, 2) + total_discount = round(total_discount, 2) + total = round(total, 2) + + return BasketTotals( + subtotal=subtotal, + discount=total_discount, + total=total, + discounts_applied=discounts_applied, + ) + + +async def create_basket_with_charge( + event: Event, + data: CreateBasket, + wallet_inkey: str, + base_url: str, +) -> tuple[Basket, list[Ticket], BasketTotals, dict | None]: + today = __import__("datetime").datetime.utcnow().date() + from .models import _parse_date + + ticket_types = await get_ticket_types(event.id) + active_types = [ + tt for tt in ticket_types + if _parse_date(tt.available_from) <= today <= _parse_date(tt.available_to) + and (tt.max_tickets == 0 or tt.sold < tt.max_tickets) + ] + active_ids = {tt.id for tt in active_types} + + for item in data.items: + if item.ticket_type_id not in active_ids: + raise ValueError(f"Ticket type {item.ticket_type_id} is not available.") + tt = next(tt for tt in active_types if tt.id == item.ticket_type_id) + remaining = tt.max_tickets - tt.sold if tt.max_tickets > 0 else float("inf") + if item.quantity > remaining: + raise ValueError( + f"Only {remaining} tickets available for '{tt.name}'." + ) + + totals = await calculate_basket_total( + event, + [{"ticket_type_id": i.ticket_type_id, "quantity": i.quantity} for i in data.items], + data.promo_codes, + ) + + basket_id = urlsafe_short_hash() + promo_code_strings = [c.upper() for c in data.promo_codes] + + basket = Basket( + id=basket_id, + event_id=event.id, + wallet=event.wallet, + email=data.email or '', + name=data.name, + promo_codes=promo_code_strings, + payment_method=data.payment_method, + fiat_provider=data.fiat_provider, + nostr_identifier=data.nostr_identifier, + refund_address=data.refund_address, + ) + + charge = None + tickets: list[Ticket] = [] + + if totals.total > 0: + methods = event.extra.payment_methods or [] + if not methods: + methods = ["ln"] + if event.allow_fiat: + methods.append("fiat") + + internal_base = f"http://{_internal_host()}:{settings.port}" + webhook_url = f"{internal_base}/events/api/v1/baskets/{basket_id}/satspay-webhook" + complete_url = f"{base_url}/events/basket/{basket_id}" + + is_fiat_currency = event.currency and event.currency.lower() not in ("sat", "sats") + + base_data = { + "description": f"Tickets for {event.name}", + "name": data.name or "", + "webhook": webhook_url, + "completelink": complete_url, + "completelinktext": "View your tickets", + "time": 1440, + } + if is_fiat_currency: + base_data["currency"] = event.currency.lower() + base_data["currency_amount"] = float(totals.total) + else: + base_data["amount"] = totals.total + + charge_data = dict(base_data) + if "ln" in methods: + charge_data["lnbitswallet"] = event.extra.ln_wallet_id or event.wallet + if "onchain" in methods: + charge_data["onchainwallet"] = event.extra.onchain_wallet_id + if "fiat" in methods: + charge_data["fiat_provider"] = data.fiat_provider + + charge = None + last_error = None + tried_methods = list(methods) + + while tried_methods and charge is None: + try: + charge = await create_satspay_charge( + api_key=wallet_inkey, + data=charge_data, + ) + except Exception as exc: + last_error = exc + logger.warning(f"SatsPay charge failed with methods {tried_methods}: {exc}") + failed = tried_methods.pop(0) + charge_data = dict(base_data) + if "ln" in tried_methods: + charge_data["lnbitswallet"] = event.extra.ln_wallet_id or event.wallet + if "onchain" in tried_methods: + charge_data["onchainwallet"] = event.extra.onchain_wallet_id + if "fiat" in tried_methods: + charge_data["fiat_provider"] = data.fiat_provider + + if charge is None: + raise ValueError( + f"Could not create payment for any enabled method. " + f"Last error: {last_error}" + ) + + basket.satspay_charge_id = charge["id"] + + await create_basket_from_model(basket) + + for item in data.items: + tt = next(tt for tt in ticket_types if tt.id == item.ticket_type_id) + for _ in range(item.quantity): + ticket_id = urlsafe_short_hash() + ticket = await create_ticket( + payment_hash=ticket_id, + wallet=event.wallet, + event=event.id, + name=data.name, + email=data.email or '', + extra={ + "ticket_type_id": tt.id, + "ticket_wave_title": tt.name, + "refund_address": data.refund_address, + "nostr_identifier": data.nostr_identifier, + "ticket_base_url": base_url, + "sats_paid": totals.total, + "satspay_charge_id": basket.satspay_charge_id, + }, + ticket_type_id=tt.id, + basket_id=basket_id, + ) + tickets.append(ticket) + + if totals.total == 0: + await _activate_basket_tickets(basket, tickets, event) + + return basket, tickets, totals, charge + + +async def create_basket_from_model(basket: Basket) -> Basket: + return await create_basket(basket) + + +async def _activate_basket_tickets( + basket: Basket, tickets: list[Ticket], event: Event +) -> None: + for ticket in tickets: + await set_ticket_paid(ticket) + basket.paid = True + await update_basket(basket) + await _increment_promo_uses_from_basket(event, basket) + _send_basket_notifications_in_background(basket, tickets, event) + if event.admin_email: + create_task(_send_admin_sale_notification(event, basket, tickets)) + + +async def handle_basket_payment( + basket_id: str, wallet_inkey: str +) -> dict | None: + basket = await get_basket(basket_id) + if not basket: + logger.warning(f"Basket {basket_id} not found.") + return None + if basket.paid: + logger.warning(f"Basket {basket_id} already paid.") + return None + if not basket.satspay_charge_id: + raise ValueError("Basket has no SatsPay charge.") + + charge = await get_satspay_charge(wallet_inkey, basket.satspay_charge_id) + if not charge.get("paid"): + logger.warning(f"Charge {basket.satspay_charge_id} not paid.") + return charge + + event = await get_event(basket.event_id) + if not event: + raise ValueError("Event not found for basket.") + + tickets = await get_basket_tickets(basket_id) + await _activate_basket_tickets(basket, tickets, event) + + return charge + + +# --------------------------------------------------------------------------- +# Email formatting helpers +# --------------------------------------------------------------------------- + +_CURRENCY_SYMBOLS = {"GBP": "£", "EUR": "€", "USD": "$", "JPY": "¥"} + + +def _currency_symbol(currency: str) -> str: + return _CURRENCY_SYMBOLS.get(currency.upper(), currency + " ") + + +def _format_price(event: Event, amount: float) -> str: + if event.currency.lower() in ("sat", "sats"): + return f"{int(amount)} sats" + symbol = _currency_symbol(event.fiat_currency or event.currency) + return f"{symbol}{amount:.2f}" + + +def _format_event_dates(event: Event) -> str: + try: + start = datetime.strptime(event.event_start_date, "%Y-%m-%d") + end = datetime.strptime(event.event_end_date, "%Y-%m-%d") + if start == end: + return start.strftime("%A, %d %B %Y") + if start.year == end.year and start.month == end.month: + return f"{start.strftime('%A, %d')} – {end.strftime('%d %B %Y')}" + if start.year == end.year: + return f"{start.strftime('%A, %d %B')} – {end.strftime('%A, %d %B %Y')}" + return f"{start.strftime('%d %B %Y')} – {end.strftime('%d %B %Y')}" + except (ValueError, TypeError): + return f"{event.event_start_date} – {event.event_end_date}" + + +def _ticket_type_name(ticket: Ticket, tt_map: dict) -> str: + if ticket.ticket_type_id and ticket.ticket_type_id in tt_map: + return tt_map[ticket.ticket_type_id].name + return "General Admission" + + +def _ticket_type_price(ticket: Ticket, tt_map: dict) -> float | None: + if ticket.ticket_type_id and ticket.ticket_type_id in tt_map: + return float(tt_map[ticket.ticket_type_id].price) + return None + + +def _display_name(name: str | None) -> str: + return name if name else "Anon" + + +def _grouped_ticket_types(tickets: list[Ticket], tt_map: dict) -> list[tuple[str, int, float]]: + counts: dict[str, dict] = {} + for t in tickets: + tt_name = _ticket_type_name(t, tt_map) + if tt_name not in counts: + price = _ticket_type_price(t, tt_map) or 0 + counts[tt_name] = {"qty": 0, "price": price} + counts[tt_name]["qty"] += 1 + return [(name, d["qty"], d["price"]) for name, d in counts.items()] + + +# --------------------------------------------------------------------------- +# HTML email template builders +# --------------------------------------------------------------------------- + +_EMAIL_BASE_STYLE = ( + "margin:0;padding:0;background:#f0f0f0;" + "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto," + "Helvetica,Arial,sans-serif;" +) +_EMAIL_WRAPPER_STYLE = "background:#f0f0f0;padding:24px 12px;" +_EMAIL_CARD_STYLE = ( + "background:#ffffff;border-radius:10px;overflow:hidden;" + "max-width:600px;margin:0 auto;" + "box-shadow:0 2px 8px rgba(0,0,0,0.08);" +) +_EMAIL_BANNER_STYLE = ( + "display:block;width:100%;max-height:220px;object-fit:cover;" +) +_EMAIL_HEADER_STYLE = ( + "background:#161b22;padding:32px 40px;text-align:center;" +) +_EMAIL_H1_STYLE = "margin:0 0 6px 0;font-size:24px;color:#ffffff;font-weight:700;" +_EMAIL_H2_STYLE = "margin:0 0 4px 0;font-size:18px;color:#161b22;font-weight:600;" +_EMAIL_DATE_STYLE = "margin:0;font-size:14px;color:#8b949e;" +_EMAIL_SECTION_STYLE = "padding:0 40px;" +_EMAIL_BODY_STYLE = "margin:0 0 20px 0;font-size:15px;color:#1f2328;line-height:1.6;" +_EMAIL_CARD_INNER = ( + "background:#f6f8fa;border:1px solid #d0d7de;border-radius:8px;" + "padding:20px;margin-bottom:16px;" +) +_EMAIL_LABEL_STYLE = "margin:0 0 2px 0;font-size:12px;color:#8b949e;text-transform:uppercase;letter-spacing:0.5px;" +_EMAIL_VALUE_STYLE = "margin:0 0 12px 0;font-size:15px;color:#1f2328;" +_EMAIL_BTN_STYLE = ( + "display:inline-block;background:#0969da;color:#ffffff;" + "text-decoration:none;font-size:14px;font-weight:600;" + "padding:10px 28px;border-radius:6px;" +) +_EMAIL_BTN_OUTLINE_STYLE = ( + "display:inline-block;background:transparent;color:#0969da;" + "text-decoration:none;font-size:14px;font-weight:600;" + "padding:10px 24px;border-radius:6px;border:1px solid #0969da;" +) +_EMAIL_FOOTER_STYLE = ( + "background:#f6f8fa;padding:24px 40px;text-align:center;" + "border-top:1px solid #d0d7de;" +) +_EMAIL_FOOTER_P_STYLE = "margin:0;font-size:12px;color:#8b949e;line-height:1.5;" + + +def _email_header_html(event: Event) -> str: + if event.banner: + return ( + f'' + f'' + f'' + f'' + f'

{escape(event.name)}

' + f'

{escape(_format_event_dates(event))}

' + f'' ) - resp.raise_for_status() - return resp.json() + return ( + f'' + f'

{escape(event.name)}

' + f'

{escape(_format_event_dates(event))}

' + f'' + ) -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, +def _email_footer_html() -> str: + base = escape(settings.lnbits_baseurl or "") + return ( + f'' + f'

' + f'Tickets powered by LNbits' + f"

" + f'

' + f'{base}' + f"

" + f"" + ) + + +def _ticket_row_html(ticket: Ticket, tt_map: dict) -> str: + tt_name = escape(_ticket_type_name(ticket, tt_map)) + url = escape(_ticket_url(ticket), quote=True) + return ( + f'
' + f'

{tt_name}

' + f'Open ticket' + f'
' + ) + + +def _order_summary_html( + event: Event, + basket: Basket, + tickets: list[Ticket], + tt_map: dict, + base_url: str, +) -> str: + buyer = escape(_display_name(basket.name)) + buyer_email = escape(basket.email) if basket.email else "—" + basket_url = escape(f"{base_url}/events/basket/{basket.id}", quote=True) + count = len(tickets) + + total = 0.0 + for t in tickets: + p = _ticket_type_price(t, tt_map) + if p is not None: + total += p + total_str = escape(_format_price(event, total)) if total > 0 else "" + + rows = ( + f'

Order ID

' + f'

' + f'{escape(basket.id)}' + f'

' + f'

Buyer

' + f'

{buyer} <{buyer_email}>

' + f'

Tickets

' + f'

{count}

' + ) + if total_str: + rows += ( + f'

Total paid

' + f'

{total_str}

' ) - resp.raise_for_status() - return resp.json() + + return ( + f'
' + f'{rows}' + f'
' + ) + + +def _render_ticket_email_html( + event: Event, + tickets: list[Ticket], + tt_map: dict, + body_text: str, + basket: Basket | None = None, + base_url: str = "", +) -> str: + header = _email_header_html(event) + + body_html = ( + f'' + f'

{escape(body_text).replace(chr(10), "
")}

' + f'' + ) + + tickets_label = "Your ticket" if len(tickets) == 1 else "Your tickets" + ticket_rows = "".join(_ticket_row_html(t, tt_map) for t in tickets) + tickets_html = ( + f'' + f'

{tickets_label}

' + f'
{ticket_rows}
' + f'' + ) + + basket_link_html = "" + if basket and base_url: + basket_url = escape(f"{base_url}/events/basket/{basket.id}", quote=True) + basket_link_html = ( + f'' + f'
' + f'View basket' + f'
' + f'' + ) + + summary_html = "" + if basket and base_url: + summary_html = ( + f'' + f'

Order summary

' + f'
' + f'{_order_summary_html(event, basket, tickets, tt_map, base_url)}' + f'
' + f'' + ) + else: + summary_html = f'' + + footer = _email_footer_html() + + return ( + '' + '' + '' + '' + '
' + '' + + header + + body_html + + tickets_html + + basket_link_html + + summary_html + + footer + + '
' + ) + + +def _render_admin_email_html( + event: Event, + basket: Basket, + tickets: list[Ticket], + tt_map: dict, + base_url: str, +) -> str: + header = _email_header_html(event) + + count = len(tickets) + total = 0.0 + for t in tickets: + p = _ticket_type_price(t, tt_map) + if p is not None: + total += p + total_str = escape(_format_price(event, total)) if total > 0 else "" + + buyer = escape(_display_name(basket.name)) + buyer_email = escape(basket.email) if basket.email else "—" + basket_url = escape(f"{base_url}/events/basket/{basket.id}", quote=True) + + summary_html = ( + f'' + f'
' + f'

Order ID

' + f'

' + f'{escape(basket.id)}' + f'

' + f'

Buyer

' + f'

{buyer} <{buyer_email}>

' + f'

Tickets sold

' + f'

{count}

' + f'

Total

' + f'

{total_str}

' + f'
' + f'' + ) + + grouped = _grouped_ticket_types(tickets, tt_map) + ticket_rows = "" + for tt_name, qty, price in grouped: + price_str = escape(_format_price(event, price * qty)) if price > 0 else "" + ticket_rows += ( + f'' + f'' + f'

{escape(tt_name)}

' + f'' + f'' + f'

×{qty}

' + f'' + f'' + f'

{price_str}

' + f'' + f'' + ) + + tickets_html = ( + f'' + f'

Tickets in this sale

' + f'' + f'' + f'' + f'' + f'' + f'' + f'{ticket_rows}' + f'
Ticket typeQtyTotal
' + f'' + ) + + body_html = ( + f'' + f'

New ticket sale

' + f'

' + f'{count} ticket(s) were just sold for ' + f'{escape(event.name)}.

' + f'' + ) + + footer = _email_footer_html() + + return ( + '' + '' + '' + '' + '
' + '' + + header + + body_html + + summary_html + + tickets_html + + footer + + '
' + ) + + +# --------------------------------------------------------------------------- +# Notification senders +# --------------------------------------------------------------------------- + +async def _send_admin_sale_notification( + event: Event, basket: Basket, tickets: list[Ticket] +) -> None: + if not event.admin_email or not settings.lnbits_email_notifications_enabled: + return + + tt_map: dict[str, TicketType] = {} + tt_ids = {t.ticket_type_id for t in tickets if t.ticket_type_id} + for ttid in tt_ids: + tt = await get_ticket_type(ttid) + if tt: + tt_map[tt.id] = tt + + base_url = _resolve_base_url(tickets) + count = len(tickets) + + grouped = _grouped_ticket_types(tickets, tt_map) + ticket_details = "\n".join( + f"- {name} ×{qty}" + + (f" — {_format_price(event, price * qty)}" if price > 0 else "") + for name, qty, price in grouped + ) + total = sum(price * qty for _, qty, price in grouped) + total_str = _format_price(event, total) if total > 0 else "" + + basket_url = f"{base_url}/events/basket/{basket.id}" + text_message = ( + f"New ticket sale for '{event.name}'\n" + f"{_format_event_dates(event)}\n\n" + f"Order ID: {basket.id}\n" + f"View basket: {basket_url}\n" + f"Buyer: {_display_name(basket.name)} ({basket.email or '—'})\n" + f"Tickets: {count}\n" + f"Total: {total_str}\n\n" + f"{ticket_details}" + ) + subject = f"New sale: {count} ticket(s) for '{event.name}'" + html_message = _render_admin_email_html(event, basket, tickets, tt_map, base_url) + + try: + await _send_ticket_email_notification( + [event.admin_email], text_message, subject, html_message + ) + except Exception as exc: + logger.warning(f"Failed to send admin notification: {exc}") + + +async def _increment_promo_uses_from_basket( + event: Event, basket: Basket +) -> None: + if not basket.promo_codes: + return + applied_codes = set(basket.promo_codes) + modified = False + for promo in event.extra.promo_codes: + if promo.code in applied_codes: + promo.used_count += 1 + modified = True + if modified: + await update_event(event) + + +async def toggle_ticket_deactivation(ticket: Ticket) -> Ticket: + ticket.extra.deactivated = not ticket.extra.deactivated + await update_ticket(ticket) + return ticket async def set_ticket_paid(ticket: Ticket) -> Ticket: @@ -93,25 +820,106 @@ 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 - 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 + + if ticket.ticket_type_id: + ticket_type = await get_ticket_type(ticket.ticket_type_id) + if ticket_type: + ticket_type.sold += 1 + await update_ticket_type(ticket_type) + await update_event(event) return ticket +async def send_bulk_ticket_emails( + event_id: str, base_url: str +) -> list[TicketResendResult]: + tickets = await get_event_tickets(event_id) + results: list[TicketResendResult] = [] + for ticket in tickets: + if not ticket.paid: + continue + try: + result = await resend_ticket_email_notification( + ticket, base_url.rstrip("/") + ) + results.append(result) + except Exception as exc: + logger.warning(f"Failed to resend ticket {ticket.id}: {exc}") + results.append( + TicketResendResult( + ticket=ticket, + email=NotificationDeliveryResult( + attempted=True, sent=False, error=str(exc) + ), + ) + ) + return results + + +async def send_bulk_message( + event_id: str, subject: str, message: str +) -> list[dict]: + tickets = await get_event_tickets(event_id) + results: list[dict] = [] + for ticket in tickets: + if not ticket.email or not ticket.paid: + continue + try: + await _send_ticket_email_notification( + [ticket.email], message, subject + ) + results.append({"ticket_id": ticket.id, "sent": True}) + except Exception as exc: + logger.warning(f"Failed to send message for ticket {ticket.id}: {exc}") + results.append( + {"ticket_id": ticket.id, "sent": False, "error": str(exc)} + ) + return results + + def send_ticket_notification_in_background(ticket: Ticket) -> None: create_task(_send_ticket_notification(ticket)) +def _send_basket_notifications_in_background( + basket: Basket, tickets: list[Ticket], event: Event +) -> None: + create_task(_send_basket_ticket_notifications(basket, tickets, event)) + + +async def _send_basket_ticket_notifications( + basket: Basket, tickets: list[Ticket], event: Event +) -> None: + if not event.extra.email_notifications: + return + if not settings.lnbits_email_notifications_enabled: + return + + tt_map = await _build_tt_map(tickets) + base_url = _resolve_base_url(tickets) + + by_email: dict[str, list[Ticket]] = {} + for ticket in tickets: + if ticket.email: + by_email.setdefault(ticket.email, []).append(ticket) + + for email, email_tickets in by_email.items(): + subject, text_message, html_message = _build_ticket_email( + event, email_tickets, tt_map, basket, base_url + ) + + try: + await _send_ticket_email_notification( + [email], text_message, subject, html_message + ) + for t in email_tickets: + t.extra.email_notification_sent = True + except Exception as exc: + logger.warning(f"Failed to email tickets to {email}: {exc}") + + async def _send_ticket_notification(ticket: Ticket) -> None: event = await get_event(ticket.event) if not event: @@ -137,46 +945,71 @@ async def resend_ticket_email_notification( return await _deliver_ticket_notifications(ticket, event) -def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: - ticket_url = _ticket_url(ticket) +async def _build_tt_map(tickets: list[Ticket]) -> dict[str, TicketType]: + tt_map: dict[str, TicketType] = {} + tt_ids = {t.ticket_type_id for t in tickets if t.ticket_type_id} + for ttid in tt_ids: + tt = await get_ticket_type(ttid) + if tt: + tt_map[tt.id] = tt + return tt_map + + +def _resolve_base_url(tickets: list[Ticket]) -> str: + for t in tickets: + if t.extra.ticket_base_url: + return t.extra.ticket_base_url.rstrip("/") + return (settings.lnbits_baseurl or "").rstrip("/") + + +def _basket_url(basket: Basket, base_url: str) -> str: + return f"{base_url}/events/basket/{basket.id}" + + +def _build_ticket_email( + event: Event, + tickets: list[Ticket], + tt_map: dict, + basket: Basket | None, + base_url: str, +) -> tuple[str, str, str]: subject = ( event.extra.notification_subject.strip() - or f"Your ticket for '{event.name}' is ready" + or (f"Your ticket for '{event.name}' is ready" + if len(tickets) == 1 + else f"Your tickets for '{event.name}' are ready") ) body = ( event.extra.notification_body.strip() - or f"Your ticket for '{event.name}' is ready." + or (f"Your ticket for '{event.name}' is ready." + if len(tickets) == 1 + else f"Your tickets for '{event.name}' are ready.") ) - return subject, f"{body}\n\nOpen it here: {ticket_url}" - - -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 - - return f"{base_message}\n\nTicket image: {ticket_image_url}" + ticket_lines = "\n".join( + f"- {_ticket_type_name(t, tt_map)}" + f"\n Open ticket: {_ticket_url(t)}" + for t in tickets + ) + text_message = ( + f"{body}\n\n" + f"{event.name}\n" + f"{_format_event_dates(event)}\n\n" + f"{ticket_lines}\n" + ) -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 + if basket: + text_message += f"\nView basket: {_basket_url(basket, base_url)}\n" + text_message += f"Order ID: {basket.id}\n" + total = sum(_ticket_type_price(t, tt_map) or 0 for t in tickets) + if total > 0: + text_message += f"Total: {_format_price(event, total)}\n" - return ( - f"{html_message}" - f'

Ticket image

' + html_message = _render_ticket_email_html( + event, tickets, tt_map, body, basket=basket, base_url=base_url ) - -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 @@ -187,7 +1020,16 @@ def _supports_nostr_delivery(identifier: str | None) -> bool: async def _deliver_ticket_notifications( ticket: Ticket, event: Event ) -> TicketResendResult: - subject, text_message, html_message = _ticket_notification_payload(ticket, event) + tt_map = await _build_tt_map([ticket]) + base_url = _resolve_base_url([ticket]) + basket: Basket | None = None + if ticket.basket_id: + basket = await get_basket(ticket.basket_id) + + subject, text_message, html_message = _build_ticket_email( + event, [ticket], tt_map, basket, base_url + ) + updated = False result = TicketResendResult( ticket=ticket, @@ -297,24 +1139,50 @@ def _ticket_url(ticket: Ticket) -> str: 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 preview_ticket_email_html( + event: Event, basket: Basket | None, tickets: list[Ticket], base_url: str +) -> tuple[str, str]: + tt_map = await _build_tt_map(tickets) + subject, text_message, html_message = _build_ticket_email( + event, tickets, tt_map, basket, base_url + ) + return subject, html_message + + +async def preview_admin_email_html( + event: Event, basket: Basket, tickets: list[Ticket], base_url: str +) -> tuple[str, str]: + tt_map = await _build_tt_map(tickets) + count = len(tickets) + grouped = _grouped_ticket_types(tickets, tt_map) + ticket_details = "\n".join( + f"- {name} ×{qty}" + + (f" — {_format_price(event, price * qty)}" if price > 0 else "") + for name, qty, price in grouped + ) + total = sum(price * qty for _, qty, price in grouped) + total_str = _format_price(event, total) if total > 0 else "" + basket_url = f"{base_url}/events/basket/{basket.id}" + text_message = ( + f"New ticket sale for '{event.name}'\n" + f"{_format_event_dates(event)}\n\n" + f"Order ID: {basket.id}\n" + f"View basket: {basket_url}\n" + f"Buyer: {_display_name(basket.name)} ({basket.email or '—'})\n" + f"Tickets: {count}\n" + f"Total: {total_str}\n\n" + f"{ticket_details}" + ) + subject = f"New sale: {count} ticket(s) for '{event.name}'" + html_message = _render_admin_email_html(event, basket, tickets, tt_map, base_url) + return subject, html_message + + async def refund_tickets(event_id: str): - """ - Refund tickets for an event that has not met the minimum ticket requirement. - This function should be called when the event is closed and the minimum ticket - condition is not met. - """ await purge_unpaid_tickets(event_id) tickets = await get_event_tickets(event_id) diff --git a/static/js/basket.js b/static/js/basket.js new file mode 100644 index 0000000..ede7a55 --- /dev/null +++ b/static/js/basket.js @@ -0,0 +1,117 @@ +;window.PageEventsBasket = { + template: '#page-events-basket', + data() { + return { + basketId: null, + basket: null, + tickets: [], + totals: null, + eventName: '', + eventImageUrl: '', + eventCurrency: 'sat', + eventFiatCurrency: 'GBP', + loading: true, + error: null, + pollTimer: null + } + }, + async created() { + this.basketId = this.$route.params.id + await this.loadBasket() + }, + beforeUnmount() { + if (this.pollTimer) { + clearInterval(this.pollTimer) + } + }, + computed: { + basketStatus() { + return this.basket?.paid ? this.$t('events.ticket_paid') : this.$t('events.basket_pending') + }, + buyerName() { + return this.basket?.name || 'Anon' + }, + formattedTotal() { + if (!this.totals) return '-' + const currency = (this.eventCurrency || 'sat').toUpperCase() + if (currency === 'SAT' || currency === 'SATS') { + return `${this.totals.total} sats` + } + return `${Number(this.totals.total).toFixed(2)} ${currency}` + }, + paymentLink() { + if (!this.basket?.satspay_charge_id) return null + return `/satspay/${this.basket.satspay_charge_id}` + }, + ticketSummary() { + const count = this.tickets.length + if (count === 1) return this.$t('events.ticket_count_one') + return this.$t('events.ticket_count_many', {count}) + }, + bannerStyle() { + return { + backgroundImage: `url(${this.eventImageUrl})`, + backgroundSize: 'cover', + backgroundPosition: 'center', + height: '180px' + } + } + }, + methods: { + async loadBasket() { + try { + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/baskets/${this.basketId}` + ) + this.basket = data.basket + this.tickets = data.tickets || [] + this.totals = data.totals || null + this.eventName = data.event_name || data.basket?.event_id || '' + this.eventImageUrl = data.event_image_url || '' + this.eventCurrency = data.event_currency || 'sat' + this.eventFiatCurrency = data.event_fiat_currency || 'GBP' + this.loading = false + this.error = null + + if (this.basket && !this.basket.paid) { + this.startPolling() + } + } catch (error) { + this.loading = false + this.error = LNbits.utils.notifyApiError(error) || 'Error loading basket' + } + }, + ticketName(ticket) { + return ticket.extra?.ticket_wave_title || ticket.name || 'Ticket' + }, + shortenId(id) { + if (!id) return '' + return id.length > 12 ? `${id.slice(0, 6)}...${id.slice(-6)}` : id + }, + startPolling() { + if (this.pollTimer) return + this.pollTimer = setInterval(async () => { + try { + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/baskets/${this.basketId}` + ) + this.basket = data.basket + this.tickets = data.tickets || [] + this.totals = data.totals || null + this.eventName = data.event_name || data.basket?.event_id || '' + this.eventImageUrl = data.event_image_url || '' + this.eventCurrency = data.event_currency || 'sat' + this.eventFiatCurrency = data.event_fiat_currency || 'GBP' + if (data.basket?.paid) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + } catch (error) { + // ignore poll errors + } + }, 3000) + } + } +} diff --git a/static/js/basket.vue b/static/js/basket.vue new file mode 100644 index 0000000..48607f4 --- /dev/null +++ b/static/js/basket.vue @@ -0,0 +1,162 @@ + diff --git a/static/js/display.js b/static/js/display.js index 427d460..950c44d 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -4,77 +4,66 @@ window.PageEventsDisplay = { return { eventErrorLabel: '', event: null, - paymentReq: null, - redirectUrl: null, - formDialog: { - show: false, - data: { - name: '', - email: '', - refund: '', - nostr_identifier: '', - ticket_wave_id: null, - payment_method: 'lightning' - } - }, + ticketTypes: [], + itemQuantities: {}, + basketName: '', + basketEmail: '', + promoCodeInput: '', + discountBreakdown: [], + basketTotal: 0, + applyingPromo: false, + basketItems: [], + submitting: false, + checkoutLoading: false, + nostrIdentifier: '', + attendeeFields: {}, + copyDetailsToAll: {}, ticketLink: { show: false, data: { link: '' } - }, - receive: { - show: false, - status: 'pending', - paymentReq: null, - isFiat: false - }, - paymentDismissMsg: null, - paymentWebsocket: null + } } }, - async created() { + async mounted() { this.eventId = this.$route.params.id - this.event = await this.getEvent() + await this.loadEvent() + if (this.event) { + await this.loadTicketTypes() + } }, computed: { formatDescription() { return LNbits.utils.convertMarkdown(this.event?.info || '') }, - activeTicketWaves() { - const today = new Date().toISOString().slice(0, 10) - return (this.event?.extra?.ticket_waves || []).filter( - wave => - wave.amount_tickets > 0 && - wave.opening_date <= today && - wave.closing_date >= today - ) - }, - selectedTicketWave() { - return ( - this.activeTicketWaves.find( - wave => wave.id === this.formDialog.data.ticket_wave_id - ) || - this.activeTicketWaves[0] || - null - ) - }, - showTicketWaveSelector() { - return this.activeTicketWaves.length > 1 - }, - allowFiatCheckout() { - return Boolean(this.selectedTicketWave?.allow_fiat) + basketCurrency() { + const c = this.event?.currency || 'sat' + return c === 'sat' ? 'sats' : c + }, + basket() { + return this.basketItems + .map(item => { + const tt = this.ticketTypes.find(t => t.id === item.ticketTypeId) + if (!tt) return null + return { + ticketTypeId: tt.id, + name: tt.name, + quantity: item.quantity, + price: tt.price, + subtotal: tt.price * item.quantity + } + }) + .filter(Boolean) }, - fiatCheckoutLabel() { - if (!this.allowFiatCheckout) return 'Fiat' - const unit = ['sat', 'sats'].includes( - (this.selectedTicketWave?.currency || '').toLowerCase() + canCheckout() { + if (this.submitting || this.checkoutLoading) return false + const hasItems = this.basketItems.some( + item => item.quantity > 0 ) - ? this.selectedTicketWave?.fiat_currency - : this.selectedTicketWave?.currency - return this.$t('events.payment_fiat', { - unit: (unit || 'GBP').toUpperCase() - }) + if (!hasItems) return false + if (this.basketEmail && !/^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/.test(this.basketEmail)) return false + return true }, allowEmailNotifications() { return Boolean(this.event?.extra?.email_notifications) @@ -82,202 +71,177 @@ 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: this.$t('events.payment_lightning'), value: 'lightning'} - ] - if (this.allowFiatCheckout) { - options.push({label: this.fiatCheckoutLabel, value: 'fiat'}) + acceptedPaymentMethods() { + const pm = this.event?.extra?.payment_methods || [] + if (!pm.length && this.event?.allow_fiat) { + return ['ln', 'fiat'] } - if (this.allowOnchain) { - options.push({ - label: this.$t('events.payment_bitcoin'), - value: 'onchain' - }) - } - return options + return pm } }, methods: { - async getEvent() { + async loadEvent() { try { const {data} = await LNbits.api.request( '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 + this.event = data } catch (error) { this.eventErrorLabel = this.$t('events.event_unavailable') LNbits.utils.notifyApiError(error) } }, - resetForm(e) { - e.preventDefault() - this.formDialog.data.name = '' - 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' - }, - - closeReceiveDialog() { - if (this.paymentDismissMsg) { - this.paymentDismissMsg() - this.paymentDismissMsg = null - } - if (this.paymentWebsocket) { - this.paymentWebsocket.close() - this.paymentWebsocket = null - } - this.paymentReq = null - this.receive = { - show: false, - status: 'pending', - paymentReq: null, - isFiat: false + async loadTicketTypes() { + try { + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/ticket-types/${this.eventId}` + ) + const today = new Date().toISOString().slice(0, 10) + this.ticketTypes = data.filter( + tt => + (tt.extra?.active ?? true) && + tt.available_from <= today && + tt.available_to >= today && + (tt.max_tickets === 0 || tt.sold < tt.max_tickets) + ) + for (const tt of this.ticketTypes) { + this.itemQuantities[tt.id] = 0 + this.attendeeFields[tt.id] = [] + this.copyDetailsToAll[tt.id] = false + } + } catch (error) { + LNbits.utils.notifyApiError(error) } }, - nameValidation(val) { - const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g - return !regex.test(val) || this.$t('events.name_validation') - }, - emailValidation(val) { - const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/ - return regex.test(val) || this.$t('events.email_validation') - }, - paymentSuccess(paymentHash) { - if (this.paymentDismissMsg) { - this.paymentDismissMsg() - this.paymentDismissMsg = null + addToBasket(tt, qty) { + if (qty <= 0) return + const existing = this.basketItems.find( + item => item.ticketTypeId === tt.id + ) + if (existing) { + existing.quantity = qty + } else { + this.basketItems.push({ticketTypeId: tt.id, quantity: qty}) } - this.paymentReq = null - this.formDialog.data.name = '' - 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', - message: this.$t('events.payment_sent'), - icon: null - }) - this.receive = { - show: false, - status: 'complete', - paymentReq: null, - isFiat: false + this.itemQuantities[tt.id] = qty + this.rebuildBasket() + }, + removeFromBasket(ttId) { + this.basketItems = this.basketItems.filter( + item => item.ticketTypeId !== ttId + ) + this.itemQuantities[ttId] = 0 + this.attendeeFields[ttId] = [] + this.rebuildBasket() + }, + addToBasketBtn(tt) { + const qty = this.itemQuantities[tt.id] || 0 + if (qty <= 0) return + this.addToBasket(tt, qty) + }, + rebuildBasket() { + this.basketItems = this.basketItems.filter( + item => item.quantity > 0 + ) + if (this.basketItems.length === 0) { + this.discountBreakdown = [] + this.basketTotal = 0 + return } - this.ticketLink = { - show: true, - data: { - link: `/events/ticket/${paymentHash}` + let subtotal = 0 + for (const bi of this.basketItems) { + const tt = this.ticketTypes.find(t => t.id === bi.ticketTypeId) + if (tt) { + subtotal += tt.price * bi.quantity } } - window.open(`/events/ticket/${paymentHash}`, '_blank', 'noopener') - }, - async createInvoice() { + this.basketTotal = subtotal + this.discountBreakdown = [] + }, + async applyPromoCode() { + const codes = this.promoCodeInput + .split(',') + .map(c => c.trim().toUpperCase()) + .filter(c => c.length > 0) + if (!codes.length) { + this.rebuildBasket() + return + } + if (!this.basketItems.length) return + this.applyingPromo = true try { + const items = this.basketItems.map(bi => ({ + ticket_type_id: bi.ticketTypeId, + quantity: bi.quantity + })) const {data} = await LNbits.api.request( 'POST', - `/events/api/v1/tickets/${this.eventId}`, + `/events/api/v1/promo/validate/${this.eventId}`, null, - { - 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.showPaymentMethodSelector - ? this.formDialog.data.payment_method - : 'lightning' - } + {codes, items} ) - if (data.satspay_charge_url) { - window.location.href = data.satspay_charge_url - return + this.basketTotal = data.total + this.discountBreakdown = (data.discounts_applied || []).map(d => ({ + label: `${d.code}: ${d.amount_saved} ${this.basketCurrency}` + })) + if (!data.discounts_applied?.length && codes.length) { + this.discountBreakdown = [{label: 'No valid promo codes found'}] } - - const isFiat = Boolean(data.is_fiat) - this.paymentReq = isFiat - ? data.fiat_payment_request || null - : data.payment_request - this.paymentHash = data.payment_hash - - this.paymentDismissMsg = Quasar.Notify.create({ - timeout: 0, - message: this.$t('events.waiting_for_payment') - }) - this.receive = { - show: true, - status: 'pending', - paymentReq: this.paymentReq, - isFiat - } - if (isFiat && this.paymentReq) { - window.open(this.paymentReq, '_blank', 'noopener') - } - this.paymentWatcher(this.paymentHash) } catch (error) { LNbits.utils.notifyApiError(error) + this.rebuildBasket() + } finally { + this.applyingPromo = false } }, - paymentWatcher(paymentHash) { - if (this.paymentWebsocket) { - this.paymentWebsocket.close() - } + + async checkout() { + if (this.checkoutLoading || !this.canCheckout) return + this.checkoutLoading = true + try { + const items = this.basketItems.map(bi => ({ + ticket_type_id: bi.ticketTypeId, + quantity: bi.quantity + })) + const promo_codes = this.promoCodeInput + .split(',') + .map(c => c.trim().toUpperCase()) + .filter(c => c.length > 0) + const refundAddr = (this.event?.extra?.conditional) + ? '' + : undefined - const url = new URL(window.location) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` - url.search = '' - url.hash = '' + const body = { + name: this.basketName, + email: this.basketEmail || null, + items, + promo_codes, + payment_method: null, + fiat_provider: this.event?.extra?.fiat_provider || null, + nostr_identifier: this.nostrIdentifier || null, + refund_address: refundAddr || null + } - const ws = new WebSocket(url.toString()) - this.paymentWebsocket = ws + const {data} = await LNbits.api.request( + 'POST', + `/events/api/v1/baskets/${this.eventId}`, + null, + body + ) - ws.onmessage = event => { - const data = JSON.parse(event.data) - if (data.paid === true) { - this.paymentSuccess(paymentHash) - ws.close() - } - } - ws.onerror = error => { - console.error('WebSocket error:', error) - } - ws.onclose = () => { - if (this.paymentWebsocket !== ws) return - this.paymentWebsocket = null - if (this.receive.show) { - setTimeout(() => { - if (this.receive.show) this.paymentWatcher(paymentHash) - }, 3000) + const firstTicket = data.tickets?.[0] + if (data.basket?.id) { + this.$router.push(`/events/basket/${data.basket.id}`) + } else if (firstTicket) { + this.ticketLink.show = true + this.ticketLink.data.link = `/events/ticket/${firstTicket.id}` } + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.checkoutLoading = false } } } diff --git a/static/js/display.vue b/static/js/display.vue index 17a271f..5704065 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -1,3 +1,4 @@ +# page-events-display