diff --git a/.gitignore b/.gitignore index 0152b6e..1822965 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__ node_modules .mypy_cache .venv +data/ 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..0729680 100644 --- a/crud.py +++ b/crud.py @@ -5,19 +5,29 @@ from lnbits.helpers import urlsafe_short_hash from .models import ( + Basket, + sold_counts, 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 +40,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 +120,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 +135,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 +149,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 +166,111 @@ async def get_event_tickets(event_id: str) -> list[Ticket]: {"event": event_id}, Ticket, ) + + +async def get_ticket_types(event_id: str) -> list[TicketType]: + ticket_types = await db.fetchall( + "SELECT * FROM events.ticket_types WHERE event_id = :event_id" + " ORDER BY sort_order ASC", + {"event_id": event_id}, + TicketType, + ) + # `sold` is DERIVED on every read (see models.sold_counts): the stored + # column under-counts, and the checkout's availability, its `remaining` + # check and the public display filter all trust this value. + counts = sold_counts(await get_event_tickets(event_id)) + for ticket_type in ticket_types: + ticket_type.sold = counts.get(ticket_type.id, 0) + return ticket_types + + +async def get_all_ticket_types() -> list[TicketType]: + """Every ticket type across all events. + + The table is tiny, and a shared cap (``extra.cap_group``) has to be counted + across events: one screening has one set of seats, sold from more than one + event's checkout. + """ + return await db.fetchall("SELECT * FROM events.ticket_types", {}, TicketType) + + +async def count_tickets_by_type(ticket_type_id: str) -> int: + """Committed seats for one ticket type -- same definition as ``sold``.""" + ticket_type = await get_ticket_type(ticket_type_id) + if not ticket_type: + return 0 + counts = sold_counts(await get_event_tickets(ticket_type.event_id)) + return counts.get(ticket_type_id, 0) + + +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..ca7cd32 100644 --- a/migrations.py +++ b/migrations.py @@ -195,3 +195,209 @@ 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}, + ) + + +async def m010_promo_source_axis(db): + """ + Move promo codes onto the offer-code + ticket-requirement axis. + + Three shapes are normalised onto `source: {require_ticket, event_ids, + ticket_type_ids, move, credit}`: + + * pre-axis rows, `type: "code"` -> require_ticket=False + * pre-axis rows, `type: "ticket"` -> require_ticket=True, move=False, + credit="none", event_ids=None (same event only), and the "TICKETID" + sentinel code cleared because it was never a real code + * pre-axis rows with no `type` (hand-written config) -> require_ticket=False + * the intermediate revision's `source.kind` -> `source.require_ticket` + + Handling `kind` as well as `type` makes this safe to re-run over either + revision, which matters because the intermediate one was deployed. + + Promos already on the final axis are left untouched. + """ + import json + + # NOTE: db.fetchall returns SQLAlchemy RowMappings (no model= passed), so + # rows must be read BY COLUMN NAME. Unpacking them (`for a, b in rows`) + # yields the column names instead of the values, and the JSON decode error + # that follows is swallowed below as if it were bad data — which silently + # converts nothing while still recording the migration as applied. + rows = await db.fetchall("SELECT id, extra FROM events.events") + for row in rows: + event_id = row["id"] + raw = row["extra"] + if not raw: + continue + try: + extra = json.loads(raw) + except (TypeError, ValueError): + continue + promos = extra.get("promo_codes") + if not isinstance(promos, list) or not promos: + continue + + changed = False + for promo in promos: + if not isinstance(promo, dict): + continue + source = promo.get("source") + if isinstance(source, dict): + # Already on an axis revision. `kind` was the intermediate + # discriminator; normalise it onto `require_ticket` so this + # migration is safe to re-run over either revision. + if "kind" in source: + is_ticket = source.pop("kind") == "ticket" + source["require_ticket"] = is_ticket + changed = True + else: + is_ticket = bool(source.get("require_ticket", False)) + else: + # Pre-axis rows: `type` was the discriminator, and a missing + # `type` meant "code". + is_ticket = promo.pop("type", None) == "ticket" + promo["source"] = { + "require_ticket": is_ticket, + "event_ids": None, + "ticket_type_ids": None, + "move": False, + "credit": "none", + } + changed = True + if is_ticket and promo.get("code") == "TICKETID": + promo["code"] = "" + changed = True + + if changed: + await db.execute( + "UPDATE events.events SET extra = :extra WHERE id = :id", + {"extra": json.dumps(extra), "id": event_id}, + ) + + +async def m011_basket_total(db): + """ + Add ``Basket.total`` — the amount actually charged for the basket. + + This is a TOP-LEVEL COLUMN, not `extra` JSON config, so adding the field to + the model is not enough: without this ALTER every basket INSERT fails with + "table events.baskets has no column named total" and checkout 500s. + The migration runner only applies versions above the recorded one, so this + runs once on a database that predates the column. + + Existing baskets keep a NULL total; `_recompute_basket_total` reconstructs + it from the tickets when a legacy row needs it. + """ + from sqlalchemy.exc import OperationalError + + # NOTE: no column introspection here. `PRAGMA table_info(events.baskets)` is + # NOT valid SQLite (schema-qualified pragmas are `PRAGMA events.table_info`), + # and it fails as a syntax error that the runner reports and retries forever. + # The runner only applies versions above the recorded one, so a plain guarded + # ALTER (the m003/m009 idiom) is both sufficient and safest. + try: + await db.execute("ALTER TABLE events.baskets ADD COLUMN total REAL;") + except OperationalError: + # Column already present (fresh schema, or a re-run) — nothing to do. + pass diff --git a/models.py b/models.py index ac0d6b5..897db5e 100644 --- a/models.py +++ b/models.py @@ -1,68 +1,241 @@ -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 PromoSource(BaseModel): + """The source-ticket condition attached to a promo. + + Orthogonal to the offer code: a promo identifies itself with ``code`` and + ADDITIONALLY may require an existing ticket. ``require_ticket=False`` is the + classic typed-code promo; ``require_ticket=True`` makes the rule an offer + code that only applies when a valid source ticket is presented too (ticket + upgrades and extra-ticket offers). + + ``event_ids``/``ticket_type_ids`` scope which source tickets qualify (empty + event_ids = the target event only), ``move`` turns it into an upgrade (void + the source and release its seat on payment), and ``credit`` adds the source + ticket's recorded paid value to the discount. + """ + + require_ticket: bool = False + event_ids: list[str] | None = None + ticket_type_ids: list[str] | None = None + move: bool = False + credit: str = "none" + + @validator("credit") + def validate_credit(cls, v): + assert v in ("none", "paid"), "source credit must be 'none' or 'paid'." + return v + + @root_validator + def drop_ticket_condition_without_ticket(cls, values): + # Coerce rather than raise: this JSON is parsed on every read, so a + # stored row with `move`/`credit` but no ticket requirement must not + # make the whole event unloadable. Those flags are meaningless without + # a ticket, so they simply fall away. + if not values.get("require_ticket", False): + values["move"] = False + values["credit"] = "none" + return values + + +class PromoGrant(BaseModel): + """The add-on entitlement a promo may carry INSTEAD of a discount. + + A grant rule authorises the buyer to ADD tickets of ``ticket_type_id`` to + the basket — up to ``per_source`` for every person the source ticket + admits — and only while a qualifying source ticket is present (already in + the basket, or presented by id). This is how a free add-on ticket such as + a Bitflix screening seat is gated: the entitlement is the effect, so no + discount maths is involved. + """ + + ticket_type_id: str + per_source: int = 1 + + @validator("per_source") + def validate_per_source(cls, v): + assert v >= 1, "per_source must be >= 1." + return v class PromoCode(BaseModel): - code: str - discount_percent: float = 0.0 + """A discount rule. + + ``code`` identifies the offer to the buyer and is the primary key for + matching. ``source.require_ticket`` additionally demands a valid existing + ticket, which is what enables upgrades and extra-ticket offers; a ticket + rule may leave ``code`` blank to mean "any eligible ticket activates this". + """ + + code: str = "" + # Human-readable name for the offer, shown in the admin and at checkout. + # A code-less rule (one activated by presenting a ticket) has no identifier + # of its own, so without this it is unidentifiable in the promo list. + title: str = "" + 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 + source: PromoSource = Field(default_factory=PromoSource) + redemptions_per_ticket: int = 1 + # TARGET scope: when set, the discount only applies to the value of the + # listed ticket types (by ticket_type_id). None/empty = basket-wide. + ticket_type_ids: list[str] | None = None + # Max number of tickets a single redemption can cover (None = any number). + # Contrast redemptions_per_ticket (how many times the same source ticket + # can be redeemed). + max_tickets: int | None = None + # Add-on entitlement: when set, this rule GRANTS tickets of another type + # rather than discounting the basket. Requires a source ticket like every + # other ticket rule, and is mutually exclusive with a discount. + grants: PromoGrant | None = None + # Runtime-only: set when a ticket rule is resolved, carrying the credit + # derived from the source ticket. Never persisted as configuration. + credit_value: float | None = None + + @root_validator(pre=True) + def migrate_legacy_axis(cls, values): + # Rows written before the offer-code axis stored `type: "code"|"ticket"` + # and, for ticket rules, the sentinel code "TICKETID"; rows written by + # the intermediate revision stored `source.kind`. Map both onto + # `source.require_ticket` so existing config keeps working. + if not isinstance(values, dict): + return values + + legacy_type = values.pop("type", None) + source = values.get("source") + if isinstance(source, dict) and "kind" in source: + source.setdefault("require_ticket", source.pop("kind") == "ticket") + elif legacy_type is not None and "source" not in values: + values["source"] = {"require_ticket": legacy_type == "ticket"} + + source = values.get("source") + require_ticket = bool( + source.get("require_ticket") if isinstance(source, dict) else False + ) + # The retired sentinel was never a real code. + if require_ticket and (values.get("code") or "") == "TICKETID": + values["code"] = "" + return values - # make the promo code uppercase @validator("code") def uppercase_code(cls, v): return v.upper() + @validator("redemptions_per_ticket") + def validate_redemptions_per_ticket(cls, v): + assert v is not None and v >= 1, "redemptions_per_ticket must be >= 1." + return v + + @validator("max_tickets") + def validate_max_tickets(cls, v): + if v is not None: + assert v >= 1, "max_tickets must be >= 1." + return v + @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 -class TicketWave(BaseModel): + @root_validator + def validate_code_present(cls, values): + # A code is required unless the rule is activated by a ticket alone. + source = values.get("source") + require_ticket = getattr(source, "require_ticket", False) + if not require_ticket and not (values.get("code") or "").strip(): + raise ValueError( + "a promo code is required unless the rule requires a ticket" + ) + return values + + @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 + + @root_validator(pre=True) + def coerce_grant_requires_source(cls, values): + # A grant needs a source ticket to gate on, so it IMPLIES the source + # gate. A discount is deliberately NOT cleared here: a grant rule is + # dropped from the discount engine entirely, so a retained discount is + # inert, and clearing it would silently destroy a value the admin typed + # (see test_grant_keeps_and_ignores_a_discount). + # Coerce rather than raise: this JSON is parsed on every read, so a + # stale row must not unload the event. pre=True so this runs BEFORE + # `validate_code_present`, which would otherwise reject the codeless + # rule such a row leaves behind. + if not isinstance(values, dict) or values.get("grants") is None: + return values + source = values.get("source") + if isinstance(source, dict): + source["require_ticket"] = True + elif source is None: + values["source"] = {"require_ticket": True} + elif not getattr(source, "require_ticket", False): + values["source"] = {**source.dict(), "require_ticket": True} + return values + + +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 +245,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,14 +264,13 @@ 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 banner: str | None extra: EventExtra = Field(default_factory=EventExtra) @@ -107,6 +279,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 +287,24 @@ 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 + ticket_id_voucher_redemptions: list = Field(default_factory=list) + # Amount actually charged for this ticket, in the event's currency. + # Recorded on payment; used as the credit basis when the ticket is later + # upgraded. Deriving credit from what was paid (not the ticket type's face + # price) means a chain of upgrades can never inflate its own value. + paid_value: float | None = None + # Set on a source ticket consumed by a move upgrade. + upgraded_to: str | None = None + # Set on a ticket created by a move upgrade. + upgraded_from: 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 +322,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 +346,15 @@ class TicketResendResult(BaseModel): class PublicTicket(BaseModel): event: str + event_name: str = "" + ticket_type_name: str = "" name: str + email: str | None = None registered: bool paid: bool + deactivated: bool = False time: datetime - reg_timestamp: datetime + reg_timestamp: datetime | None = None class TicketPaymentRequest(BaseModel): @@ -176,11 +365,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 +387,144 @@ 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 PromoRequirement(BaseModel): + """A recognised offer code that still needs more input before it applies. - 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 + Drives the checkout's auto-prompt: the buyer types an offer code, this says + a ticket id is also needed, and the checkout reveals that input. + """ + + code: str + requires: str = "ticket_id" + message: str | None = None + + +class PromoRejection(BaseModel): + """Why a submitted token did not apply. + + Without this the checkout silently returns no discount, leaving the buyer + with no idea whether the code was wrong, the ticket was wrong, or the offer + was simply not applicable. ``reason`` is a stable code for the UI to + translate; never parse ``message``. + """ + + code: str + reason: str + message: str | None = None + + +class BasketTotals(BaseModel): + subtotal: float = 0 + discount: float = 0 + total: float = 0 + discounts_applied: list[BasketDiscount] = Field(default_factory=list) + pending: list[PromoRequirement] = Field(default_factory=list) + rejections: list[PromoRejection] = Field(default_factory=list) + + +class PromoValidateRequest(BaseModel): + codes: list[str] = Field(default_factory=list) + items: list[dict] = Field(default_factory=list) + + +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 + # Amount charged for this basket, in the event's currency, after discounts. + # Recorded at creation and used to stamp per-ticket paid_value on payment. + total: float | None = None + time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - return event +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 get_active_ticket_waves( - event: Event | PublicEvent, today: date | None = None -) -> list[TicketWave]: + +def _parse_date(value: str) -> date: + return datetime.strptime(value, "%Y-%m-%d").date() + + +def sold_counts(tickets: list[Ticket]) -> dict[str, int]: + """Committed seats per ticket type -- DERIVED, never read from the column. + + A seat is committed when its ticket is paid and neither refunded nor + deactivated (an upgrade voids its source ticket, which frees the seat). + ``TicketType.sold`` is incremented by ``set_ticket_paid``, but that returns + early for a ticket which arrives already paid -- an import or a migration -- + so the stored column drifts low and every cap derived from it leaks. + """ + counts: dict[str, int] = {} + for ticket in tickets: + if not ticket.paid or not ticket.ticket_type_id: + continue + extra = ticket.extra + if extra is not None and (extra.refunded or extra.deactivated): + continue + counts[ticket.ticket_type_id] = counts.get(ticket.ticket_type_id, 0) + 1 + return counts + + +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..ca7f50f 100644 --- a/services.py +++ b/services.py @@ -1,86 +1,1600 @@ from __future__ import annotations +import asyncio import smtplib from asyncio.tasks import create_task +from datetime import datetime, timedelta, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from html import escape -from typing import Any +from typing import Any, NamedTuple 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 ( + count_tickets_by_type, + get_all_ticket_types, + create_basket, + create_ticket, + get_basket, + get_basket_tickets, get_event, get_event_tickets, + get_events, + 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, + PromoCode, + PromoRejection, + PromoRequirement, + PromoSource, 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: + """Total discount a promo yields against ``price``. + + A ticket-source promo resolved with ``credit="paid"`` also contributes the + source ticket's recorded paid value. The credit is applied first and the + promo's own percent/fixed value sits on top of the target price; the whole + thing is capped at the base, so an upgrade can become free (cap to free) + but never negative. + """ + discount = 0.0 + credit = getattr(promo, "credit_value", None) + if credit: + discount += float(credit) + 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 _promo_scope(promo) -> list: + """Ticket-type ids a promo is restricted to (empty = basket-wide).""" + return list(getattr(promo, "ticket_type_ids", None) or []) + + +def _apply_promo_codes( + promos: list, type_subtotals: dict, subtotal: float +) -> tuple[float, list[BasketDiscount]]: + """Apply promo codes to a basket. + + ``type_subtotals`` maps ticket_type_id -> value of that type in the basket. + A promo scoped to specific ticket types discounts only against the value of + those types; an unscoped promo discounts against the whole (remaining) + basket. Stacking rule: ticket-type-scoped ("base") promos are applied + first, each against its own base; any basket-wide promos then apply against + what is left, so a combinable scoped code and a combinable basket code + stack predictably. + """ + discounts_applied: list[BasketDiscount] = [] + remaining_by_type: dict = {k: float(v) for k, v in (type_subtotals or {}).items()} + remaining_total = float(subtotal) + total_discount = 0.0 + + def _base(promo) -> float: + scope = _promo_scope(promo) + if not scope: + return max(0.0, remaining_total) + total = 0.0 + for tid in scope: + # Duplicate ids must not double-count. + value = remaining_by_type.get(tid, 0.0) + total += value + return max(0.0, total) + + # Scoped promos first (each against its own base), then basket-wide ones. + scoped = [p for p in promos if _promo_scope(p)] + basket = [p for p in promos if not _promo_scope(p)] + scoped.sort(key=lambda p: _effective_discount(p, _base(p)), reverse=True) + basket.sort(key=lambda p: _effective_discount(p, _base(p)), reverse=True) + + for promo in scoped + basket: + 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 + scope = _promo_scope(promo) + base = _base(promo) + if base <= 0: + continue + # Never discount more than the value of the base the promo targets. + discount_amount = min(_effective_discount(promo, base), base) + if discount_amount <= 0: + continue + total_discount += discount_amount + remaining_total -= discount_amount + if scope: + # Consume the discount from the targeted types, weighted by their + # remaining share, so a later scoped promo on the same types cannot + # discount value already discounted away. + scope_remaining = sum(remaining_by_type.get(tid, 0.0) for tid in scope) + for tid in scope: + cur = remaining_by_type.get(tid, 0.0) + share = (cur / scope_remaining) if scope_remaining > 0 else 0.0 + remaining_by_type[tid] = max(0.0, cur - discount_amount * share) + 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}, + +# --------------------------------------------------------------------------- +# Ticket-ID vouchers ("type": "ticket" promo codes) +# +# A ticket-type promo doesn't use a fixed code string. Instead the buyer +# submits an EXISTING tickets.id as the discount code; it is resolved against +# the tickets table at checkout. Single/multi-use is tracked PER SOURCE TICKET +# in the source ticket's TicketExtra (``ticket_id_voucher_redemptions``), not +# on the promo row, because the "code" is dynamic. A redemption is reserved +# ("pending") at checkout creation and only finalized on payment, so two +# concurrent checkouts can't both consume the last slot. +# --------------------------------------------------------------------------- +_VOUCHER_TTL_SECONDS = 24 * 3600 # matches the satspay charge time (1440 min) + +# Per-SOURCE-TICKET locks so concurrent checkouts cannot both claim the last +# redemption slot of one source ticket. Keyed on the source ticket id rather +# than the target event, because a ticket-source promo may reference a ticket +# that belongs to a different event. +_reservation_locks: dict[str, asyncio.Lock] = {} + + +def _reservation_lock(key: str) -> asyncio.Lock: + if key not in _reservation_locks: + _reservation_locks[key] = asyncio.Lock() + return _reservation_locks[key] + + +def _promo_source(promo) -> PromoSource: + """The promo's ticket condition, tolerating legacy/constructed objects.""" + source = getattr(promo, "source", None) + if isinstance(source, PromoSource): + return source + # construct()'d or pre-axis object: fall back to the legacy discriminators. + legacy_type = getattr(promo, "type", None) + return PromoSource(require_ticket=legacy_type == "ticket") + + +def _requires_ticket(promo) -> bool: + return bool(_promo_source(promo).require_ticket) + + +def _rule_code(promo) -> str: + """The offer code a promo is selected by ("" = activated by a ticket alone).""" + return (getattr(promo, "code", "") or "").upper() + + +def _ticket_rules(event: Event, active_only: bool = True) -> list[PromoCode]: + """Every promo that ALSO requires a source ticket. + + Plural by design: an event may carry any number of these, distinguished by + their offer codes (an upgrade rule, a bring-a-friend rule, ...). This used + to return a single rule, which made offers indistinguishable. + ``active_only=False`` is used when consuming an already-paid basket, so a + promo deactivated mid-checkout still voids its source ticket. + """ + return [ + p for p in event.extra.promo_codes + if _requires_ticket(p) and (p.active or not active_only) + ] + + +def _source_scope_matches(promo, source: Ticket, event: Event) -> bool: + source_axis = _promo_source(promo) + if not source_axis.require_ticket: + return False + # No source events named = this event only (the pre-existing behaviour). + allowed_events = source_axis.event_ids or [event.id] + if source.event not in allowed_events: + return False + if (source_axis.ticket_type_ids + and source.ticket_type_id not in source_axis.ticket_type_ids): + return False + return True + + +async def _resolve_source_ticket( + event: Event, ticket_id: str, promo: PromoCode +) -> tuple[Ticket | None, str | None]: + """Resolve a submitted ticket id against a ticket rule. + + Returns ``(ticket, None)`` on success or ``(None, reason)`` — the reason is + a stable code the checkout can show, instead of failing silently. + """ + if not ticket_id: + return None, "ticket_not_found" + source = await get_ticket(ticket_id) + if not source: + return None, "ticket_not_found" + if not source.paid or source.extra.deactivated or source.extra.refunded: + return None, "ticket_not_eligible" + if source.extra.upgraded_to: + return None, "ticket_already_used" + if not _source_scope_matches(promo, source, event): + return None, "ticket_out_of_scope" + source_event = await get_event(source.event) + if not source_event: + return None, "ticket_not_found" + if (source_event.currency or "").lower() != (event.currency or "").lower(): + return None, "currency_mismatch" + return source, None + + +async def _source_credit(promo, source: Ticket) -> float: + """Credit contributed by the source ticket, in the target event's currency.""" + if _promo_source(promo).credit != "paid": + return 0.0 + value = source.extra.paid_value + if value is None and source.ticket_type_id: + # Tickets sold before paid_value was recorded fall back to the ticket + # type's price (the documented backfill fallback). + ticket_type = await get_ticket_type(source.ticket_type_id) + if ticket_type: + value = ticket_type.price + return float(value or 0) + + +def _active_redemption_count(redemptions: list, now: datetime) -> int: + count = 0 + for r in redemptions or []: + status = r.get("status") + if status == "completed": + count += 1 + continue + if status == "pending": + expires_at = r.get("expires_at") + if expires_at: + try: + exp = datetime.fromisoformat(expires_at) + except (TypeError, ValueError): + exp = None + if exp is not None and exp > now: + count += 1 + return count + + +def _prune_expired_redemptions(redemptions: list, now: datetime) -> list: + kept = [] + for r in redemptions or []: + if r.get("status") == "pending": + expires_at = r.get("expires_at") + if expires_at: + try: + exp = datetime.fromisoformat(expires_at) + except (TypeError, ValueError): + exp = None + if exp is not None and exp <= now: + continue # expired pending reservation -> release slot + kept.append(r) + return kept + + +def _ticket_voucher_available(source: Ticket, promo: PromoCode, now: datetime) -> bool: + count = _active_redemption_count(source.extra.ticket_id_voucher_redemptions, now) + return count < (promo.redemptions_per_ticket or 1) + + +def _source_gate_ok(promo, item_count: int) -> bool: + """Whether the promo may apply to a basket of ``item_count`` tickets.""" + source_axis = _promo_source(promo) + # A move upgrade is strictly one ticket in, one ticket out. + if source_axis.move and item_count != 1: + return False + if promo.max_tickets is not None and item_count > promo.max_tickets: + return False + return True + + +def _rule_grants(rule) -> bool: + """Whether a rule GRANTS add-on tickets instead of discounting.""" + return getattr(rule, "grants", None) is not None + + +def ticket_admits(ticket_type: TicketType | None) -> int: + """How many people a ticket of this type admits (``extra.admits``, default 1). + + Read on every checkout, so anything unparseable falls back to 1 rather + than breaking the flow. + """ + if ticket_type is None: + return 1 + try: + value = int((ticket_type.extra or {}).get("admits", 1)) + except (TypeError, ValueError): + return 1 + return value if value >= 1 else 1 + + +def _addon_rules( + event: Event, addon_type_id: str, active_only: bool = True +) -> list[PromoCode]: + """Rules that authorise ADDING tickets of ``addon_type_id``.""" + return [ + p for p in event.extra.promo_codes + if _rule_grants(p) + and p.grants.ticket_type_id == addon_type_id + and (p.active or not active_only) + ] + + +def _source_scope_type_ids(promo, ticket_types: list[TicketType]) -> set[str]: + """Types that may act as a source for ``promo``. + + An empty scope means any type in the event, minus the add-on itself: an + add-on ticket must never be able to satisfy its own requirement. + """ + ids = set(_promo_source(promo).ticket_type_ids or [tt.id for tt in ticket_types]) + grant = getattr(promo, "grants", None) + if grant: + ids.discard(grant.ticket_type_id) + return ids + + +def _addon_allowance( + rule, items, ticket_types_by_id: dict, presented: list +) -> int: + """How many add-on tickets ``rule`` authorises for this basket. + + Qualifying sources are the tickets in the basket itself PLUS any source + ticket the buyer presented by id. Each source ticket admits ``admits`` + people and ``per_source`` add-ons are allowed per person, so a Family - + Weekend ticket (admits 4) is worth four seats where a Single is worth one. + """ + grant = rule.grants + source_ids = _source_scope_type_ids(rule, list(ticket_types_by_id.values())) + people = 0 + for item in items: + if item.ticket_type_id in source_ids: + people += item.quantity * ticket_admits( + ticket_types_by_id.get(item.ticket_type_id) + ) + for other, _token, source in presented or []: + if not _rule_grants(other) or other.grants.ticket_type_id != grant.ticket_type_id: + continue + if other.grants.per_source != grant.per_source: + continue + if _rule_code(other) != _rule_code(rule): + continue + # A cross-event source's type is not in this event's map; admits then + # defaults to 1, which is the conservative reading. + people += ticket_admits(ticket_types_by_id.get(source.ticket_type_id)) + return people * grant.per_source + + +def _cap_group(ticket_type) -> str | None: + """The shared-cap label on a ticket type, if it has one.""" + try: + group = (ticket_type.extra or {}).get("cap_group") + except AttributeError: + return None + return str(group) if group else None + + +async def _cap_scope_types(ticket_type: TicketType) -> list[TicketType]: + """Every type that shares ``ticket_type``'s cap. + + A cap often spans events: one screening has one pool of seats, sold from + both the Bitfest and the Freedom Fest checkout. The types carry the same + ``extra.cap_group``, and each one's ``max_tickets`` is the group's cap. + """ + group = _cap_group(ticket_type) + if not group: + return [ticket_type] + members = [tt for tt in await get_all_ticket_types() if _cap_group(tt) == group] + return members or [ticket_type] + + +async def _validate_addon_items( + event: Event, items, ticket_types: list[TicketType], reservation +) -> None: + """Enforce add-on entitlements and the counted cap on add-on types. + + The gate lives HERE, not in the checkout's cosmetic filter: an add-on may + only be added while a qualifying source ticket is present, and never + beyond ``per_source × admits`` per source ticket. The cap is counted from + real ticket rows because ``ticket_types.sold`` under-counts. + """ + by_id = {tt.id: tt for tt in ticket_types} + presented = list(getattr(reservation, "pairs", None) or []) + # A presented source may belong to ANOTHER event (a Freedom Fest ticket + # claiming a Bitfest screening seat). Its type is not in this event's map, + # so load it or `admits` silently defaults to 1. + for _other, _token, source in presented: + type_id = source.ticket_type_id + if type_id and type_id not in by_id: + source_type = await get_ticket_type(type_id) + if source_type: + by_id[type_id] = source_type + for item in items: + rules = _addon_rules(event, item.ticket_type_id) + if not rules: + continue + name = by_id[item.ticket_type_id].name if item.ticket_type_id in by_id else item.ticket_type_id + allowance = max( + _addon_allowance(rule, items, by_id, presented) for rule in rules ) - resp.raise_for_status() - return resp.json() + if allowance <= 0: + raise ValueError( + f"'{name}' can only be added with a qualifying ticket. " + "Add that ticket to your basket, or enter your existing ticket ID " + "in the Promo Code box." + ) + if item.quantity > allowance: + raise ValueError( + f"'{name}': only {allowance} can be added with your ticket(s)." + ) + ticket_type = by_id.get(item.ticket_type_id) + if ticket_type and ticket_type.max_tickets > 0: + issued = 0 + for member in await _cap_scope_types(ticket_type): + issued += await count_tickets_by_type(member.id) + if issued + item.quantity > ticket_type.max_tickets: + remaining = max(0, ticket_type.max_tickets - issued) + raise ValueError(f"Only {remaining} '{name}' left.") -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, +async def _match_ticket_rule( + event: Event, ticket_id: str, item_count: int, active_only: bool = True +) -> tuple[PromoCode, Ticket] | None: + """Find the ticket rule a submitted ticket id satisfies (rule order wins).""" + for promo in _ticket_rules(event, active_only=active_only): + if not _source_gate_ok(promo, item_count): + continue + source, _reason = await _resolve_source_ticket(event, ticket_id, promo) + if source: + return promo, source + return None + + +async def _reserve_ticket_voucher( + event: Event, source_ticket_id: str, basket_id: str, item_count: int = 1 +) -> None: + """Authoritative, concurrency-safe reservation of one redemption slot. + + Callers must hold the lock for ``source_ticket_id``. + """ + match = await _match_ticket_rule(event, source_ticket_id, item_count) + if not match: + raise ValueError("Invalid or ineligible ticket ID for discount.") + promo, source = match + now = datetime.now(timezone.utc) + if not _ticket_voucher_available(source, promo, now): + raise ValueError("This ticket has already been used for its discount.") + redemptions = _prune_expired_redemptions( + source.extra.ticket_id_voucher_redemptions, now + ) + redemptions.append( + { + "basket_id": basket_id, + "status": "pending", + "time": now.isoformat(), + "expires_at": (now + timedelta(seconds=_VOUCHER_TTL_SECONDS)).isoformat(), + } + ) + source.extra.ticket_id_voucher_redemptions = redemptions + await update_ticket(source) + + +async def _finalize_ticket_voucher(source: Ticket, basket_id: str) -> None: + redemptions = list(source.extra.ticket_id_voucher_redemptions or []) + changed = False + for r in redemptions: + if r.get("basket_id") == basket_id and r.get("status") == "pending": + r["status"] = "completed" + r.pop("expires_at", None) + changed = True + if changed: + source.extra.ticket_id_voucher_redemptions = redemptions + await update_ticket(source) + + +async def _consume_upgraded_source( + promo, source: Ticket, successor_ticket_id: str | None +) -> None: + """Void a source ticket consumed by a move upgrade and release its seat. + + The seat is only released when the source ticket had not been used: an + already-registered/attended ticket still represents a seat that was + occupied, so decrementing ``sold`` would over-state availability. + """ + source.extra.deactivated = True + source.extra.upgraded_to = successor_ticket_id + await update_ticket(source) + + if not source.registered: + source_event = await get_event(source.event) + if source_event and source_event.sold > 0: + source_event.sold -= 1 + await update_event(source_event) + if source.ticket_type_id: + ticket_type = await get_ticket_type(source.ticket_type_id) + if ticket_type and ticket_type.sold > 0: + ticket_type.sold -= 1 + await update_ticket_type(ticket_type) + + if successor_ticket_id: + successor = await get_ticket(successor_ticket_id) + if successor: + successor.extra.upgraded_from = source.id + await update_ticket(successor) + + +async def backfill_paid_values(wallet_ids: str | list[str]) -> dict: + """Backfill ``TicketExtra.paid_value`` for tickets sold before it existed. + + Prefers reconstructing the charged amount from the ticket's basket, falling + back to the ticket type's price when a basket can't be resolved. Idempotent: + only fills tickets whose ``paid_value`` is still None. + """ + summary = { + "events": 0, + "filled": 0, + "basket_reconstructed": 0, + "type_price_fallback": 0, + } + for event in await get_events(wallet_ids): + summary["events"] += 1 + basket_cache: dict[str, list[Ticket]] = {} + for ticket in await get_event_tickets(event.id): + if not ticket.paid or ticket.extra.paid_value is not None: + continue + value = None + if ticket.basket_id: + basket = await get_basket(ticket.basket_id) + if basket: + siblings = basket_cache.get(ticket.basket_id) + if siblings is None: + siblings = await get_basket_tickets(ticket.basket_id) + basket_cache[ticket.basket_id] = siblings + total = basket.total + if total is None: + total = await _recompute_basket_total(basket, event) + if total: + value = float(total) / len(siblings or [ticket]) + summary["basket_reconstructed"] += 1 + if value is None and ticket.ticket_type_id: + ticket_type = await get_ticket_type(ticket.ticket_type_id) + if ticket_type: + value = float(ticket_type.price) + summary["type_price_fallback"] += 1 + if value is None: + continue + ticket.extra.paid_value = value + await update_ticket(ticket) + summary["filled"] += 1 + return summary + + +class _TicketRuleResolution(NamedTuple): + """Outcome of pairing submitted offer codes + ticket ids against the rules.""" + + promos: list[PromoCode] + # (rule, ticket-id token, source ticket) for every rule that actually applied + pairs: list[tuple[PromoCode, str, Ticket]] + pending: list[PromoRequirement] + rejections: list[PromoRejection] + + +def _rule_selected(rule, selected: set[str]) -> bool: + """Whether the buyer chose this rule (a codeless rule is always chosen).""" + rule_code = _rule_code(rule) + return (not rule_code) or (rule_code in selected) + + +def _rule_uses_available(rule, allow_consumed: bool) -> bool: + """Whether a rule still has global uses left. + + A ticket rule can now cap its own usage ("first 20 upgrades"), which the + retired code-less design could not express because every rule shared an + empty code and so could not be counted. + """ + if allow_consumed: + return True + max_uses = getattr(rule, "max_uses", None) + if max_uses is None: + return True + return (getattr(rule, "used_count", 0) or 0) < max_uses + + +async def _resolve_ticket_rules( + event: Event, + promo_codes: list[str], + item_count: int, + *, + allow_consumed: bool = False, +) -> _TicketRuleResolution: + """Pair submitted offer codes and ticket ids against the ticket rules. + + A token matching a rule's offer code is an OFFER CODE; every other token is + a TICKET-ID CANDIDATE (ticket ids are case-sensitive PKs, so candidates keep + their raw case while codes compare uppercased). A rule applies when it was + SELECTED (its code typed, or it has no code) and a candidate resolves + against it. + + Two outcomes are reported rather than swallowed, because a silent + no-discount is indistinguishable from a wrong code: + + * ``pending`` — a rule was selected but no ticket was supplied at all. + This is what drives the checkout's "now enter your ticket ID" prompt. + * ``rejections`` — a ticket was supplied and did not work, with a stable + reason code. + + Rule order decides ties: the first eligible rule a ticket satisfies wins. + """ + rules = _ticket_rules(event) + if not rules: + return _TicketRuleResolution([], [], [], []) + + offer_codes = { + _rule_code(p) for p in event.extra.promo_codes if _rule_code(p) + } + selected = {c.upper() for c in promo_codes if c.upper() in offer_codes} + candidates = [c for c in promo_codes if c.upper() not in offer_codes] + + # Probe each candidate against each eligible rule, once. A ticket may + # satisfy more than one rule, so rank the offers the buyer actually named + # FIRST — otherwise a ticket would bind to whichever rule happens to sit + # earliest in the event config and the typed offer code would be ignored. + # A codeless rule (applies to any eligible ticket) outranks an un-named + # coded offer, but not a named one. + def _rank(rule) -> int: + rule_code = _rule_code(rule) + if rule_code and rule_code in selected: + return 0 + return 1 if not rule_code else 2 + + resolved: dict[tuple[int, str], Ticket] = {} + reasons: dict[str, str] = {} + exhausted: set[str] = set() + gate_blocked: set[str] = set() + for token in candidates: + for idx in sorted(range(len(rules)), key=lambda i: (_rank(rules[i]), i)): + rule = rules[idx] + # Resolve BEFORE the basket-shape gate so a valid ticket that simply + # doesn't fit the basket gets an accurate reason instead of falling + # through to a generic one. + source, reason = await _resolve_source_ticket(event, token, rule) + if not source: + reasons.setdefault(token, reason or "ticket_not_found") + continue + if not _source_gate_ok(rule, item_count): + # The ticket is fine; the BASKET shape is what this rule refuses. + gate_blocked.add(token) + continue + if not _rule_uses_available(rule, allow_consumed): + # The ticket is fine; the OFFER has run out. Remember it so the + # reason below is accurate rather than a generic one. + exhausted.add(token) + continue + resolved[(idx, token)] = source + break + + now = datetime.now(timezone.utc) + promos: list[PromoCode] = [] + pairs: list[tuple[PromoCode, str, Ticket]] = [] + pending: list[PromoRequirement] = [] + rejections: list[PromoRejection] = [] + rejected: set[str] = set() + + for idx, rule in enumerate(rules): + if not _rule_selected(rule, selected): + continue + if not _source_gate_ok(rule, item_count): + continue + token = next((t for t in candidates if (idx, t) in resolved), None) + if token is None: + # No usable ticket. Prompt only when the buyer supplied nothing at + # all; otherwise the per-token rejection below explains the failure. + if not candidates and _rule_code(rule): + pending.append( + PromoRequirement(code=rule.code, requires="ticket_id") + ) + continue + source = resolved[(idx, token)] + if not allow_consumed and not _ticket_voucher_available(source, rule, now): + rejections.append( + PromoRejection(code=token, reason="ticket_redemptions_exhausted") + ) + rejected.add(token) + continue + credit = await _source_credit(rule, source) + if _rule_grants(rule): + # A grant rule carries no discount: it authorises ADDING tickets of + # another type. It still pairs, so the reservation, redemption + # accounting and finalize-on-payment machinery apply unchanged. + pairs.append((rule, token, source)) + continue + promos.append( + # construct() bypasses validation so the rule passes through with + # the resolved credit. `code` is the offer code so the buyer sees + # WHICH offer applied and max_uses can count it; a codeless rule + # falls back to the ticket id so the checkout still shows something + # identifying rather than a blank. + PromoCode.construct( + code=rule.code or token, + source=_promo_source(rule), + discount_percent=rule.discount_percent, + discount_fixed=rule.discount_fixed, + active=True, + combinable=rule.combinable, + max_uses=None, + used_count=0, + ticket_type_ids=rule.ticket_type_ids, + max_tickets=rule.max_tickets, + credit_value=credit, + ) ) - resp.raise_for_status() - return resp.json() + pairs.append((rule, token, source)) + + # Every candidate that did not produce a discount gets a reason — a ticket + # supplied without its offer code must not fail silently either, and a token + # must never collect two explanations. + used = {token for _rule, token, _source in pairs} + for token in candidates: + if token in used or token in rejected: + continue + reason = reasons.get(token) + if reason is None: + if token in exhausted: + reason = "promo_uses_exhausted" + elif token in gate_blocked: + reason = "ticket_count_not_eligible" + else: + matches_unselected = any( + (idx, token) in resolved + and not _rule_selected(rules[idx], selected) + for idx in range(len(rules)) + ) + reason = ( + "ticket_needs_offer_code" + if matches_unselected + else "ticket_not_applicable" + ) + rejections.append(PromoRejection(code=token, reason=reason)) + + return _TicketRuleResolution(promos, pairs, pending, rejections) + + +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], + *, + allow_consumed: bool = False, +) -> BasketTotals: + ticket_types = await get_ticket_types(event.id) + types_by_id = {tt.id: tt for tt in ticket_types} + + subtotal = 0 + type_subtotals: dict = {} + for item in items: + tt = types_by_id.get(item["ticket_type_id"]) + if not tt: + continue + line_total = float(tt.price) * item.get("quantity", 1) + subtotal += line_total + tid = item["ticket_type_id"] + type_subtotals[tid] = type_subtotals.get(tid, 0.0) + line_total + + if event.currency.lower() in ("sat", "sats"): + subtotal = int(subtotal) + + submitted = [c.upper() for c in promo_codes] + # Plain promos: identified by their offer code, no ticket involved. Ticket + # rules selected by the same code are resolved separately below. + promos = [ + p for p in event.extra.promo_codes + if not _requires_ticket(p) and p.code and p.code in submitted + ] + + item_count = sum(item.get("quantity", 1) for item in items) + resolution = await _resolve_ticket_rules( + event, promo_codes, item_count, allow_consumed=allow_consumed + ) + promos.extend(resolution.promos) + + # Ordering (scoped bases first, then basket-wide) is handled inside + # _apply_promo_codes, which needs per-ticket-type bases to compute it. + total_discount, discounts_applied = _apply_promo_codes( + promos, type_subtotals, 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, + pending=resolution.pending, + rejections=resolution.rejections, + ) + + +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() + # Keep the RAW submitted codes so ticket-source promos (whose codes are + # case-sensitive ticket ids) survive into basket.promo_codes. Literal-code + # matching upper-cases at the point of comparison. + promo_code_strings = list(data.promo_codes) + + item_count = sum(i.quantity for i in data.items) + + # Authoritatively reserve ticket-rule redemptions on their SOURCE tickets. + # This is the write that closes the single-use race: it happens under a lock + # keyed on the SOURCE ticket id, before any charge is created, so two + # concurrent checkouts — in this event or any other — can't both claim the + # last available slot of the same source ticket. Only rules that actually + # applied get a reservation, so a stray token can never block checkout. + reservation = await _resolve_ticket_rules(event, promo_code_strings, item_count) + await _validate_addon_items(event, data.items, ticket_types, reservation) + for _rule, token, _source in reservation.pairs: + async with _reservation_lock(token): + await _reserve_ticket_voucher( + event, token, basket_id, item_count=item_count + ) + + 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, + total=float(totals.total), + ) + + charge = None + tickets: list[Ticket] = [] + + if totals.total > 0: + methods = event.extra.payment_methods or [] + + 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, + "applied_promo_code": ",".join(promo_code_strings) if promo_code_strings else None, + }, + 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 _recompute_basket_total(basket: Basket, event: Event) -> float: + """Total for a basket created before Basket.total existed.""" + items = [] + for ticket in await get_basket_tickets(basket.id): + if ticket.ticket_type_id: + items.append({"ticket_type_id": ticket.ticket_type_id, "quantity": 1}) + if not items: + return 0.0 + totals = await calculate_basket_total( + event, items, list(basket.promo_codes or []), allow_consumed=True + ) + return float(totals.total) + + +async def _activate_basket_tickets( + basket: Basket, tickets: list[Ticket], event: Event +) -> None: + # Stamp each ticket with the amount actually charged, in the event's + # currency. This is the credit basis if the ticket is later upgraded: + # deriving credit from what was paid (rather than the ticket type's face + # price) means a chain of upgrades can never inflate its own value. + if tickets: + charged = basket.total + if charged is None: + charged = await _recompute_basket_total(basket, event) + share = float(charged or 0) / len(tickets) + for ticket in tickets: + if ticket.extra.paid_value is None: + ticket.extra.paid_value = share + + for ticket in tickets: + await set_ticket_paid(ticket) + basket.paid = True + await update_basket(basket) + await _increment_promo_uses_from_basket(event, basket) + + # Finalize ticket-source redemptions (pending -> completed) now that this + # basket is actually paid, and consume the source ticket for move upgrades. + # Resolution here ignores `active` so that deactivating a promo between + # checkout and payment can never leave a discounted ticket paid while the + # source ticket stays valid. + item_count = len(tickets) or 1 + offer_codes = { + _rule_code(p) for p in event.extra.promo_codes if _rule_code(p) + } + for token in basket.promo_codes or []: + if token.upper() in offer_codes: + continue + match = await _match_ticket_rule( + event, token, item_count, active_only=False + ) + if not match: + continue + rule, source = match + await _finalize_ticket_voucher(source, basket.id) + if _promo_source(rule).move: + successor = tickets[0].id if tickets else None + await _consume_upgraded_source(rule, source, successor) + _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'
{escape(_format_event_dates(event))}
' + f'{escape(_format_event_dates(event))}
' + f'' + f'Tickets powered by LNbits' + f"
" + f'' + f'{base}' + f"
" + f"{tt_name}
' + f'Open ticket' + 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}
' + ) + + return ( + f'{escape(body_text).replace(chr(10), "
")}
| '
+ ' |
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'{escape(tt_name)}
' + f'×{qty}
' + f'{price_str}
' + f'| Ticket type | ' + f'Qty | ' + f'Total | ' + f'
' + f'{count} ticket(s) were just sold for ' + f'{escape(event.name)}.
' + f'| '
+ ' |
{escape(text_message).replace(chr(10), '
')}