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'' + f'' + f'' + f'' + f'

{escape(event.name)}

' + f'

{escape(_format_event_dates(event))}

' + f'' + ) + return ( + f'' + f'

{escape(event.name)}

' + f'

{escape(_format_event_dates(event))}

' + f'' + ) + + +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}

' + ) + + 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 = {c.upper() for c in 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 +1607,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 +1732,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 +1807,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 +1926,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..cff322c --- /dev/null +++ b/static/js/basket.js @@ -0,0 +1,125 @@ +;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 + }, + formatDate(value) { + const d = new Date(value) + if (isNaN(d)) return '' + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` + }, + 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..4fb48a7 --- /dev/null +++ b/static/js/basket.vue @@ -0,0 +1,165 @@ + diff --git a/static/js/display.js b/static/js/display.js index 427d460..42c3342 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -4,77 +4,85 @@ 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: [], + ticketTypesLoaded: false, + itemQuantities: {}, + basketName: '', + basketEmail: '', + promoCodeInput: '', + discountBreakdown: [], + promoMessages: [], + basketTotal: 0, + applyingPromo: false, + basketItems: [], + submitting: false, + checkoutLoading: false, + anonDialog: 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 - ) + basketCurrency() { + const c = this.event?.currency || 'sat' + return c === 'sat' ? 'sats' : c }, - selectedTicketWave() { - return ( - this.activeTicketWaves.find( - wave => wave.id === this.formDialog.data.ticket_wave_id - ) || - this.activeTicketWaves[0] || - null - ) + 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) + }, + basketItemCount() { + return this.basketItems.reduce((n, item) => n + item.quantity, 0) }, - showTicketWaveSelector() { - return this.activeTicketWaves.length > 1 + basketSummary() { + const key = + this.basketItemCount === 1 + ? 'events.basket_summary_one' + : 'events.basket_summary_many' + return this.$t(key, { + count: this.basketItemCount, + total: `${this.basketTotal} ${this.basketCurrency}` + }) }, - allowFiatCheckout() { - return Boolean(this.selectedTicketWave?.allow_fiat) + needsAnonWarning() { + return !this.basketName || !this.basketEmail }, - 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,203 +90,250 @@ 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'}) - } - if (this.allowOnchain) { - options.push({ - label: this.$t('events.payment_bitcoin'), - value: 'onchain' - }) - } - return options + acceptedPaymentMethods() { + return this.event?.extra?.payment_methods || [] } }, 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) + } finally { + this.ticketTypesLoaded = true } }, - 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({ + this.itemQuantities[tt.id] = qty + this.rebuildBasket() + this.$q.notify({ + message: this.$t('events.ticket_added_to_basket', { + qty, + name: tt.name + }), type: 'positive', - message: this.$t('events.payment_sent'), - icon: null + position: 'top', + timeout: 2000 }) - this.receive = { - show: false, - status: 'complete', - paymentReq: null, - isFiat: false + }, + removeFromBasket(ttId) { + const removed = this.basket.find(item => item.ticketTypeId === ttId) + this.basketItems = this.basketItems.filter( + item => item.ticketTypeId !== ttId + ) + this.itemQuantities[ttId] = 0 + this.attendeeFields[ttId] = [] + this.rebuildBasket() + if (removed) { + this.$q.notify({ + message: this.$t('events.ticket_removed_from_basket', { + name: removed.name + }), + type: 'info', + position: 'top', + timeout: 2000 + }) } - this.ticketLink = { - show: true, - data: { - link: `/events/ticket/${paymentHash}` + }, + 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.promoMessages = [] + this.basketTotal = 0 + return + } + 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') + this.basketTotal = subtotal + this.discountBreakdown = [] + this.promoMessages = [] }, - async createInvoice() { + async applyPromoCode() { + // Offer codes are matched case-insensitively by the server, but TICKET + // IDs are case-sensitive primary keys — never upper-case what the buyer + // typed or a valid ticket ID stops resolving. + const codes = this.promoCodeInput + .split(',') + .map(c => c.trim()) + .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 - } - - 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.basketTotal = data.total + this.discountBreakdown = (data.discounts_applied || []).map(d => ({ + label: `${d.code}: ${d.amount_saved} ${this.basketCurrency}` + })) + // An offer that needs a ticket ID reports what it still wants, so the + // buyer is told to add one instead of seeing nothing happen. + this.promoMessages = (data.pending || []).map(p => ({ + kind: 'pending', + label: this.$t('events.promo_pending_ticket', {code: p.code}) + })) + // A token that could not be used says why, instead of silently doing + // nothing (which is indistinguishable from a mistyped code). + this.promoMessages = this.promoMessages.concat( + (data.rejections || []).map(r => ({ + kind: 'rejected', + label: `${r.code}: ${this.promoReasonLabel(r.reason)}` + })) + ) + if ( + !this.discountBreakdown.length && + !this.promoMessages.length && + codes.length + ) { + this.promoMessages = [ + {kind: 'rejected', label: this.$t('events.promo_none_found')} + ] } - this.paymentWatcher(this.paymentHash) } catch (error) { LNbits.utils.notifyApiError(error) + this.rebuildBasket() + } finally { + this.applyingPromo = false } }, - paymentWatcher(paymentHash) { - if (this.paymentWebsocket) { - this.paymentWebsocket.close() + promoReasonLabel(reason) { + const key = `events.promo_reason_${reason}` + const label = this.$t(key) + // $t returns the key itself when it is missing; fall back to a generic + // message rather than showing a raw i18n key to a buyer. + return label === key ? this.$t('events.promo_reason_generic') : label + }, + + scrollToBasket() { + const card = this.$refs.basketCard + const el = card && card.$el ? card.$el : card + if (el && el.scrollIntoView) { + el.scrollIntoView({behavior: 'smooth', block: 'start'}) } + }, + async checkout() { + if (this.checkoutLoading || !this.canCheckout) return + // An anonymous ticket (no name and/or no email) can only be reached again + // through the ticket link we hand back, so acknowledge that first. + if (this.needsAnonWarning) { + this.anonDialog = true + return + } + await this.submitCheckout() + }, + async submitCheckout() { + this.checkoutLoading = true + try { + const items = this.basketItems.map(bi => ({ + ticket_type_id: bi.ticketTypeId, + quantity: bi.quantity + })) + // Same as applyPromoCode: codes are case-insensitive server-side but + // ticket IDs are not, so send what the buyer typed. + const promo_codes = this.promoCodeInput + .split(',') + .map(c => c.trim()) + .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 } + }, + confirmAnonCheckout() { + this.anonDialog = false + this.submitCheckout() } } } diff --git a/static/js/display.vue b/static/js/display.vue index 17a271f..feec0eb 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -6,126 +6,226 @@ v-if="event.banner" :src="event.banner" transition="slide-up" + style="max-height: 300px" + :img-style="{ objectFit: 'contain' }" >

-
+
+ + + + + | + + +
+
- - -
- -
-
- + +
+ + + + +
+ +
+ + +
+
+
+
+
+
+ + {{ tt.price }} {{ basketCurrency }} +
-
- +
+ + + +
-
+
+ + {{ $t('events.available_until') }} {{ tt.available_to }} + +
+
+
+
+
+ +
+ + +
+ + + + + +
+
+
+
+
+ +
+
+ +
- -
-
- -
-
+ v-model.trim="basketEmail" + type="email" + :label="$t('events.your_email_delivery_label')" + > +
+ +
+ +
+ +
+
+
+
+ +
+
+
+
+
+ {{ d.label }} +
+
+
+
+ {{ m.label }} +
+
+
+ Total: {{ basketTotal }} {{ basketCurrency }} +
+
+ +
+ + + + + +
+
+ +
-
+
-
- +
@@ -143,78 +243,11 @@
- - - - - -
-
-
- -
-
- - -
-
- -
- -
-
- - -
-
-
-
+
@@ -223,4 +256,45 @@
+ +
+
+
+ +
+
+
+ + + + + +
+ + +
+ + + + + +
+
diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index ca3c2ea..f8bcc86 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -4,19 +4,16 @@ window.i18n.global.mergeLocaleMessage('de', { events_title: 'Veranstaltungen', export_csv: 'Als CSV exportieren', tickets_title: 'Tickets', - ticket_waves: 'Ticket-Wellen', promo_codes: 'Promo-Codes', - no_active_promo_codes: 'Keine aktiven Promo-Codes für diese Veranstaltung.', - wave_chip: - '{title} - {opening} bis {closing} - {price} - {amount} Tickets - {sold} verkauft', + no_promo_codes: 'Keine Promo-Codes für diese Veranstaltung.', resend_ticket_email: 'Ticket-E-Mail erneut senden', confirm_onchain_payment: 'Onchain-Zahlung bestätigen', extension_title: 'Events-Erweiterung', info_label: 'Info', extension_desc_title: - 'Events: Ticket-Wellen für eine Veranstaltung verkaufen und registrieren', + 'Events: Tickets für eine Veranstaltung verkaufen und registrieren', extension_desc: - 'Mit Events können Sie eine Welle von Tickets für eine Veranstaltung erstellen. Jedes Ticket ist ein einzigartiger QR-Code, den der Nutzer bei der Anmeldung vorzeigt. Events wird mit einem teilbaren Ticket-Scanner geliefert, mit dem Teilnehmer registriert werden können.', + 'Mit Events können Sie Ticket-Typen für eine Veranstaltung erstellen. Jedes Ticket ist ein einzigartiger QR-Code, den der Nutzer bei der Anmeldung vorzeigt. Events wird mit einem teilbaren Ticket-Scanner geliefert, mit dem Teilnehmer registriert werden können.', created_by: 'Erstellt von,', swagger_api: 'Swagger API', event_title_label: 'Titel der Veranstaltung', @@ -27,25 +24,25 @@ window.i18n.global.mergeLocaleMessage('de', { image_url_hint: 'Optionales Bannerbild für die Veranstaltungsseite', event_begins: 'Veranstaltung beginnt', event_ends: 'Veranstaltung endet', - primary_wave_hint: - 'Primäre Ticket-Welle (weitere Wellen können später hinzugefügt werden)', - wave_title_label: 'Wellenbezeichnung', opening_date_label: 'Öffnungsdatum der Tickets', - closing_date_label: 'Schlussdatum der Tickets', - unit_label: 'Einheit', + + currency_unit: 'Währungseinheit', amount_tickets_label: 'Anzahl der Tickets', price_label: 'Preis ({currency}) *', use_ticket_image: 'Ticket-Bild verwenden', download_template: 'Vorlage herunterladen', replace_template: 'Ersetzen', custom_template_uploaded: 'Benutzerdefinierte Ticket-Vorlage hochgeladen.', - allow_fiat_checkout: 'Fiat-Checkout erlauben', - allow_fiat_hint: - 'Ermöglicht Teilnehmern, über einen konfigurierten Fiat-Anbieter mit der Veranstaltungswährung zu bezahlen.', - wave_allow_fiat_hint: - 'Ermöglicht Teilnehmern, über einen konfigurierten Fiat-Anbieter mit der Wellenwährung zu bezahlen.', - fiat_checkout_currency: 'Fiat-Checkout-Währung', - advanced_options: 'Erweiterte Optionen', + payment_methods: 'Zahlungsmethoden', + lightning: 'Lightning', + fiat: 'Fiat', + onchain: 'Onchain', + ln_wallet_label: 'LN Wallet', + onchain_wallet_label: 'Onchain Wallet', + onchain_disabled_hint: 'Die Onchain Wallet (WatchOnly) Erweiterung muss aktiviert sein und eine Wallet haben.', + fiat_provider_hint: 'Fiat-Anbieter müssen in den LNbits-Servereinstellungen konfiguriert und dein Benutzer autorisiert sein.', + fiat_provider_label: 'Fiat-Anbieter', + conditional_events_title: 'Bedingte Veranstaltungen', conditional_events_desc: 'Machen Sie diese Veranstaltung bedingt, wenn eine Mindestanzahl an Tickets verkauft wird. Der Nutzer wird gebeten, eine Lightning-Adresse oder LNURL-Pay für Rückerstattungen anzugeben.', @@ -55,7 +52,9 @@ window.i18n.global.mergeLocaleMessage('de', { ticket_delivery_desc: 'Senden Sie den bezahlten Ticket-Link automatisch per E-Mail oder Nostr DM.', email_notifications: 'E-Mail-Benachrichtigungen', + email_not_configured: 'E-Mail in LNbits-Kerneinstellungen nicht konfiguriert', nostr_notifications: 'Nostr-Benachrichtigungen', + nostr_not_configured: 'Nostr in LNbits-Kerneinstellungen nicht konfiguriert', notification_subject_label: 'Betreff der Ticket-Benachrichtigung', notification_subject_hint: 'Wird als E-Mail-Betreff beim Versenden von bezahlten Ticket-Links verwendet.', @@ -73,19 +72,15 @@ window.i18n.global.mergeLocaleMessage('de', { onchain_fasttrack: 'Fasttrack (ausstehende als bezahlt behandeln)', update_event: 'Veranstaltung aktualisieren', create_event: 'Veranstaltung erstellen', - promo_codes_title: 'Promo-Codes', - promo_codes_desc: - 'Erlaubt Nutzern, einen Promo-Code für Rabatte einzugeben.', promo_code_label: 'Promo-Code', + promo_code_required: 'Ein Angebotscode ist erforderlich, außer diese Regel verlangt zusätzlich eine Ticket-ID.', active: 'Aktiv', inactive: 'Inaktiv', - discount_label: 'Rabatt (%)', - add_promo_code: 'Promo-Code hinzufügen', - save_promo_codes: 'Promo-Codes speichern', - edit_ticket_wave: 'Ticket-Welle bearbeiten', - add_ticket_wave: 'Ticket-Welle hinzufügen', - update_ticket_wave: 'Ticket-Welle aktualisieren', - save_ticket_wave: 'Ticket-Welle speichern', + discount_label: 'Rabatt (%)', + add_promo_code: 'Promo-Code hinzufügen', + edit_promo_code: 'Promo-Code bearbeiten', + promo_code_exists: 'Dieser Promo-Code existiert bereits.', + save: 'Speichern', email_resent: 'Ticket-E-Mail erneut gesendet.', email_resend_failed: 'Ticket-E-Mail fehlgeschlagen: {error}', nostr_resent: 'Ticket Nostr DM erneut gesendet.', @@ -93,8 +88,6 @@ window.i18n.global.mergeLocaleMessage('de', { unknown_error: 'Unbekannter Fehler.', onchain_confirmed: 'Onchain-Zahlung bestätigt.', ticket_template_uploaded: 'Ticket-Vorlage hochgeladen.', - ticket_wave_updated: 'Ticket-Welle aktualisiert.', - ticket_wave_added: 'Ticket-Welle hinzugefügt.', promo_codes_updated: 'Promo-Codes aktualisiert.', delete_ticket_confirm: 'Sind Sie sicher, dass Sie dieses Ticket löschen möchten?', @@ -109,23 +102,23 @@ window.i18n.global.mergeLocaleMessage('de', { col_event: 'Veranstaltung', col_registered: 'Registriert', col_nostr: 'Nostr', + col_deactivated: 'Deaktiviert', col_promo_code: 'Promo-Code', + col_satspay: 'Zahlung', col_yes: 'Ja', col_no: 'Nein', submit: 'Absenden', promo_code_optional: '(optional) Promo-Code', buy_ticket: 'Ticket kaufen', - your_name_label: 'Ihr Name', + your_name_label: 'Ihr Name (optional)', your_email_label: 'Ihre E-Mail', - your_email_delivery_label: 'Ihre E-Mail (Ticket-Lieferung)', + your_email_delivery_label: 'Ihre E-Mail (Ticket-Zustellung, optional)', required: '* Erforderlich', - nostr_nip05_label: '(optional) Nostr NIP-05', - nostr_nip05_hint: - 'Falls angegeben, senden wir Ihnen den Ticket-Link per DM nach der Zahlung.', + nostr_nip05_label: 'Ihre Nostr NIP-05 (Ticket-Zustellung, optional)', + nostr_nip05_hint: '', refund_label: 'Rückerstattungs-Lnaddress oder LNURL', refund_hint: 'Wenn die Mindestanzahl von {min_tickets} Tickets nicht erreicht wird, wird eine Rückerstattung gesendet.', - ticket_wave_label: 'Ticket-Welle', link_to_ticket: 'Link zu Ihrem Ticket!', continue_to_checkout: 'Weiter zur Kasse', fiat_checkout_opened: @@ -147,6 +140,9 @@ window.i18n.global.mergeLocaleMessage('de', { registered_success: 'Registriert', name_label: 'Name:', email_label: 'E-Mail:', + ticket_details: 'Ticket-Details', + yes: 'Ja', + no: 'Nein', paid_label: 'Bezahlt:', id_label: 'ID:', failed: 'Fehlgeschlagen', @@ -157,8 +153,163 @@ window.i18n.global.mergeLocaleMessage('de', { 'Setzen Sie ein Lesezeichen, drucken oder erstellen Sie einen Screenshot dieser Seite und zeigen Sie ihn bei der Registrierung vor!', ticket_paid: 'Bezahlt', ticket_not_paid: 'Nicht bezahlt', + ticket_deactivated: 'Deaktiviert', + ticket_pending: 'Ausstehend', + deactivate_ticket: 'Ticket deaktivieren', + activate_ticket: 'Ticket aktivieren', checked_in: 'Eingecheckt', not_checked_in: 'Nicht eingecheckt', - print: 'Drucken' + print: 'Drucken', + ticket_types: 'Ticket-Typen', + no_ticket_types: 'Keine Ticket-Typen konfiguriert.', + no_ticket_types_available: 'Keine Ticket-Typen für diese Veranstaltung verfügbar.', + available_until: 'Verfügbar bis', + tt_chip: '{name} - {price} {currency}', + ticket_type_updated: 'Ticket-Typ aktualisiert.', + ticket_type_added: 'Ticket-Typ hinzugefügt.', + delete_ticket_type_confirm: 'Möchten Sie diesen Ticket-Typ wirklich löschen?', + ticket_type_deleted: 'Ticket-Typ gelöscht.', + edit_ticket_type: 'Ticket-Typ bearbeiten', + add_ticket_type: 'Ticket-Typ hinzufügen', + tt_name_label: 'Name', + tt_description_label: 'Beschreibung', + tt_image_url_label: 'Bild-URL', + max_tickets_hint: 'Maximale Anzahl verkaufbarer Tickets (0 = unbegrenzt)', + sort_order_label: 'Sortierreihenfolge', + available_from_label: 'Verfügbar ab', + available_to_label: 'Verfügbar bis', + update_ticket_type: 'Ticket-Typ aktualisieren', + save_ticket_type: 'Ticket-Typ speichern', + promo_combinable: 'Kombinierbar', + promo_sec_identity: 'Identität', + promo_sec_effect: 'Was bewirkt sie?', + promo_sec_condition: 'Wann gilt sie?', + promo_sec_limits: 'Limits & Kombination', + promo_sec_usage: 'Nutzung', + promo_effect_discount: 'Rabatt', + promo_effect_grants: 'Fügt Tickets hinzu', + promo_applies_to: 'Gilt für', + promo_applies_whole: 'den gesamten Warenkorb', + promo_applies_types: 'nur diese Tickettypen', + promo_sum_no_code: 'kein Code', + promo_sum_code: 'Code {code}', + promo_sum_any_buyer: 'jeder Käufer', + promo_sum_requires: 'erfordert {types}', + promo_sum_any_ticket: 'beliebiges Ticket', + promo_sum_grant: 'fügt {qty} × {ticket} pro Person hinzu', + promo_sum_discount_percent: '{percent}% Rabatt', + promo_sum_discount_fixed: '{amount} {currency} Rabatt', + promo_sum_swap: 'entwertet das vorgelegte Ticket', + promo_sum_uses: 'max. {max}×', + promo_sum_unlimited: 'unbegrenzte Einlösungen', + promo_ticket_type_scope: 'Auf Tickettypen beschränken', + promo_ticket_type_scope_hint: 'Optional: beschränkt diesen Code auf bestimmte Tickettypen. Leer lassen, damit er für den gesamten Warenkorb gilt.', + promo_ticket_type_scope_all: 'Alle Tickettypen', + promo_max_uses: 'Max. Verwendungen', + promo_max_uses_hint: 'Maximale Anzahl der Verwendungen dieses Codes (0 = unbegrenzt)', + promo_uses_label: 'Verwendet: {used} / {max}', + promo_uses_unlimited: 'Verwendet: {used} mal (unbegrenzt)', + promo_require_ticket: 'Ticket-ID erforderlich', + promo_require_ticket_hint: 'Der Käufer muss zusätzlich zum Code eine bestehende Ticket-ID eingeben. Für Upgrades ("Ticket umbuchen") und Zusatzticket-Angebote ("Freund zum halben Preis mitbringen").', + promo_grants_toggle: 'Fügt Tickets hinzu statt eines Rabatts', + promo_grants_ticket_type: 'Hinzuzufügendes Ticket', + promo_grants_per_source: 'Tickets pro Person', + promo_grants_per_source_hint: + 'Wird mit der Anzahl der Personen multipliziert, die das vorgelegte Ticket abdeckt ("admits" am Tickettyp).', + promo_grants_hint: + 'Es gilt kein Rabatt: stattdessen wird das obige Ticket hinzugefügt, {per} pro abgedeckter Person des vorgelegten Tickets.', + promo_title_label: 'Name des Angebots', + promo_title_hint: + 'Optional. Erscheint in der Promo-Liste, damit ein Angebot ohne Code erkennbar bleibt.', + admits_label: 'Personen pro Ticket', + admits_hint: + 'Wie viele Personen ein Ticket dieses Typs abdeckt. Ein Familienticket mit 4 schaltet vier Zusatztickets frei.', + cap_group_label: 'Gemeinsames Limit', + cap_group_hint: + 'Optional. Typen mit derselben Gruppe teilen EIN Limit über Events hinweg (z. B. ein Kontingent an Plätzen).', + promo_code_optional_hint: 'Optional: leer lassen — jede berechtigte Ticket-ID aktiviert dieses Angebot.', + promo_code_required_hint: 'Der Käufer gibt diesen Code beim Bezahlen ein.', + promo_redemptions_per_ticket: 'Einlösungen pro Ticket', + promo_redemptions_per_ticket_hint: 'Wie viele Käufe diese einzelne Ticket-ID rabattieren kann (Standard 1 = einmalig)', + promo_max_tickets: 'Max. Tickets pro Einlösung', + promo_max_tickets_hint: 'Auf wie viele Tickets eine Einlösung angewendet wird (leer = beliebig viele)', + promo_ticket_code_hint: 'Käufer lösen ein, indem sie zusammen mit dem Angebotscode eine berechtigte Ticket-ID eingeben', + promo_source_events: 'Quell-Events', + promo_source_events_hint: 'Aus welchen Events das Quell-Ticket stammen darf. Leer lassen für nur dieses Event; weitere hinzufügen für eventübergreifende Upgrades.', + promo_source_ticket_types: 'Quell-Tickettypen', + promo_source_ticket_types_hint: 'Optional: schränkt ein, welche Tickettypen als Quelle dienen dürfen, z. B. nur Early-Bird-Tickets.', + promo_source_move: 'Mein Ticket umbuchen', + promo_source_move_hint: 'Das Quell-Ticket wird ungültig und sein Platz freigegeben, sobald dieser Kauf bezahlt ist — ein Upgrade, kein Zusatzticket.', + promo_source_credit: 'Quell-Guthaben', + promo_source_credit_none: 'Kein Guthaben', + promo_source_credit_paid: 'Gezahlten Betrag anrechnen', + promo_source_credit_hint: 'Das Guthaben rechnet an, was das Quell-Ticket tatsächlich gekostet hat, sodass nur die Differenz zu zahlen ist. Ein zusätzlicher Rabatt obendrauf fördert das Upgrade. Begrenzt auf den Preis des neuen Tickets.', + discount_type: 'Rabatt-Typ', + discount_percent_label: '% Rabatt', + discount_fixed_label: 'Fester Rabatt', + bulk_actions: 'Massenaktionen', + resend_all_emails: 'Alle Ticket-E-Mails erneut senden', + email_all_attendees: 'E-Mail an alle Teilnehmer', + email_all_attendees_title: 'E-Mail an alle Teilnehmer', + email_subject_label: 'Betreff', + email_message_label: 'Nachricht', + send: 'Senden', + admin_email_label: 'Admin-E-Mail', + admin_email_hint: 'E-Mail-Benachrichtigungen über Ticketverkäufe erhalten', + bulk_email_sent: 'Ticket-E-Mails erneut gesendet.', + confirm_resend_all: 'Sind Sie sicher, dass Sie alle Ticket-E-Mails für diese Veranstaltung erneut senden möchten?', + bulk_message_sent: 'Nachricht an alle Teilnehmer gesendet.', + quantity: 'Anzahl', + add_to_basket: 'In den Warenkorb', + basket_summary_one: '{count} Ticket · {total}', + basket_summary_many: '{count} Tickets · {total}', + ticket_added_to_basket: '{qty} × {name} zum Warenkorb hinzugefügt', + ticket_removed_from_basket: '{name} aus dem Warenkorb entfernt', + view_basket: 'Warenkorb ansehen', + anon_dialog_title: 'Auf Inkognito?', + anon_dialog_body: + 'Anonymes Ticket: ohne Name und E-Mail können wir dein Ticket nicht erneut senden und dich nicht über das Event informieren — bewahre den Link also auf wie eine Seed Phrase. Verlierst du ihn, können wir dir keinen neuen schicken.', + anon_dialog_confirm: 'Voll anonym', + anon_dialog_back: 'Daten ergänzen', + copy_details_to_all: 'Auf alle übertragen', + attendee: 'Teilnehmer', + attendees_for: 'Teilnehmer für', + name: 'Name', + basket: 'Warenkorb', + promo_code_comma_separated: 'Promo-Code(s) (durch Kommas getrennt)', + promo_pending_ticket: + '{code} benötigt zusätzlich deine bestehende Ticket-ID — füge sie im selben Promo-Code-Feld hinzu, durch ein Komma getrennt.', + promo_none_found: 'Keine gültigen Promo-Codes gefunden', + promo_reason_generic: 'Dieser Code konnte nicht verwendet werden.', + promo_reason_ticket_not_found: 'Kein bekannter Angebotscode oder keine bekannte Ticket-ID.', + promo_reason_ticket_not_eligible: 'Dieses Ticket ist für dieses Angebot nicht berechtigt.', + promo_reason_ticket_out_of_scope: 'Dieses Ticket stammt nicht aus einem berechtigten Event oder Tickettyp.', + promo_reason_ticket_already_used: 'Dieses Ticket wurde bereits für ein Angebot verwendet.', + promo_reason_ticket_redemptions_exhausted: 'Dieses Ticket hat alle Einlösungen für dieses Angebot genutzt.', + promo_reason_promo_uses_exhausted: 'Dieses Angebot hat sein Nutzungslimit erreicht.', + promo_reason_ticket_needs_offer_code: + 'Gib den Angebotscode und deine Ticket-ID ein, beide im Promo-Code-Feld.', + promo_reason_ticket_count_not_eligible: 'Dieses Angebot gilt nicht für die Anzahl der Tickets in deinem Warenkorb.', + promo_reason_ticket_not_applicable: 'Diese Ticket-ID kann nicht mit den eingegebenen Codes verwendet werden.', + promo_reason_currency_mismatch: 'Dieses Ticket hat eine andere Währung und kann nicht angerechnet werden.', + promo_code_hint: 'Promo-Codes durch Kommas getrennt eingeben', + checkout: 'Zur Kasse', + apply: 'Anwenden', + loading: 'Loading...', + basket_not_found: 'Warenkorb nicht gefunden.', + basket_confirmed: 'Zahlung bestätigt! Ihre Tickets sind bereit.', + basket_pending: 'Warten auf Zahlungsbestätigung...', + buyer: 'Käufer', + your_tickets: 'Ihre Tickets', + buy_more: 'Mehr Tickets kaufen', + anon: 'Anonym', + col_ticket_type: 'Ticket-Typ', + ticket_count_one: '1 Ticket', + ticket_count_many: '{count} Tickets', + show_ticket: 'Ticket anzeigen', + copy_url: 'Link kopieren', + continue_payment: 'Zahlung fortsetzen', + basket_auto_update: 'Warenkorb aktualisiert...', + total: 'Gesamt', } }) diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js index 6f8d08f..2cae492 100644 --- a/static/js/i18n/en.js +++ b/static/js/i18n/en.js @@ -4,18 +4,15 @@ window.i18n.global.mergeLocaleMessage('en', { events_title: 'Events', export_csv: 'Export to CSV', tickets_title: 'Tickets', - ticket_waves: 'Ticket waves', - promo_codes: 'Promo codes', - no_active_promo_codes: 'No active promo codes for this event.', - wave_chip: - '{title} - {opening} to {closing} - {price} - {amount} tickets - {sold} sold', + promo_codes: 'Promo Codes', + no_promo_codes: 'No promo codes for this event.', resend_ticket_email: 'Resend ticket email', confirm_onchain_payment: 'Confirm onchain payment', extension_title: 'Events extension', info_label: 'Info', - extension_desc_title: 'Events: Sell and register ticket waves for an event', + extension_desc_title: 'Events: Sell and register tickets for an event', extension_desc: - 'Events allows you to make a wave of tickets for an event, each ticket is in the form of a unique QRcode, which the user presents at registration. Events comes with a shareable ticket scanner, which can be used to register attendees.', + 'Events allows you to create ticket types for an event, each ticket is in the form of a unique QRcode, which the user presents at registration. Events comes with a shareable ticket scanner, which can be used to register attendees.', created_by: 'Created by,', swagger_api: 'Swagger API', event_title_label: 'Title of event', @@ -26,24 +23,25 @@ window.i18n.global.mergeLocaleMessage('en', { image_url_hint: 'Optional banner image to display on the event page', event_begins: 'Event begins', event_ends: 'Event ends', - primary_wave_hint: 'Primary ticket wave (you can add other waves later)', - wave_title_label: 'Wave title', opening_date_label: 'Ticket opening date', - closing_date_label: 'Ticket closing date', - unit_label: 'Unit', + + currency_unit: 'Currency unit', amount_tickets_label: 'Amount of tickets', price_label: 'Price ({currency}) *', use_ticket_image: 'Use ticket image', download_template: 'Download template', replace_template: 'Replace', custom_template_uploaded: 'Custom ticket template uploaded.', - allow_fiat_checkout: 'Allow fiat checkout', - allow_fiat_hint: - 'Lets attendees pay through a configured fiat provider using the event currency.', - wave_allow_fiat_hint: - 'Lets attendees pay through a configured fiat provider using this wave currency.', - fiat_checkout_currency: 'Fiat checkout currency', - advanced_options: 'Advanced options', + payment_methods: 'Payment Methods', + lightning: 'Lightning', + fiat: 'Fiat', + onchain: 'Onchain', + ln_wallet_label: 'LN Wallet', + onchain_wallet_label: 'Onchain Wallet', + onchain_disabled_hint: 'Onchain Wallet (WatchOnly) extension must be activated and have a wallet.', + fiat_provider_hint: 'Fiat providers must be configured in LNbits core server settings and your user must be authorized.', + fiat_provider_label: 'Fiat Provider', + conditional_events_title: 'Conditional Events', conditional_events_desc: 'Make this event conditional if minimum tickets are sold. User will be asked to provide a Lightning Address or LNURL pay for refunds.', @@ -53,13 +51,17 @@ window.i18n.global.mergeLocaleMessage('en', { ticket_delivery_desc: 'Send the paid ticket link automatically by email or Nostr DM.', email_notifications: 'Email notifications', + email_not_configured: 'Email not configured in LNbits core settings', nostr_notifications: 'Nostr notifications', + nostr_not_configured: 'Nostr not configured in LNbits core settings', notification_subject_label: 'Ticket notification subject', notification_subject_hint: 'Used as the email subject when sending paid ticket links.', notification_body_label: 'Ticket notification body', notification_body_hint: 'Shown before the ticket link in the paid ticket notification.', + preview_ticket_email: 'Preview ticket email', + preview_admin_email: 'Preview admin email', onchain_payments: 'Onchain payments', onchain_desc: 'Accept Bitcoin onchain payments. Requires the SatsPay and Watchonly extensions to be installed and enabled.', @@ -71,18 +73,15 @@ window.i18n.global.mergeLocaleMessage('en', { onchain_fasttrack: 'Fasttrack (treat pending as paid)', update_event: 'Update Event', create_event: 'Create Event', - promo_codes_title: 'Promo Codes', - promo_codes_desc: 'Allow users to enter a promo code for discounts.', promo_code_label: 'Promo Code', + promo_code_required: 'An offer code is required unless this rule also requires a ticket ID.', active: 'Active', inactive: 'Inactive', - discount_label: 'Discount (%)', - add_promo_code: 'Add Promo Code', - save_promo_codes: 'Save Promo Codes', - edit_ticket_wave: 'Edit Ticket Wave', - add_ticket_wave: 'Add Ticket Wave', - update_ticket_wave: 'Update Ticket Wave', - save_ticket_wave: 'Save Ticket Wave', + discount_label: 'Discount (%)', + add_promo_code: 'Add Promo Code', + edit_promo_code: 'Edit Promo Code', + promo_code_exists: 'This promo code already exists.', + save: 'Save', email_resent: 'Ticket email resent.', email_resend_failed: 'Ticket email failed: {error}', nostr_resent: 'Ticket Nostr DM resent.', @@ -90,8 +89,6 @@ window.i18n.global.mergeLocaleMessage('en', { unknown_error: 'Unknown error.', onchain_confirmed: 'Onchain payment confirmed.', ticket_template_uploaded: 'Ticket template uploaded.', - ticket_wave_updated: 'Ticket wave updated.', - ticket_wave_added: 'Ticket wave added.', promo_codes_updated: 'Promo codes updated.', delete_ticket_confirm: 'Are you sure you want to delete this ticket?', delete_event_confirm: 'Are you sure you want to delete this event?', @@ -104,22 +101,25 @@ window.i18n.global.mergeLocaleMessage('en', { col_event: 'Event', col_registered: 'Registered', col_nostr: 'Nostr', + col_deactivated: 'Deactivated', + purchase_date: 'Purchase Date', + col_purchase_date: 'Purchase Date', col_promo_code: 'Promo Code', + col_satspay: 'Payment', col_yes: 'Yes', col_no: 'No', submit: 'Submit', promo_code_optional: '(optional) Promo Code', buy_ticket: 'Buy Ticket', - your_name_label: 'Your name', + your_name_label: 'Your name (optional)', your_email_label: 'Your email', - your_email_delivery_label: 'Your email (ticket delivery)', + your_email_delivery_label: 'Your email (ticket delivery, optional)', required: '* Required', - nostr_nip05_label: '(optional) Nostr NIP-05', - nostr_nip05_hint: "If provided, we'll DM your ticket link after payment.", + nostr_nip05_label: 'Your Nostr NIP-05 (ticket delivery, optional)', + nostr_nip05_hint: '', refund_label: 'Refund lnaddress or LNURL', refund_hint: 'If minimum tickets ({min_tickets}) are not met, refund will be sent.', - ticket_wave_label: 'Ticket wave', link_to_ticket: 'Link to your ticket!', continue_to_checkout: 'Continue to checkout', fiat_checkout_opened: @@ -140,6 +140,9 @@ window.i18n.global.mergeLocaleMessage('en', { registered_success: 'Registered', name_label: 'Name:', email_label: 'Email:', + ticket_details: 'Ticket Details', + yes: 'Yes', + no: 'No', paid_label: 'Paid:', id_label: 'ID:', failed: 'Failed', @@ -150,8 +153,166 @@ window.i18n.global.mergeLocaleMessage('en', { 'Bookmark, print or screenshot this page, and present it for registration!', ticket_paid: 'Paid', ticket_not_paid: 'Not Paid', + ticket_deactivated: 'Deactivated', + ticket_pending: 'Pending', + deactivate_ticket: 'Deactivate ticket', + activate_ticket: 'Activate ticket', checked_in: 'Checked In', not_checked_in: 'Not Checked In', - print: 'Print' + print: 'Print', + ticket_types: 'Ticket Types', + no_ticket_types: 'No ticket types configured.', + no_ticket_types_available: 'No ticket types available for this event.', + available_until: 'Available until', + tt_chip: '{name} - {price} {currency}', + ticket_type_updated: 'Ticket type updated.', + ticket_type_added: 'Ticket type added.', + delete_ticket_type_confirm: 'Are you sure you want to delete this ticket type?', + ticket_type_deleted: 'Ticket type deleted.', + edit_ticket_type: 'Edit Ticket Type', + add_ticket_type: 'Add Ticket Type', + tt_name_label: 'Name', + tt_description_label: 'Description', + tt_image_url_label: 'Image URL', + max_tickets_hint: 'Maximum number of tickets that can be sold (0 = unlimited)', + sort_order_label: 'Sort Order', + available_from_label: 'Available From', + available_to_label: 'Available To', + update_ticket_type: 'Update Ticket Type', + save_ticket_type: 'Save Ticket Type', + show_remaining_label: 'Show remaining tickets', + remaining_threshold_label: 'Remaining threshold', + remaining_threshold_hint: 'Only show when remaining is at or below this number (0 = always show)', + promo_max_uses: 'Max Uses', + promo_max_uses_hint: 'Total redemptions allowed (0 = unlimited). Counts ticket-ID offers too, so it can cap an upgrade campaign.', + promo_uses_label: 'Uses: {used} / {max}', + promo_uses_unlimited: 'Used: {used} times (unlimited)', + promo_combinable: 'Combinable', + promo_sec_identity: 'Identity', + promo_sec_effect: 'What does it do?', + promo_sec_condition: 'When does it apply?', + promo_sec_limits: 'Limits & stacking', + promo_sec_usage: 'Usage', + promo_effect_discount: 'Discount', + promo_effect_grants: 'Adds tickets', + promo_applies_to: 'Applies to', + promo_applies_whole: 'the whole basket', + promo_applies_types: 'only these ticket types', + promo_sum_no_code: 'no code', + promo_sum_code: 'code {code}', + promo_sum_any_buyer: 'any buyer', + promo_sum_requires: 'requires {types}', + promo_sum_any_ticket: 'any ticket', + promo_sum_grant: 'adds {qty} × {ticket} per person', + promo_sum_discount_percent: '{percent}% off', + promo_sum_discount_fixed: '{amount} {currency} off', + promo_sum_swap: 'voids the ticket they present', + promo_sum_uses: 'max {max}×', + promo_sum_unlimited: 'unlimited redemptions', + promo_ticket_type_scope: 'Limit to ticket types', + promo_ticket_type_scope_hint: 'Optional: restrict this code to specific ticket types. Leave empty to apply to the whole basket.', + promo_ticket_type_scope_all: 'All ticket types', + promo_require_ticket: 'Requires a ticket ID', + promo_require_ticket_hint: 'The buyer must enter an existing ticket ID as well as the code. Use it for upgrades ("move my ticket") and extra-ticket offers ("bring a friend half price").', + promo_grants_toggle: 'Adds tickets instead of a discount', + promo_grants_ticket_type: 'Ticket to add', + promo_grants_per_source: 'Tickets per person', + promo_grants_per_source_hint: + 'Multiplied by how many people the presented ticket covers ("admits" on the ticket type).', + promo_grants_hint: + 'No discount applies: the ticket above is added instead, {per} per person the presented ticket covers.', + promo_title_label: 'Offer name', + promo_title_hint: + 'Optional. Shown in the promo list, so a code-less offer stays identifiable.', + admits_label: 'Admits (people)', + admits_hint: + 'How many people one ticket of this type covers. A Family ticket admitting 4 unlocks four add-ons.', + cap_group_label: 'Shared cap group', + cap_group_hint: + 'Optional. Types sharing a group share ONE cap across events (e.g. one pool of screening seats).', + promo_code_optional_hint: 'Optional: leave blank and any eligible ticket ID activates this offer.', + promo_code_required_hint: 'The buyer types this code at checkout.', + promo_redemptions_per_ticket: 'Redemptions per ticket', + promo_redemptions_per_ticket_hint: 'How many purchases this single ticket ID can discount (default 1 = single use)', + promo_max_tickets: 'Max tickets per redemption', + promo_max_tickets_hint: 'How many tickets a single use applies to (empty = any number)', + promo_ticket_code_hint: 'Buyers redeem by entering an eligible ticket ID alongside the offer code', + promo_source_events: 'Source events', + promo_source_events_hint: 'Which events a source ticket may come from. Leave empty for this event only; add others to allow cross-event upgrades.', + promo_source_ticket_types: 'Source ticket types', + promo_source_ticket_types_hint: 'Optional: restrict which ticket types may be used as the source, e.g. only Early Bird tickets.', + promo_source_move: 'Move my ticket', + promo_source_move_hint: 'Void the source ticket and release its seat once this purchase is paid — an upgrade, not an extra ticket.', + promo_source_credit: 'Source credit', + promo_source_credit_none: 'No credit', + promo_source_credit_paid: 'Credit what was paid', + promo_source_credit_hint: 'Credit adds what the source ticket actually cost, so the buyer only pays the difference. Add a discount on top to further incentivise the upgrade. Capped at the new ticket price.', + discount_type: 'Discount Type', + discount_percent_label: '% Discount', + discount_fixed_label: 'Fixed Discount', + bulk_actions: 'Bulk Actions', + resend_all_emails: 'Resend All Ticket Emails', + email_all_attendees: 'Email All Attendees', + email_all_attendees_title: 'Email All Attendees', + email_subject_label: 'Subject', + email_message_label: 'Message', + send: 'Send', + admin_email_label: 'Admin Email', + admin_email_hint: 'Receive email notifications for ticket sales', + bulk_email_sent: 'Ticket emails resent.', + confirm_resend_all: 'Are you sure you want to resend all ticket emails for this event?', + bulk_message_sent: 'Message sent to all attendees.', + quantity: 'Quantity', + add_to_basket: 'Add to Basket', + basket_summary_one: '{count} ticket · {total}', + basket_summary_many: '{count} tickets · {total}', + ticket_added_to_basket: '{qty} × {name} added to your basket', + ticket_removed_from_basket: '{name} removed from your basket', + view_basket: 'View basket', + anon_dialog_title: 'Going incognito?', + anon_dialog_body: + 'Anonymous ticket: with no name and no email we cannot resend your ticket or send you event updates — so guard that link like a seed phrase. If you lose it, we have no way to get another one to you.', + anon_dialog_confirm: 'Full Anon', + anon_dialog_back: 'Let me add my details', + copy_details_to_all: 'Copy to All', + attendee: 'Attendee', + attendees_for: 'Attendees for', + name: 'Name', + basket: 'Basket', + promo_code_comma_separated: 'Promo Code(s) (comma separated)', + promo_pending_ticket: + '{code} also needs your existing ticket ID — add it in the same Promo Code box, separated by a comma.', + promo_none_found: 'No valid promo codes found', + promo_reason_generic: 'This code could not be used.', + promo_reason_ticket_not_found: 'Not a recognised offer code or ticket ID.', + promo_reason_ticket_not_eligible: 'That ticket is not eligible for this offer.', + promo_reason_ticket_out_of_scope: 'That ticket is not from an eligible event or ticket type.', + promo_reason_ticket_already_used: 'That ticket has already been used for an offer.', + promo_reason_ticket_redemptions_exhausted: 'That ticket has already used all of its redemptions for this offer.', + promo_reason_promo_uses_exhausted: 'This offer has reached its usage limit.', + promo_reason_ticket_needs_offer_code: + 'Enter the offer code as well as your ticket ID, both in the Promo Code box.', + promo_reason_ticket_count_not_eligible: 'This offer does not cover the number of tickets in your basket.', + promo_reason_ticket_not_applicable: 'That ticket ID cannot be used with the codes entered.', + promo_reason_currency_mismatch: 'That ticket is in a different currency and cannot be credited.', + promo_code_hint: 'Enter promo codes separated by commas', + checkout: 'Checkout', + apply: 'Apply', + loading: 'Loading...', + basket_not_found: 'Basket not found.', + basket_confirmed: 'Payment confirmed! Your tickets are ready.', + basket_pending: 'Waiting for payment confirmation...', + buyer: 'Buyer', + your_tickets: 'Your Tickets', + buy_more: 'Buy More Tickets', + anon: 'Anonymous', + col_ticket_type: 'Ticket Type', + ticket_count_one: '1 Ticket', + ticket_count_many: '{count} Tickets', + show_ticket: 'Show Ticket', + copy_url: 'Copy URL', + continue_payment: 'Continue to Payment', + basket_auto_update: 'Basket auto-updating...', + total: 'Total', } }) diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 24c6e48..48c6686 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -4,20 +4,16 @@ window.i18n.global.mergeLocaleMessage('es', { events_title: 'Eventos', export_csv: 'Exportar a CSV', tickets_title: 'Entradas', - ticket_waves: 'Oleadas de entradas', promo_codes: 'Códigos promocionales', - no_active_promo_codes: - 'No hay códigos promocionales activos para este evento.', - wave_chip: - '{title} - {opening} al {closing} - {price} - {amount} entradas - {sold} vendidas', + no_promo_codes: 'No hay códigos promocionales para este evento.', resend_ticket_email: 'Reenviar email de entrada', confirm_onchain_payment: 'Confirmar pago onchain', extension_title: 'Extensión de Eventos', info_label: 'Info', extension_desc_title: - 'Eventos: Vender y registrar oleadas de entradas para un evento', + 'Eventos: Vender y registrar entradas para un evento', extension_desc: - 'Eventos te permite crear oleadas de entradas para un evento. Cada entrada es un código QR único que el usuario presenta al registrarse. Eventos incluye un escáner de entradas compartible que puede usarse para registrar asistentes.', + 'Eventos te permite crear tipos de entradas para un evento. Cada entrada es un código QR único que el usuario presenta al registrarse. Eventos incluye un escáner de entradas compartible que puede usarse para registrar asistentes.', created_by: 'Creado por,', swagger_api: 'Swagger API', event_title_label: 'Título del evento', @@ -29,25 +25,25 @@ window.i18n.global.mergeLocaleMessage('es', { 'Imagen de banner opcional para mostrar en la página del evento', event_begins: 'El evento comienza', event_ends: 'El evento termina', - primary_wave_hint: - 'Oleada de entradas primaria (se pueden añadir más oleadas más adelante)', - wave_title_label: 'Título de la oleada', opening_date_label: 'Fecha de apertura de entradas', - closing_date_label: 'Fecha de cierre de entradas', - unit_label: 'Unidad', + + currency_unit: 'Unidad monetaria', amount_tickets_label: 'Cantidad de entradas', price_label: 'Precio ({currency}) *', use_ticket_image: 'Usar imagen de entrada', download_template: 'Descargar plantilla', replace_template: 'Reemplazar', custom_template_uploaded: 'Plantilla de entrada personalizada subida.', - allow_fiat_checkout: 'Permitir pago fiat', - allow_fiat_hint: - 'Permite a los asistentes pagar mediante un proveedor fiat configurado usando la moneda del evento.', - wave_allow_fiat_hint: - 'Permite a los asistentes pagar mediante un proveedor fiat configurado usando la moneda de esta oleada.', - fiat_checkout_currency: 'Moneda de pago fiat', - advanced_options: 'Opciones avanzadas', + payment_methods: 'Métodos de Pago', + lightning: 'Lightning', + fiat: 'Fiat', + onchain: 'Onchain', + ln_wallet_label: 'Monedero LN', + onchain_wallet_label: 'Monedero Onchain', + onchain_disabled_hint: 'La extensión Onchain Wallet (WatchOnly) debe estar activada y tener un monedero.', + fiat_provider_hint: 'Los proveedores fiat deben configurarse en los ajustes del servidor LNbits y tu usuario debe estar autorizado.', + fiat_provider_label: 'Proveedor Fiat', + conditional_events_title: 'Eventos condicionales', conditional_events_desc: 'Haz este evento condicional si se vende un mínimo de entradas. Se pedirá al usuario que proporcione una dirección Lightning o LNURL pay para reembolsos.', @@ -57,7 +53,9 @@ window.i18n.global.mergeLocaleMessage('es', { ticket_delivery_desc: 'Envía el enlace de la entrada pagada automáticamente por email o Nostr DM.', email_notifications: 'Notificaciones por email', + email_not_configured: 'Email no configurado en ajustes de LNbits', nostr_notifications: 'Notificaciones por Nostr', + nostr_not_configured: 'Nostr no configurado en ajustes de LNbits', notification_subject_label: 'Asunto de notificación de entrada', notification_subject_hint: 'Se usa como asunto del email al enviar enlaces de entradas pagadas.', @@ -75,19 +73,15 @@ window.i18n.global.mergeLocaleMessage('es', { onchain_fasttrack: 'Fasttrack (tratar pendientes como pagados)', update_event: 'Actualizar evento', create_event: 'Crear evento', - promo_codes_title: 'Códigos promocionales', - promo_codes_desc: - 'Permite a los usuarios introducir un código promocional para descuentos.', promo_code_label: 'Código promocional', + promo_code_required: 'Se requiere un código de oferta a menos que esta regla también requiera un ID de entrada.', active: 'Activo', inactive: 'Inactivo', - discount_label: 'Descuento (%)', - add_promo_code: 'Añadir código promocional', - save_promo_codes: 'Guardar códigos promocionales', - edit_ticket_wave: 'Editar oleada de entradas', - add_ticket_wave: 'Añadir oleada de entradas', - update_ticket_wave: 'Actualizar oleada de entradas', - save_ticket_wave: 'Guardar oleada de entradas', + discount_label: 'Descuento (%)', + add_promo_code: 'Añadir código promocional', + edit_promo_code: 'Editar código promocional', + promo_code_exists: 'Este código promocional ya existe.', + save: 'Guardar', email_resent: 'Email de entrada reenviado.', email_resend_failed: 'Email de entrada fallido: {error}', nostr_resent: 'Nostr DM de entrada reenviado.', @@ -95,8 +89,6 @@ window.i18n.global.mergeLocaleMessage('es', { unknown_error: 'Error desconocido.', onchain_confirmed: 'Pago onchain confirmado.', ticket_template_uploaded: 'Plantilla de entrada subida.', - ticket_wave_updated: 'Oleada de entradas actualizada.', - ticket_wave_added: 'Oleada de entradas añadida.', promo_codes_updated: 'Códigos promocionales actualizados.', delete_ticket_confirm: '¿Está seguro de que quiere eliminar esta entrada?', delete_event_confirm: '¿Está seguro de que quiere eliminar este evento?', @@ -109,23 +101,23 @@ window.i18n.global.mergeLocaleMessage('es', { col_event: 'Evento', col_registered: 'Registrado', col_nostr: 'Nostr', + col_deactivated: 'Desactivado', col_promo_code: 'Código promocional', + col_satspay: 'Pago', col_yes: 'Sí', col_no: 'No', submit: 'Enviar', promo_code_optional: '(opcional) Código promocional', buy_ticket: 'Comprar entrada', - your_name_label: 'Tu nombre', + your_name_label: 'Tu nombre (opcional)', your_email_label: 'Tu email', - your_email_delivery_label: 'Tu email (entrega de entrada)', + your_email_delivery_label: 'Su email (entrega de tickets, opcional)', required: '* Obligatorio', - nostr_nip05_label: '(opcional) Nostr NIP-05', - nostr_nip05_hint: - 'Si se proporciona, enviaremos el enlace de tu entrada por DM tras el pago.', + nostr_nip05_label: 'Su Nostr NIP-05 (entrega de tickets, opcional)', + nostr_nip05_hint: '', refund_label: 'Lnaddress o LNURL de reembolso', refund_hint: 'Si no se alcanzan las entradas mínimas ({min_tickets}), se enviará un reembolso.', - ticket_wave_label: 'Oleada de entradas', link_to_ticket: '¡Enlace a tu entrada!', continue_to_checkout: 'Continuar al pago', fiat_checkout_opened: @@ -147,6 +139,9 @@ window.i18n.global.mergeLocaleMessage('es', { registered_success: 'Registrado', name_label: 'Nombre:', email_label: 'Email:', + ticket_details: 'Detalles del ticket', + yes: 'Sí', + no: 'No', paid_label: 'Pagado:', id_label: 'ID:', failed: 'Fallido', @@ -157,8 +152,163 @@ window.i18n.global.mergeLocaleMessage('es', { '¡Guarda esta página como marcador, imprímela o toma una captura de pantalla y preséntala para registrarte!', ticket_paid: 'Pagado', ticket_not_paid: 'No pagado', + ticket_deactivated: 'Desactivada', + ticket_pending: 'Pendiente', + deactivate_ticket: 'Desactivar entrada', + activate_ticket: 'Activar entrada', checked_in: 'Registrado', not_checked_in: 'No registrado', - print: 'Imprimir' + print: 'Imprimir', + ticket_types: 'Tipos de entrada', + no_ticket_types: 'No hay tipos de entrada configurados.', + no_ticket_types_available: 'No hay tipos de entrada disponibles para este evento.', + available_until: 'Disponible hasta', + tt_chip: '{name} - {price} {currency}', + ticket_type_updated: 'Tipo de entrada actualizado.', + ticket_type_added: 'Tipo de entrada añadido.', + delete_ticket_type_confirm: '¿Está seguro de que quiere eliminar este tipo de entrada?', + ticket_type_deleted: 'Tipo de entrada eliminado.', + edit_ticket_type: 'Editar tipo de entrada', + add_ticket_type: 'Añadir tipo de entrada', + tt_name_label: 'Nombre', + tt_description_label: 'Descripción', + tt_image_url_label: 'URL de imagen', + max_tickets_hint: 'Número máximo de entradas que se pueden vender (0 = ilimitado)', + sort_order_label: 'Orden', + available_from_label: 'Disponible desde', + available_to_label: 'Disponible hasta', + update_ticket_type: 'Actualizar tipo de entrada', + save_ticket_type: 'Guardar tipo de entrada', + promo_combinable: 'Combinable', + promo_sec_identity: 'Identidad', + promo_sec_effect: '¿Qué hace?', + promo_sec_condition: '¿Cuándo se aplica?', + promo_sec_limits: 'Límites y combinación', + promo_sec_usage: 'Uso', + promo_effect_discount: 'Descuento', + promo_effect_grants: 'Añade entradas', + promo_applies_to: 'Se aplica a', + promo_applies_whole: 'toda la cesta', + promo_applies_types: 'solo estos tipos de entrada', + promo_sum_no_code: 'sin código', + promo_sum_code: 'código {code}', + promo_sum_any_buyer: 'cualquier comprador', + promo_sum_requires: 'requiere {types}', + promo_sum_any_ticket: 'cualquier entrada', + promo_sum_grant: 'añade {qty} × {ticket} por persona', + promo_sum_discount_percent: '{percent}% de descuento', + promo_sum_discount_fixed: '{amount} {currency} de descuento', + promo_sum_swap: 'anula la entrada presentada', + promo_sum_uses: 'máx. {max}×', + promo_sum_unlimited: 'canjes ilimitados', + promo_ticket_type_scope: 'Limitar a tipos de entrada', + promo_ticket_type_scope_hint: 'Opcional: restringe este código a tipos de entrada concretos. Déjalo vacío para aplicarlo a toda la cesta.', + promo_ticket_type_scope_all: 'Todos los tipos de entrada', + promo_max_uses: 'Usos máximos', + promo_max_uses_hint: 'Número máximo de veces que se puede usar este código (0 = ilimitado)', + promo_uses_label: 'Usos: {used} / {max}', + promo_uses_unlimited: 'Usado: {used} veces (ilimitado)', + promo_require_ticket: 'Requiere un ID de entrada', + promo_require_ticket_hint: 'El comprador debe ingresar un ID de entrada existente además del código. Úsalo para mejoras ("cambiar mi entrada") y ofertas de entradas adicionales ("trae un amigo a mitad de precio").', + promo_grants_toggle: 'Añade entradas en lugar de un descuento', + promo_grants_ticket_type: 'Entrada que añadir', + promo_grants_per_source: 'Entradas por persona', + promo_grants_per_source_hint: + 'Se multiplica por las personas que cubre la entrada presentada ("admits" del tipo de entrada).', + promo_grants_hint: + 'No se aplica descuento: se añade la entrada anterior, {per} por persona cubierta por la entrada presentada.', + promo_title_label: 'Nombre de la oferta', + promo_title_hint: + 'Opcional. Se muestra en la lista de promociones, para que una oferta sin código siga siendo identificable.', + admits_label: 'Admite (personas)', + admits_hint: + 'Cuántas personas cubre una entrada de este tipo. Una entrada familiar que admite 4 desbloquea cuatro añadidos.', + cap_group_label: 'Grupo de límite compartido', + cap_group_hint: + 'Opcional. Los tipos que comparten grupo comparten UN límite entre eventos (p. ej. un cupo de asientos).', + promo_code_optional_hint: 'Opcional: déjalo vacío y cualquier ID de entrada válido activa esta oferta.', + promo_code_required_hint: 'El comprador escribe este código al pagar.', + promo_redemptions_per_ticket: 'Canjes por entrada', + promo_redemptions_per_ticket_hint: 'Cuántas compras puede descontar este ID de entrada (predeterminado 1 = un solo uso)', + promo_max_tickets: 'Máx. entradas por canje', + promo_max_tickets_hint: 'A cuántas entradas se aplica un solo uso (vacío = cualquier número)', + promo_ticket_code_hint: 'Los compradores canjean ingresando un ID de entrada válido junto con el código de la oferta', + promo_source_events: 'Eventos de origen', + promo_source_events_hint: 'De qué eventos puede provenir la entrada de origen. Déjalo vacío para solo este evento; añade otros para permitir mejoras entre eventos.', + promo_source_ticket_types: 'Tipos de entrada de origen', + promo_source_ticket_types_hint: 'Opcional: restringe qué tipos de entrada pueden usarse como origen, p. ej. solo entradas Early Bird.', + promo_source_move: 'Cambiar mi entrada', + promo_source_move_hint: 'Anula la entrada de origen y libera su plaza cuando se pague esta compra: es una mejora, no una entrada adicional.', + promo_source_credit: 'Crédito de origen', + promo_source_credit_none: 'Sin crédito', + promo_source_credit_paid: 'Acreditar lo pagado', + promo_source_credit_hint: 'El crédito añade lo que realmente costó la entrada de origen, así el comprador solo paga la diferencia. Añade un descuento encima para incentivar más la mejora. Limitado al precio de la nueva entrada.', + discount_type: 'Tipo de descuento', + discount_percent_label: '% Descuento', + discount_fixed_label: 'Descuento fijo', + bulk_actions: 'Acciones masivas', + resend_all_emails: 'Reenviar todos los emails de entradas', + email_all_attendees: 'Enviar email a todos los asistentes', + email_all_attendees_title: 'Enviar email a todos los asistentes', + email_subject_label: 'Asunto', + email_message_label: 'Mensaje', + send: 'Enviar', + admin_email_label: 'Email del administrador', + admin_email_hint: 'Recibir notificaciones por email de ventas de entradas', + bulk_email_sent: 'Emails de entradas reenviados.', + confirm_resend_all: '¿Está seguro de que quiere reenviar todos los emails de entradas para este evento?', + bulk_message_sent: 'Mensaje enviado a todos los asistentes.', + quantity: 'Cantidad', + add_to_basket: 'Añadir a la cesta', + basket_summary_one: '{count} entrada · {total}', + basket_summary_many: '{count} entradas · {total}', + ticket_added_to_basket: '{qty} × {name} añadido a tu cesta', + ticket_removed_from_basket: '{name} eliminado de tu cesta', + view_basket: 'Ver cesta', + anon_dialog_title: '¿Modo incógnito?', + anon_dialog_body: + 'Entrada anónima: sin nombre ni email no podemos reenviarte la entrada ni enviarte novedades del evento — así que cuida ese enlace como una seed phrase. Si lo pierdes, no tenemos forma de mandarte otro.', + anon_dialog_confirm: 'Anónimo total', + anon_dialog_back: 'Añadir mis datos', + copy_details_to_all: 'Copiar a todos', + attendee: 'Asistente', + attendees_for: 'Asistentes para', + name: 'Nombre', + basket: 'Cesta', + promo_code_comma_separated: 'Código(s) promocional(es) (separados por comas)', + promo_pending_ticket: + '{code} también necesita tu ID de entrada existente: añádelo en la misma casilla de Promo Code, separado por una coma.', + promo_none_found: 'No se encontraron códigos promocionales válidos', + promo_reason_generic: 'Este código no se pudo usar.', + promo_reason_ticket_not_found: 'No es un código de oferta ni un ID de entrada reconocido.', + promo_reason_ticket_not_eligible: 'Esa entrada no es válida para esta oferta.', + promo_reason_ticket_out_of_scope: 'Esa entrada no pertenece a un evento o tipo de entrada válido.', + promo_reason_ticket_already_used: 'Esa entrada ya se usó para una oferta.', + promo_reason_ticket_redemptions_exhausted: 'Esa entrada ya ha agotado todos sus canjes para esta oferta.', + promo_reason_promo_uses_exhausted: 'Esta oferta ha alcanzado su límite de uso.', + promo_reason_ticket_needs_offer_code: + 'Introduce el código de oferta además de tu ID de entrada, ambos en la casilla de Promo Code.', + promo_reason_ticket_count_not_eligible: 'Esta oferta no cubre el número de entradas de tu cesta.', + promo_reason_ticket_not_applicable: 'Ese ID de entrada no se puede usar con los códigos introducidos.', + promo_reason_currency_mismatch: 'Esa entrada está en otra moneda y no se puede acreditar.', + promo_code_hint: 'Introduce códigos promocionales separados por comas', + checkout: 'Pagar', + apply: 'Aplicar', + loading: 'Loading...', + basket_not_found: 'Cesta no encontrada.', + basket_confirmed: '¡Pago confirmado! Sus entradas están listas.', + basket_pending: 'Esperando confirmación de pago...', + buyer: 'Comprador', + your_tickets: 'Sus Entradas', + buy_more: 'Comprar más entradas', + anon: 'Anónimo', + col_ticket_type: 'Tipo de entrada', + ticket_count_one: '1 entrada', + ticket_count_many: '{count} entradas', + show_ticket: 'Mostrar entrada', + copy_url: 'Copiar enlace', + continue_payment: 'Continuar con el pago', + basket_auto_update: 'Cesta actualizándose...', + total: 'Total', } }) diff --git a/static/js/index.js b/static/js/index.js index 0954da4..78afe58 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -72,6 +72,21 @@ window.PageEvents = { label: this.$t('events.col_nostr'), field: row => row.extra?.nostr_identifier || '' }, + { + name: 'deactivated', + align: 'left', + label: this.$t('events.col_deactivated'), + field: row => row.extra?.deactivated ? this.$t('events.col_yes') : this.$t('events.col_no') + }, + { + name: 'purchase_date', + align: 'left', + label: this.$t('events.col_purchase_date'), + field: row => { + const d = new Date(row.time) + return isNaN(d) ? '' : this.formatISODate(d) + } + }, { name: 'promo_code', align: 'left', @@ -88,8 +103,8 @@ window.PageEvents = { tickets: [], allPaidTickets: [], resendingTicketEmails: [], + resendingAllEmailsFor: [], isUploadingTicketTemplate: false, - ticketImageUploadTarget: null, currencies: [], eventsTable: { pagination: { @@ -113,166 +128,316 @@ window.PageEvents = { allow_fiat: false, fiat_currency: 'GBP', extra: { - ticket_waves: [], promo_codes: [], notification_subject: '', notification_body: '' } } }, - ticketWaveDialog: { + ticketTypeDialog: { + show: false, + eventId: null, + isEdit: false, + wallet: null, + data: { + id: null, + name: '', + description: '', + image_url: null, + price: 0, + max_tickets: 0, + available_from: '', + available_to: '', + sort_order: 0, +active: true, + show_remaining: false, + remaining_threshold: 0 + } + }, + ticketTypesByEvent: {}, + editPromoCodeDialog: { show: false, eventId: null, - wallet: null, - editingWaveId: null, + codeIndex: -1, data: { - id: null, - title: '', - opening_date: '', - closing_date: '', - currency: 'sats', - use_ticket_image: false, - ticket_image_id: null, - allow_fiat: false, - fiat_currency: 'GBP', - amount_tickets: 0, - price_per_ticket: 0 - } + code: '', + type: 'code', + discount_percent: null, + discount_fixed: null, + active: true, + combinable: true, + max_uses: null, + used_count: 0, + redemptions_per_ticket: 1, + ticket_type_ids: [], + max_tickets: null + }, + discountType: 'percent' }, - promoCodesDialog: { - show: false, - data: { - id: null, - wallet: null, - name: '', - extra: { - promo_codes: [] - } - } + watchonlyWallets: [], + paymentMethods: { + ln: false, + onchain: false, + fiat: false }, - onchainWallets: [] + emailAllDialog: { + show: false, + eventId: null, + subject: '', + message: '', + loading: false + } } }, methods: { - waveChipLabel(event, wave) { - const price = this.isFiatCurrency(wave.currency) + formatISODate(value) { + const d = new Date(value) + if (isNaN(d)) return '' + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` + }, + ticketTypeChipLabel(tt) { + const event = this.events.find(e => e.id === tt.event_id) + const currency = event?.currency || 'sat' + const price = this.isFiatCurrency(currency) ? LNbits.utils.formatCurrency( - Number(wave.price_per_ticket || 0).toFixed(2), - wave.currency + Number(tt.price || 0).toFixed(2), + currency ) - : `${wave.price_per_ticket} sats` - return this.$t('events.wave_chip', { - title: wave.title, - opening: wave.opening_date, - closing: wave.closing_date, + : `${tt.price} sats` + return this.$t('events.tt_chip', { + name: tt.name, price, - amount: wave.amount_tickets, - sold: this.soldTicketsForWave(event.id, wave.id) + sold: tt.sold || 0, + max: tt.max_tickets }) }, + soldTicketsForType(ttId) { + return this.allPaidTickets.filter( + ticket => + ticket.paid && + ticket.ticket_type_id === ttId + ).length + }, shortenId(value) { if (!value) return '' return value.length > 4 ? `${value.slice(0, 4)}...` : value }, - async loadOnchainWallets() { - const wallet = _.findWhere(this.g.user.wallets, { - id: this.formDialog.data.wallet - }) - if (!wallet) return - try { - const {data} = await LNbits.api.request( - 'GET', - '/events/api/v1/events/onchain/status', - wallet.adminkey - ) - this.onchainWallets = data.available ? data.wallets || [] : [] - } catch { - this.onchainWallets = [] + isFiatCurrency(currency) { + return !['sat', 'sats'].includes((currency || '').toLowerCase()) + }, + getEventCurrency(eventId) { + const event = this.events.find(e => e.id === eventId) + return event?.currency || 'sats' + }, + normalizePromoSource(code) { + const src = + code?.source || + (code?.type === 'ticket' ? {require_ticket: true} : {}) + const events = Array.isArray(src.event_ids) + ? src.event_ids.filter(Boolean) + : [] + const types = Array.isArray(src.ticket_type_ids) + ? src.ticket_type_ids.filter(Boolean) + : [] + return { + // `require_ticket` is an ADDITIONAL condition on top of the offer code: + // the offer applies only when an eligible ticket id is supplied too. + // The legacy `type: 'ticket'` and interim `kind: 'ticket'` both map here. + require_ticket: + src.require_ticket === true || + src.kind === 'ticket' || + code?.type === 'ticket', + // Empty source scope = this event only (the backend default). + event_ids: events.length ? events : null, + ticket_type_ids: types.length ? types : null, + move: !!src.move, + credit: src.credit === 'paid' ? 'paid' : 'none' } }, - async confirmOnchainTicket(ticket) { - const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet}) - if (!wallet) return - try { - await LNbits.api.request( - 'PUT', - `/events/api/v1/tickets/${ticket.id}/onchain-confirm`, - wallet.adminkey - ) - Quasar.Notify.create({ - type: 'positive', - message: this.$t('events.onchain_confirmed'), - icon: null + normalizePromoCodes(promoCodes = []) { + return promoCodes + .map(code => { + const source = this.normalizePromoSource(code) + code = { + redemptions_per_ticket: 1, + ...code, + source + } + // The legacy `type` axis is retired; `source.require_ticket` carries + // it now. The code is ALWAYS the offer code — never blanked, since it + // is what identifies the offer to the buyer and what max_uses counts. + delete code.type + // Ticket-type scoping: empty selection = whole basket (null). + const scope = Array.isArray(code.ticket_type_ids) + ? code.ticket_type_ids.filter(Boolean) + : [] + code.ticket_type_ids = scope.length ? scope : null + if (!code.max_tickets) code.max_tickets = null + return { + ...code, + code: (code.code || '').trim().toUpperCase() + } }) - await this.getTickets() - await this.getAllTickets() - } catch (error) { - LNbits.utils.notifyApiError(error) + // A rule without a ticket requirement needs a code to be redeemable; + // one WITH a ticket requirement may legitimately have none (it is then + // activated by any eligible ticket id). + .filter(code => code.source.require_ticket || code.code !== '') + }, + grantedTicketTypeName(grant) { + // A granted type is named by id only; look it up across the loaded + // events, since it need not belong to the rule's own event. + if (!grant || !grant.ticket_type_id) return '' + for (const types of Object.values(this.ticketTypesByEvent || {})) { + const match = (types || []).find(tt => tt.id === grant.ticket_type_id) + if (match) return match.name } + return grant.ticket_type_id }, - primaryTicketWave(data = this.formDialog.data) { - if (!data.extra) data.extra = {} - if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) { - data.extra.ticket_waves = [ - { - id: 'primary', - title: 'Primary wave', - opening_date: data.closing_date || '', - closing_date: data.closing_date || '', - currency: data.currency || 'sats', - use_ticket_image: false, - ticket_image_id: null, - allow_fiat: Boolean(data.allow_fiat), - fiat_currency: data.fiat_currency || 'GBP', - amount_tickets: data.amount_tickets || 0, - price_per_ticket: data.price_per_ticket || 0 - } - ] - } - return data.extra.ticket_waves[0] - }, - syncPrimaryWaveFromForm(data = this.formDialog.data) { - const primaryWave = this.primaryTicketWave(data) - primaryWave.title = primaryWave.title || 'Primary wave' - primaryWave.opening_date = primaryWave.opening_date || '' - primaryWave.closing_date = data.closing_date || '' - primaryWave.currency = data.currency || 'sats' - primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image) - primaryWave.ticket_image_id = primaryWave.ticket_image_id || null - primaryWave.allow_fiat = Boolean(data.allow_fiat) - primaryWave.fiat_currency = data.fiat_currency || 'GBP' - primaryWave.amount_tickets = Number(data.amount_tickets || 0) - primaryWave.price_per_ticket = Number(data.price_per_ticket || 0) - return primaryWave - }, - hydrateEventForm(data) { - const formData = { - ...data, - extra: { - ...(data.extra || {}), - ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])] + promoChipLabel(code, currency) { + const uses = + code.max_uses != null && code.max_uses > 0 + ? ` (${code.used_count || 0}/${code.max_uses})` + : '' + // The offer NAME is what the admin recognises the rule by, so it wins + // over the code; the code is still one hover away and in the dialog. + const name = code.title || (code.code ? code.code.toUpperCase() : '') + if (code.grants) { + // A grant discounts nothing, so the old label rendered as a blank + // " - " and the rule looked purposeless. Name what it adds instead. + return `${name ? name + ' ' : ''}\u2192 ${this.grantedTicketTypeName(code.grants)}${uses}` + } + const amount = + code.discount_percent != null + ? code.discount_percent + '%' + : code.discount_fixed != null + ? ` ${code.discount_fixed} ${currency === 'sat' ? 'sats' : currency}` + : '' + return `${name} - ${amount}${uses}` + }, + onPromoEffectChange(code, enabled) { + // Switching the effect keeps every other field: values you typed are only + // ever changed by you, so flipping to a grant and back must not lose the + // discount. A grant rule ignores a discount at runtime, so a retained + // value is inert, not wrong. + if (enabled) { + code.grants = code.grants || {ticket_type_id: '', per_source: 1} + code.source = code.source || {} + code.source.require_ticket = true + } else { + delete code.grants + // only fill a BLANK magnitude; never overwrite one + if (code.discount_percent == null && code.discount_fixed == null) { + code.discount_percent = 0 } } - const primaryWave = this.primaryTicketWave(formData) - formData.currency = primaryWave.currency || formData.currency || 'sats' - formData.allow_fiat = Boolean(primaryWave.allow_fiat) - formData.fiat_currency = primaryWave.fiat_currency || 'GBP' - formData.amount_tickets = primaryWave.amount_tickets - formData.price_per_ticket = primaryWave.price_per_ticket - formData.closing_date = - primaryWave.closing_date || formData.closing_date || '' - return formData }, - isFiatCurrency(currency) { - return !['sat', 'sats'].includes((currency || '').toLowerCase()) + ticketTypeName(typeId) { + if (!typeId) return '' + for (const types of Object.values(this.ticketTypesByEvent || {})) { + const match = (types || []).find(tt => tt.id === typeId) + if (match) return match.name + } + return typeId }, - normalizePromoCodes(promoCodes = []) { - return promoCodes - .filter(code => code.code?.trim() !== '') - .map(code => ({ - ...code, - code: code.code.trim().toUpperCase() - })) + promoSummary(code, eventId) { + // A short, generated statement of what this rule does. Assembled from + // fragments rather than a sentence so word order stays translatable. + if (!code) return '' + const parts = [] + parts.push( + code.code + ? this.$t('events.promo_sum_code', {code: code.code}) + : this.$t('events.promo_sum_no_code') + ) + if (code.grants) { + parts.push( + this.$t('events.promo_sum_grant', { + qty: code.grants.per_source || 1, + ticket: this.ticketTypeName(code.grants.ticket_type_id) || '?' + }) + ) + } else if (code.discount_percent != null) { + parts.push( + this.$t('events.promo_sum_discount_percent', { + percent: code.discount_percent + }) + ) + } else if (code.discount_fixed != null) { + parts.push( + this.$t('events.promo_sum_discount_fixed', { + amount: code.discount_fixed, + currency: eventId ? this.getEventCurrency(eventId) : '' + }) + ) + } + if (code.source && code.source.move) { + parts.push(this.$t('events.promo_sum_swap')) + } + if (code.source && code.source.require_ticket) { + const ids = code.source.ticket_type_ids || [] + const types = ids.length + ? ids.map(id => this.ticketTypeName(id)).join(', ') + : this.$t('events.promo_sum_any_ticket') + parts.push(this.$t('events.promo_sum_requires', {types})) + } else { + parts.push(this.$t('events.promo_sum_any_buyer')) + } + parts.push( + code.max_uses != null && code.max_uses > 0 + ? this.$t('events.promo_sum_uses', {max: code.max_uses}) + : this.$t('events.promo_sum_unlimited') + ) + return parts.join(' \u00b7 ') + }, + onPromoAppliesToChange(code, mode) { + // "only these ticket types" reveals the picker; the saver turns an empty + // selection back into null (= the whole basket). + code.ticket_type_ids = mode === 'types' ? code.ticket_type_ids || [] : null + }, + ticketTypeOptions(eventId) { + const tts = this.ticketTypesByEvent?.[eventId] || [] + return tts.map(tt => ({ + label: `${tt.name} (${tt.price})`, + value: tt.id + })) + }, + sourceEventOptions(targetEventId) { + return (this.events || []).map(ev => ({ + label: ev.id === targetEventId ? `${ev.name} — this event` : ev.name, + value: ev.id + })) + }, + sourceEventIds(source, targetEventId) { + const picked = Array.isArray(source?.event_ids) + ? source.event_ids.filter(Boolean) + : [] + // No source event named = this event only (the backend default). + return picked.length ? picked : [targetEventId].filter(Boolean) + }, + sourceTicketTypeOptions(source, targetEventId) { + const out = [] + for (const id of this.sourceEventIds(source, targetEventId)) { + for (const tt of this.ticketTypesByEvent?.[id] || []) { + out.push({label: `${tt.name} (${tt.price})`, value: tt.id}) + } + } + return out + }, + loadPromoScopeTicketTypes(codes, targetEventId) { + if (!this.ticketTypesByEvent?.[targetEventId]) { + this.loadTicketTypes(targetEventId) + } + for (const code of codes || []) { + if (!code?.source?.require_ticket) continue + for (const id of code.source.event_ids || []) { + if (id !== targetEventId && !this.ticketTypesByEvent?.[id]) { + this.loadTicketTypes(id) + } + } + } }, templateDownloadUrl() { return '/events/static/image/ticket.jpg' @@ -289,79 +454,39 @@ window.PageEvents = { ) return data.id }, - triggerTicketImageUpload(target) { - this.ticketImageUploadTarget = target - this.$refs.ticketImageUpload.value = null - this.$refs.ticketImageUpload.click() - }, - async handleTicketImageSelected(event) { - const file = event.target.files?.[0] - if (!file || !this.ticketImageUploadTarget) return - - this.isUploadingTicketTemplate = true - try { - const assetId = await this.uploadAssetFile(file) - if (this.ticketImageUploadTarget === 'primary') { - const wave = this.primaryTicketWave() - wave.use_ticket_image = true - wave.ticket_image_id = assetId - } else if (this.ticketImageUploadTarget === 'dialog') { - this.ticketWaveDialog.data.use_ticket_image = true - this.ticketWaveDialog.data.ticket_image_id = assetId - } - Quasar.Notify.create({ - type: 'positive', - message: this.$t('events.ticket_template_uploaded'), - icon: null - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.isUploadingTicketTemplate = false - this.ticketImageUploadTarget = null - } - }, - soldTicketsForWave(eventId, waveId) { - return this.allPaidTickets.filter( - ticket => - ticket.event === eventId && - ticket.paid && - (ticket.extra?.ticket_wave_id === waveId || - (!ticket.extra?.ticket_wave_id && waveId === 'primary')) - ).length - }, - async getAllTickets() { - try { - const {data} = await LNbits.api.request( + getAllTickets() { + LNbits.api + .request( 'GET', '/events/api/v1/tickets?all_wallets=true', this.g.user.wallets[0].adminkey ) - this.allPaidTickets = data.filter(ticket => ticket.paid) - } catch (error) { - LNbits.utils.notifyApiError(error) - } + .then(response => { + this.allPaidTickets = response.data.filter(ticket => ticket.paid) + }) + .catch(LNbits.utils.notifyApiError) }, - async getTickets(props) { - try { - this.ticketsTable.loading = true - const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props) - const {data} = await LNbits.api.request( + getTickets(props) { + this.ticketsTable.loading = true + const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props) + LNbits.api + .request( 'GET', `/events/api/v1/tickets/paginated?all_wallets=true&${params}`, this.g.user.wallets[0].adminkey ) - this.tickets = data.data - this.ticketsTable.pagination.rowsNumber = data.total - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.ticketsTable.loading = false - } + .then(response => { + this.tickets = response.data.data + this.ticketsTable.pagination.rowsNumber = response.data.total + }) + .catch(LNbits.utils.notifyApiError) + .finally(() => { + this.ticketsTable.loading = false + }) }, deleteTicket(ticketId) { - const tickets = _.findWhere(this.tickets, {id: ticketId}) - const wallet = _.findWhere(this.g.user.wallets, {id: tickets.wallet}) + const ticket = _.findWhere(this.tickets, {id: ticketId}) + const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet}) LNbits.utils .confirmDialog(this.$t('events.delete_ticket_confirm')) @@ -422,14 +547,37 @@ window.PageEvents = { } }) .catch(LNbits.utils.notifyApiError) - .finally(() => { + .finally(() => { this.resendingTicketEmails = this.resendingTicketEmails.filter( ticketId => ticketId !== ticket.id ) }) }, + toggleTicketDeactivation(ticket) { + const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet}) + if (!wallet) return + LNbits.api + .request( + 'PUT', + '/events/api/v1/tickets/' + ticket.id + '/deactivate', + wallet.adminkey + ) + .then(response => { + const updated = response.data + const action = updated.extra?.deactivated ? 'deactivated' : 'activated' + this.tickets = this.tickets.map(t => + t.id === ticket.id ? updated : t + ) + Quasar.Notify.create({ + type: 'positive', + message: `Ticket ${action}.`, + icon: null + }) + }) + .catch(LNbits.utils.notifyApiError) + }, exportticketsCSV() { - LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets) + LNbits.utils.exportCSV(this.ticketsColumns, this.allPaidTickets) }, getEvents() { LNbits.api @@ -441,14 +589,40 @@ window.PageEvents = { .then(response => { this.events = response.data this.checkCanceledEvents() + this.events.forEach(ev => this.loadTicketTypes(ev.id)) + }) + }, + fetchWatchOnlyWallets() { + LNbits.api + .request( + 'GET', + '/watchonly/api/v1/wallet', + this.g.user.wallets[0].adminkey + ) + .then(response => { + this.watchonlyWallets = (response.data || []).map(w => ({ + value: w.id, + label: (w.title || w.name || 'Wallet') + ' - ' + w.id + })) + }) + .catch(() => { + console.warn('WatchOnly extension not available, onchain disabled') }) }, sendEventData() { - const wallet = _.findWhere(this.g.user.wallets, { - id: this.formDialog.data.wallet - }) const data = this.formDialog.data - this.syncPrimaryWaveFromForm(data) + if (!data.id) { + data.wallet = data.extra?.ln_wallet_id || this.g.user.wallets?.[0]?.id + } + const wallet = _.findWhere(this.g.user.wallets, {id: data.wallet}) + if (this.paymentMethods.ln && !data.extra?.ln_wallet_id) { + this.$q.notify({message: 'Select an LN wallet for Lightning payments.', type: 'negative'}) + return + } + if (this.paymentMethods.onchain && !data.extra?.onchain_wallet_id) { + this.$q.notify({message: 'Select an onchain wallet for onchain payments.', type: 'negative'}) + return + } if (data.extra?.promo_codes) { data.extra.promo_codes = this.normalizePromoCodes( data.extra.promo_codes @@ -459,20 +633,36 @@ window.PageEvents = { data.fiat_currency = 'GBP' } } - this.syncPrimaryWaveFromForm(data) - + const methods = [] + if (this.paymentMethods.ln) methods.push('ln') + if (this.paymentMethods.onchain) methods.push('onchain') + if (this.paymentMethods.fiat) methods.push('fiat') + data.extra.payment_methods = methods if (data.id) { this.updateEvent(wallet, data) } else { this.createEvent(wallet, data) } }, - openEventDialog(data = false) { if (data && data.id) { - this.formDialog.data = this.hydrateEventForm(data) + this.formDialog.data = { + ...data, + extra: { + ...(data.extra || {}), + promo_codes: [...((data.extra && data.extra.promo_codes) || [])], + } + } + const pm = (data.extra && data.extra.payment_methods) || [] + this.paymentMethods.ln = pm.includes('ln') + this.paymentMethods.onchain = pm.includes('onchain') + this.paymentMethods.fiat = pm.includes('fiat') + if (!data.extra.fiat_provider) { + data.extra.fiat_provider = '' + } } else { this.formDialog.data = { + wallet: null, currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', @@ -481,42 +671,25 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, - onchain_enabled: false, - onchain_wallet_id: null, - onchain_zeroconf: false, - onchain_fasttrack: false, - ticket_waves: [ - { - id: 'primary', - title: 'Primary wave', - opening_date: '', - closing_date: '', - currency: 'sats', - use_ticket_image: false, - ticket_image_id: null, - allow_fiat: false, - fiat_currency: 'GBP', - amount_tickets: 0, - price_per_ticket: 0 - } - ], promo_codes: [], notification_subject: '', - notification_body: '' + notification_body: '', + payment_methods: [], + ln_wallet_id: null, + onchain_wallet_id: null, + fiat_provider: '' } } + this.paymentMethods.ln = false + this.paymentMethods.onchain = false + this.paymentMethods.fiat = false } this.formDialog.show = true - if ( - this.formDialog.data.wallet && - this.formDialog.data.extra?.onchain_enabled - ) { - this.loadOnchainWallets() - } }, resetEventDialog() { this.formDialog.show = false this.formDialog.data = { + wallet: this.g.user.wallets?.[0]?.id, currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', @@ -525,30 +698,19 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, - onchain_enabled: false, - onchain_wallet_id: null, - ticket_waves: [ - { - id: 'primary', - title: 'Primary wave', - opening_date: '', - closing_date: '', - currency: 'sats', - use_ticket_image: false, - ticket_image_id: null, - allow_fiat: false, - fiat_currency: 'GBP', - amount_tickets: 0, - price_per_ticket: 0 - } - ], promo_codes: [], notification_subject: '', - notification_body: '' + notification_body: '', + payment_methods: [], + ln_wallet_id: null, + onchain_wallet_id: null, + fiat_provider: '' } } + this.paymentMethods.ln = false + this.paymentMethods.onchain = false + this.paymentMethods.fiat = false }, - createEvent(wallet, data) { LNbits.api .request('POST', '/events/api/v1/events', wallet.adminkey, data) @@ -562,250 +724,520 @@ window.PageEvents = { const link = _.findWhere(this.events, {id: formId}) this.openEventDialog(link) }, - openTicketWaveDialog(event, wave = null) { - const primaryWave = (event.extra?.ticket_waves || [])[0] || {} - const isEditing = Boolean(wave) - this.ticketWaveDialog = { + updateEvent(wallet, data) { + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + data.id, + wallet.adminkey, + data + ) + .then(response => { + this.events = _.reject(this.events, function (obj) { + return obj.id == data.id + }) + this.events.push(response.data) + this.resetEventDialog() + }) + .catch(LNbits.utils.notifyApiError) + }, + deleteEvent(eventsId) { + const events = _.findWhere(this.events, {id: eventsId}) + + LNbits.utils + .confirmDialog(this.$t('events.delete_event_confirm')) + .onOk(() => { + LNbits.api + .request( + 'DELETE', + '/events/api/v1/events/' + eventsId, + _.findWhere(this.g.user.wallets, {id: events.wallet}).adminkey + ) + .then(response => { + this.events = _.reject(this.events, function (obj) { + return obj.id == eventsId + }) + }) + .catch(LNbits.utils.notifyApiError) + }) + }, + exporteventsCSV() { + LNbits.utils.exportCSV(this.eventsColumns, this.events) + }, + async checkCanceledEvents() { + const events = this.events + .filter(event => event.extra.conditional) + .filter(e => !e.canceled) + if (!events.length) return + const now = new Date() + events.forEach(async ev => { + if (new Date(ev.event_end_date) < now && ev.sold < ev.extra.min_tickets) { + const {data} = await LNbits.api.request( + 'PUT', + '/events/api/v1/events/' + ev.id + '/cancel', + _.findWhere(this.g.user.wallets, {id: ev.wallet}).adminkey + ) + Quasar.Notify.create({ + type: 'warning', + message: this.$t('events.event_canceled_notify', {name: ev.name}), + icon: null + }) + this.events = this.events.map(e => (e.id === ev.id ? data : e)) + } + }) + }, + + loadTicketTypes(eventId) { + const wallet = _.findWhere(this.g.user.wallets, { + id: _.findWhere(this.events, {id: eventId})?.wallet + }) + if (!wallet) return + LNbits.api + .request( + 'GET', + `/events/api/v1/ticket-types/${eventId}`, + wallet.inkey + ) + .then(response => { + this.ticketTypesByEvent[eventId] = response.data + }) + .catch(LNbits.utils.notifyApiError) + }, + openTicketTypeDialog(eventId, tt = null) { + const event = _.findWhere(this.events, {id: eventId}) + if (!event) return + const isEdit = Boolean(tt) + this.ticketTypeDialog = { show: true, - eventId: event.id, + eventId, + isEdit, wallet: event.wallet, - editingWaveId: wave?.id || null, data: { - id: wave?.id || null, - title: wave?.title || '', - opening_date: wave?.opening_date || '', - closing_date: wave?.closing_date || '', - currency: - wave?.currency || primaryWave.currency || event.currency || 'sats', - use_ticket_image: Boolean(wave?.use_ticket_image), - ticket_image_id: wave?.ticket_image_id || null, - allow_fiat: isEditing - ? Boolean(wave?.allow_fiat) - : Boolean(primaryWave.allow_fiat ?? event.allow_fiat), - fiat_currency: - wave?.fiat_currency || - primaryWave.fiat_currency || - event.fiat_currency || - 'GBP', - amount_tickets: wave?.amount_tickets || 0, - price_per_ticket: - wave?.price_per_ticket || - primaryWave.price_per_ticket || - event.price_per_ticket || - 0 + id: tt?.id || null, + name: tt?.name || '', + description: tt?.description || '', + image_url: tt?.image_url || null, + price: tt?.price || 0, + max_tickets: tt?.max_tickets || 0, + available_from: + tt?.available_from || new Date().toISOString().slice(0, 10), + available_to: tt?.available_to || event.event_end_date || '', + sort_order: tt?.sort_order || 0, + active: tt?.extra?.active ?? true, + show_remaining: tt?.extra?.show_remaining ?? false, + remaining_threshold: tt?.extra?.remaining_threshold ?? 0, + admits: tt?.extra?.admits ?? 1, + cap_group: tt?.extra?.cap_group || '' } } }, - resetTicketWaveDialog() { - this.ticketWaveDialog = { + resetTicketTypeDialog() { + this.ticketTypeDialog = { show: false, eventId: null, + isEdit: false, wallet: null, - editingWaveId: null, data: { id: null, - title: '', - opening_date: '', - closing_date: '', - currency: 'sats', - use_ticket_image: false, - ticket_image_id: null, - allow_fiat: false, - fiat_currency: 'GBP', - amount_tickets: 0, - price_per_ticket: 0 + name: '', + description: '', + image_url: null, + price: 0, + max_tickets: 0, + available_from: '', + available_to: '', + sort_order: 0, + active: true, + admits: 1, + cap_group: '' } } }, - saveTicketWave() { + saveTicketType() { const event = _.findWhere(this.events, { - id: this.ticketWaveDialog.eventId + id: this.ticketTypeDialog.eventId }) const wallet = _.findWhere(this.g.user.wallets, { - id: this.ticketWaveDialog.wallet + id: this.ticketTypeDialog.wallet }) if (!event || !wallet) return const payload = { - ...event, + ...this.ticketTypeDialog.data, + event_id: this.ticketTypeDialog.eventId, extra: { - ...event.extra, - ticket_waves: (event.extra?.ticket_waves || []).map(existingWave => - existingWave.id === this.ticketWaveDialog.editingWaveId - ? {...this.ticketWaveDialog.data} - : existingWave - ) + ...this.ticketTypeDialog.data.extra, + active: this.ticketTypeDialog.data.active, + show_remaining: this.ticketTypeDialog.data.show_remaining, + remaining_threshold: this.ticketTypeDialog.data.remaining_threshold, + // `extra` is REBUILT here, so every derived field has to be carried + // explicitly or saving a type silently drops it (admits, cap_group). + admits: Math.max(1, Number(this.ticketTypeDialog.data.admits) || 1), + ...(this.ticketTypeDialog.data.cap_group + ? {cap_group: this.ticketTypeDialog.data.cap_group.trim()} + : {}) } } - - if (!this.ticketWaveDialog.editingWaveId) { - payload.extra.ticket_waves.push({...this.ticketWaveDialog.data}) + if (!this.ticketTypeDialog.isEdit) { + delete payload.id } + delete payload.active + delete payload.show_remaining + delete payload.remaining_threshold - if (payload.extra?.promo_codes) { - payload.extra.promo_codes = this.normalizePromoCodes( - payload.extra.promo_codes - ) - } + const request = this.ticketTypeDialog.isEdit + ? LNbits.api.request( + 'PUT', + `/events/api/v1/ticket-types/${this.ticketTypeDialog.eventId}/${this.ticketTypeDialog.data.id}`, + wallet.adminkey, + payload + ) + : LNbits.api.request( + 'POST', + `/events/api/v1/ticket-types/${this.ticketTypeDialog.eventId}`, + wallet.adminkey, + payload + ) - LNbits.api - .request( - 'PUT', - '/events/api/v1/events/' + payload.id, - wallet.adminkey, - payload - ) + request .then(response => { - this.events = this.events.map(item => - item.id === payload.id ? response.data : item - ) + const tts = this.ticketTypesByEvent[this.ticketTypeDialog.eventId] || [] + if (this.ticketTypeDialog.isEdit) { + this.ticketTypesByEvent[this.ticketTypeDialog.eventId] = tts.map( + t => (t.id === response.data.id ? response.data : t) + ) + } else { + this.ticketTypesByEvent[this.ticketTypeDialog.eventId] = [ + ...tts, + response.data + ] + } Quasar.Notify.create({ type: 'positive', - message: this.ticketWaveDialog.editingWaveId - ? this.$t('events.ticket_wave_updated') - : this.$t('events.ticket_wave_added'), + message: this.ticketTypeDialog.isEdit + ? this.$t('events.ticket_type_updated') + : this.$t('events.ticket_type_added'), icon: null }) - this.resetTicketWaveDialog() + this.resetTicketTypeDialog() }) .catch(LNbits.utils.notifyApiError) }, - openPromoCodesDialog(event) { - this.promoCodesDialog.data = { - ...event, - extra: { - ...event.extra, - promo_codes: [...(event.extra?.promo_codes || [])] - } - } - this.promoCodesDialog.show = true + deleteTicketType(eventId, ttId) { + const wallet = _.findWhere(this.g.user.wallets, { + id: _.findWhere(this.events, {id: eventId})?.wallet + }) + if (!wallet) return + LNbits.utils + .confirmDialog(this.$t('events.delete_ticket_type_confirm')) + .onOk(() => { + LNbits.api + .request( + 'DELETE', + `/events/api/v1/ticket-types/${eventId}/${ttId}`, + wallet.adminkey + ) + .then(() => { + const tts = this.ticketTypesByEvent[eventId] || [] + this.ticketTypesByEvent[eventId] = tts.filter( + t => t.id !== ttId + ) + Quasar.Notify.create({ + type: 'positive', + message: this.$t('events.ticket_type_deleted'), + icon: null + }) + }) + .catch(LNbits.utils.notifyApiError) + }) }, - resetPromoCodesDialog() { - this.promoCodesDialog.show = false - this.promoCodesDialog.data = { - id: null, - wallet: null, - name: '', - extra: { - promo_codes: [] - } + openEditPromoCodeDialog(eventId, codeIndex) { + const event = _.findWhere(this.events, {id: eventId}) + if (!event) return + const code = event.extra?.promo_codes?.[codeIndex] + if (!code) return + this.editPromoCodeDialog.eventId = eventId + this.editPromoCodeDialog.codeIndex = codeIndex + // Drop the retired `type` axis and hand the dialog a `source` object + // whose list fields are real arrays, which is what q-select needs. + const {type: _legacyType, ...rest} = code + const source = this.normalizePromoSource(code) + this.editPromoCodeDialog.data = { + ...rest, + source: { + require_ticket: source.require_ticket, + event_ids: source.event_ids || [], + ticket_type_ids: source.ticket_type_ids || [], + move: source.move, + credit: source.credit + }, + ticket_type_ids: [...(code.ticket_type_ids || [])], + max_uses: code.max_uses ?? null, + max_tickets: code.max_tickets ?? null, + redemptions_per_ticket: code.redemptions_per_ticket ?? 1 + } + this.editPromoCodeDialog.discountType = + code.discount_fixed != null ? 'fixed' : 'percent' + if (!this.ticketTypesByEvent?.[eventId]) { + this.loadTicketTypes(eventId) } + this.loadPromoScopeTicketTypes([this.editPromoCodeDialog.data], eventId) + this.editPromoCodeDialog.show = true }, - addPromoCodeToDialog() { - this.promoCodesDialog.data.extra.promo_codes.push({ + openAddPromoCodeDialog(eventId) { + this.editPromoCodeDialog.eventId = eventId + this.editPromoCodeDialog.codeIndex = -1 + this.editPromoCodeDialog.data = { code: '', - discount_percent: 0, - active: true - }) + source: { + require_ticket: false, + event_ids: [], + ticket_type_ids: [], + move: false, + credit: 'none' + }, + discount_percent: null, + discount_fixed: null, + active: true, + combinable: true, + max_uses: null, + used_count: 0, + redemptions_per_ticket: 1, + ticket_type_ids: [], + max_tickets: null + } + this.editPromoCodeDialog.discountType = 'percent' + if (!this.ticketTypesByEvent?.[eventId]) { + this.loadTicketTypes(eventId) + } + this.editPromoCodeDialog.show = true }, - savePromoCodes() { - const data = this.promoCodesDialog.data - const wallet = _.findWhere(this.g.user.wallets, { - id: data.wallet - }) + onPromoRequireTicketChange(requireTicket) { + const data = this.editPromoCodeDialog?.data + if (!data) return + // Nothing is cleared here: the ticket-only settings are simply not shown + // (and not used) without a ticket requirement, and they keep their values + // so re-enabling it restores them. The offer code is never blanked either + // -- it identifies the offer and is required when no ticket is involved. + // NB the backend still drops `move`/`credit` on save without a ticket + // requirement; that is a load-safety invariant, not a UI choice. + // max_uses is deliberately left alone in both directions: a ticket rule + // can cap its own usage now ("first 20 upgrades"). + this.loadPromoScopeTicketTypes( + [data], + this.editPromoCodeDialog?.eventId + ) + }, + onPromoMoveChange() { + // Voiding already forces a single-ticket basket at runtime, so the + // basket-size limit is hidden while it is on -- but it keeps its value, + // so turning the toggle back off restores what you had typed. + }, + saveEditPromoCode() { + const {eventId, codeIndex, data, discountType} = this.editPromoCodeDialog + const event = _.findWhere(this.events, {id: eventId}) + if (!event) return + const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) if (!wallet) return + const source = this.normalizePromoSource(data) + const code = {...data, source} + delete code.type + if (discountType === 'fixed') { + code.discount_percent = null + } else { + code.discount_fixed = null + } + // A void upgrade is already strictly one ticket in, one ticket out at + // runtime, so a basket limit is redundant here but KEPT -- voiding the + // ticket and back must not lose a limit you set. + const scope = Array.isArray(code.ticket_type_ids) + ? code.ticket_type_ids.filter(Boolean) + : [] + code.ticket_type_ids = scope.length ? scope : null + if (!code.max_tickets) code.max_tickets = null + code.code = (code.code || '').trim().toUpperCase() + + // Without a ticket requirement the code is the only way in, so it cannot + // be blank. With a ticket requirement a blank code is legitimate: the + // rule is then activated by any eligible ticket id. + if (!source.require_ticket && !code.code) { + Quasar.Notify.create({ + type: 'negative', + message: this.$t('events.promo_code_required'), + icon: null + }) + return + } + + const codes = [...(event.extra?.promo_codes || [])] + if (codeIndex >= 0 && codeIndex < codes.length) { + codes[codeIndex] = code + } else { + codes.push(code) + } + const payload = { - ...data, + ...event, extra: { - ...data.extra, - promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || []) + ...event.extra, + promo_codes: codes } } LNbits.api - .request( - 'PUT', - '/events/api/v1/events/' + data.id, - wallet.adminkey, - payload - ) + .request('PUT', '/events/api/v1/events/' + eventId, wallet.adminkey, payload) .then(response => { - this.events = this.events.map(event => - event.id === data.id ? response.data : event + this.events = this.events.map(e => + e.id === eventId ? response.data : e ) Quasar.Notify.create({ type: 'positive', message: this.$t('events.promo_codes_updated'), icon: null }) - this.resetPromoCodesDialog() + this.editPromoCodeDialog.show = false }) .catch(LNbits.utils.notifyApiError) }, - updateEvent(wallet, data) { + deletePromoCode(eventId, codeIndex) { + const event = _.findWhere(this.events, {id: eventId}) + if (!event) return + const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) + if (!wallet) return + + const codes = [...(event.extra?.promo_codes || [])] + codes.splice(codeIndex, 1) + + const payload = { + ...event, + extra: { + ...event.extra, + promo_codes: codes + } + } + LNbits.api - .request( - 'PUT', - '/events/api/v1/events/' + data.id, - wallet.adminkey, - data - ) + .request('PUT', '/events/api/v1/events/' + eventId, wallet.adminkey, payload) .then(response => { - this.events = _.reject(this.events, function (obj) { - return obj.id == data.id + this.events = this.events.map(e => + e.id === eventId ? response.data : e + ) + Quasar.Notify.create({ + type: 'positive', + message: this.$t('events.promo_codes_updated'), + icon: null }) - this.events.push(response.data) - this.resetEventDialog() + this.editPromoCodeDialog.show = false }) .catch(LNbits.utils.notifyApiError) }, - deleteEvent(eventsId) { - const events = _.findWhere(this.events, {id: eventsId}) + resendAllTicketEmails(eventId) { + const event = _.findWhere(this.events, {id: eventId}) + if (!event) return + const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) + if (!wallet) return - LNbits.utils - .confirmDialog(this.$t('events.delete_event_confirm')) - .onOk(() => { - LNbits.api - .request( - 'DELETE', - '/events/api/v1/events/' + eventsId, - _.findWhere(this.g.user.wallets, {id: events.wallet}).adminkey - ) - .then(response => { - this.events = _.reject(this.events, function (obj) { - return obj.id == eventsId - }) + Quasar.Dialog.create({ + title: this.$t('events.resend_all_emails'), + message: this.$t('events.confirm_resend_all'), + cancel: true, + persistent: true + }).onOk(() => { + this.resendingAllEmailsFor.push(eventId) + LNbits.api + .request( + 'POST', + `/events/api/v1/events/${eventId}/email-tickets`, + wallet.adminkey, + null + ) + .then(response => { + Quasar.Notify.create({ + type: 'positive', + message: this.$t('events.bulk_email_sent'), + icon: null }) - .catch(LNbits.utils.notifyApiError) - }) + }) + .catch(LNbits.utils.notifyApiError) + .finally(() => { + this.resendingAllEmailsFor = this.resendingAllEmailsFor.filter( + id => id !== eventId + ) + }) + }) }, - exporteventsCSV() { - LNbits.utils.exportCSV(this.eventsTable.columns, this.events) + openEmailAllDialog(eventId) { + this.emailAllDialog = { + show: true, + eventId, + subject: '', + message: '', + loading: false + } }, - async checkCanceledEvents() { - const events = this.events - .filter(event => event.extra.conditional) - .filter(e => !e.canceled) - if (!events.length) return - const now = new Date() - events.forEach(async ev => { - if (new Date(ev.closing_date) < now && ev.sold < ev.extra.min_tickets) { - const {data} = await LNbits.api.request( - 'PUT', - '/events/api/v1/events/' + ev.id + '/cancel', - _.findWhere(this.g.user.wallets, {id: ev.wallet}).adminkey - ) + resetEmailAllDialog() { + this.emailAllDialog = { + show: false, + eventId: null, + subject: '', + message: '', + loading: false + } + }, + sendEmailToAttendees() { + if (!this.emailAllDialog.subject || !this.emailAllDialog.message) return + const event = _.findWhere(this.events, {id: this.emailAllDialog.eventId}) + if (!event) return + const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) + if (!wallet) return + + this.emailAllDialog.loading = true + LNbits.api + .request( + 'POST', + `/events/api/v1/events/${this.emailAllDialog.eventId}/email-message`, + wallet.adminkey, + { + subject: this.emailAllDialog.subject, + message: this.emailAllDialog.message + } + ) + .then(() => { Quasar.Notify.create({ - type: 'warning', - message: this.$t('events.event_canceled_notify', {name: ev.name}), + type: 'positive', + message: this.$t('events.bulk_message_sent'), icon: null }) - this.events = this.events.map(e => (e.id === ev.id ? data : e)) - } - }) + this.resetEmailAllDialog() + }) + .catch(LNbits.utils.notifyApiError) + .finally(() => { + this.emailAllDialog.loading = false + }) + }, + previewTicketEmailUrl(event) { + const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) + if (!wallet) return '#' + return `/events/api/v1/events/${event.id}/preview/ticket-email?key=${wallet.inkey}` + }, + previewAdminEmailUrl(event) { + const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) + if (!wallet) return '#' + return `/events/api/v1/events/${event.id}/preview/admin-email?key=${wallet.inkey}` } }, - async created() { + created() { if (this.g.user.wallets.length) { this.getTickets() this.getAllTickets() this.getEvents() + this.fetchWatchOnlyWallets() if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) { this.currencies = ['sats', ...this.g.allowedCurrencies] } else { this.currencies = ['sats', ...this.g.currencies] } } - } + }, } diff --git a/static/js/index.vue b/static/js/index.vue index e7ddaee..2135805 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -1,3 +1,4 @@ +# page-events + diff --git a/static/js/register.js b/static/js/register.js index 1c83c2b..69973b8 100644 --- a/static/js/register.js +++ b/static/js/register.js @@ -1,4 +1,4 @@ -window.PageEventsRegister = { +;window.PageEventsRegister = { template: '#page-events-register', computed: { ticketsColumns() { @@ -7,26 +7,26 @@ window.PageEventsRegister = { name: 'name', align: 'left', label: this.$t('events.col_name'), - field: 'name' + field: row => row.name || this.$t('events.anon') }, { - name: 'email', + name: 'ticket_type', align: 'left', - label: this.$t('email'), - field: 'email' + label: this.$t('events.col_ticket_type'), + field: row => row.ticket_type_name || '-' }, { name: 'id', align: 'left', - label: this.$t('id'), - field: 'id', - format: val => this.shortId(val) + label: 'Ticket ID', + field: 'id' } ] } }, data() { return { + eventName: '', tickets: [], ticketsTable: { pagination: { @@ -37,7 +37,8 @@ window.PageEventsRegister = { show: false, camera: 'auto' }, - lastScan: null + lastScan: null, + ticketTypes: [] } }, methods: { @@ -48,18 +49,30 @@ window.PageEventsRegister = { this.tickets = Quasar.LocalStorage.getItem(this.storageKey()) || [] }, saveScannedTicket(ticket) { + if (!ticket.name) ticket.name = 'Anon' + if (ticket.ticket_type_id && this.ticketTypes.length) { + const tt = this.ticketTypes.find(t => t.id === ticket.ticket_type_id) + if (tt) ticket.ticket_type_name = tt.name + } + const existingIndex = this.tickets.findIndex(t => t.id === ticket.id) + if (existingIndex >= 0) { + this.tickets.splice(existingIndex, 1) + } this.tickets.unshift(ticket) Quasar.LocalStorage.set(this.storageKey(), this.tickets) }, + resolveTypeName(ticket) { + if (ticket.ticket_type_id && this.ticketTypes.length) { + const tt = this.ticketTypes.find(t => t.id === ticket.ticket_type_id) + if (tt) ticket.ticket_type_name = tt.name + } + }, closeCamera() { this.sendCamera.show = false }, showCamera() { this.sendCamera.show = true }, - shortId(id) { - return id ? `${id.slice(0, 6)}...${id.slice(-4)}` : '' - }, decodeQR(res) { this.sendCamera.show = false const value = res[0].rawValue.split('//')[1] @@ -68,7 +81,6 @@ window.PageEventsRegister = { .then(response => { this.saveScannedTicket(response.data) this.lastScan = {success: true, ticket: response.data} - Quasar.Notify.create({type: 'positive', message: 'Registered!'}) }) .catch(error => { this.lastScan = { @@ -77,12 +89,30 @@ window.PageEventsRegister = { error: error.response?.data?.detail || error.message || 'Unknown error' } - LNbits.utils.notifyApiError(error) }) } }, - created() { + async created() { this.eventId = this.$route.params.id + try { + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/events/${this.eventId}` + ) + this.eventName = data.name || '' + } catch (error) { + // ignore + } + try { + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/ticket-types/${this.eventId}` + ) + this.ticketTypes = data || [] + } catch (error) { + this.ticketTypes = [] + } this.loadScannedTickets() + this.tickets.forEach(t => this.resolveTypeName(t)) } } diff --git a/static/js/register.vue b/static/js/register.vue index 24bc242..9b84af9 100644 --- a/static/js/register.vue +++ b/static/js/register.vue @@ -2,61 +2,31 @@
- -
-

-
- -
- - -
+ +
+

+ + + +
- - + +
-
-
- - {{ lastScan.ticket.name }} -
-
- - {{ lastScan.ticket.email }} -
-
- - {{ lastScan.ticket.paid }} -
-
- - {{ shortId(lastScan.ticket.id) }} + +
+
+
+ ID: +
+
-
- - {{ shortId(lastScan.ticketId) }} -
-
- - {{ lastScan.error }} -
+
@@ -81,7 +51,8 @@ diff --git a/static/js/test_addon.py b/static/js/test_addon.py new file mode 100644 index 0000000..8e273d0 --- /dev/null +++ b/static/js/test_addon.py @@ -0,0 +1,365 @@ +"""Add-on ticket entitlements: gated offer codes that GRANT rather than +discount (the Bitflix screening-seat case). + +A grant rule authorises ADDING tickets of another type, only while a +qualifying source ticket is present (in the basket, or presented by id), never +beyond ``per_source x admits`` per source ticket, and never past the add-on +type's counted cap. +""" +import asyncio +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest + +from events import crud +from events import models as M +from events import services as S + +TODAY = datetime.now(timezone.utc).date().isoformat() + +WEEKEND = "WEEKEND" +FAMILY = "FAMILY" +ADDON = "ADDON" + + +async def make_event(addon_cap=100): + rule = M.PromoCode( + code="", + source=M.PromoSource(require_ticket=True, ticket_type_ids=[WEEKEND, FAMILY]), + grants=M.PromoGrant(ticket_type_id=ADDON, per_source=1), + ) + ev = M.Event( + id="EV1", wallet="w1", name="Bitfest", info="", canceled=False, + event_start_date="2026-01-01", event_end_date="2026-12-31", + currency="GBP", admin_email=None, allow_fiat=False, fiat_currency="GBP", + amount_tickets=100, price_per_ticket=149, time=datetime.now(timezone.utc), + sold=0, banner=None, extra=M.EventExtra(promo_codes=[rule]), + ) + await crud.create_event(ev) + for tid, name, price, admits, cap in [ + (WEEKEND, "Single - Weekend", 149, 1, 500), + (FAMILY, "Family - Weekend", 399, 4, 50), + (ADDON, "Bitflix - The Six Billion Dollar Man", 0, 1, addon_cap), + ]: + await crud.create_ticket_type(M.TicketType( + id=tid, event_id="EV1", name=name, price=price, max_tickets=cap, + available_from=TODAY, available_to="2026-12-31", + sort_order=0, extra={"admits": admits}, + )) + return ev + + +async def make_ticket(ticket_id, ticket_type_id, paid=True, event="EV1", extra=None): + t = M.Ticket( + id=ticket_id, wallet="w1", event=event, name="Holder", email="a@b.c", + registered=False, paid=paid, time=datetime.now(timezone.utc), + reg_timestamp=datetime.now(timezone.utc), ticket_type_id=ticket_type_id, + basket_id=None, extra=extra or M.TicketExtra(), + ) + await crud.update_ticket(t) + return t + + +def items(*pairs): + return [SimpleNamespace(ticket_type_id=tid, quantity=q) for tid, q in pairs] + + +async def gate(ev, basket_items, presented=()): + """Run the real resolution + the add-on gate, as the checkout does.""" + ticket_types = await S.get_ticket_types(ev.id) + reservation = await S._resolve_ticket_rules( + ev, list(presented), sum(i.quantity for i in basket_items) + ) + await S._validate_addon_items(ev, basket_items, ticket_types, reservation) + + +# --- model invariants ------------------------------------------------------- + +def test_offer_title_round_trips(): + """A code-less offer has no identifier; `title` names it in the admin.""" + assert M.PromoCode(code="X").title == "" + assert M.PromoCode(code="X", title="Bitflix free seat").title == "Bitflix free seat" + # the case it exists for: a code-less rule (blank code needs a ticket gate) + codeless = M.PromoCode( + code="", source=M.PromoSource(require_ticket=True), title="Screening seat") + assert codeless.code == "" and codeless.title == "Screening seat" + # survives the legacy-axis migration path too + legacy = M.PromoCode(code="TICKETID", type="ticket", title="Old rule") + assert legacy.grants is None and legacy.title == "Old rule" + + +def test_grant_implies_the_source_gate(): + """A stored grant with no explicit source condition still loads, and is + read as requiring a ticket — otherwise the codeless rule is invalid and + the whole event fails to load.""" + promo = M.PromoCode(code="", grants=M.PromoGrant(ticket_type_id=ADDON)) + assert promo.grants is not None + assert promo.source.require_ticket is True + + +def test_grant_keeps_and_ignores_a_discount(): + """A grant must not DESTROY a discount the admin typed — only ignore it, so + flipping the effect back and forth in the UI is lossless.""" + promo = M.PromoCode( + code="", discount_percent=50, + source=M.PromoSource(require_ticket=True), + grants=M.PromoGrant(ticket_type_id=ADDON, per_source=2), + ) + assert promo.grants.per_source == 2 + assert promo.discount_percent == 50, "the typed discount is retained" + assert promo.source.require_ticket is True, "a grant implies the source gate" + + +def test_per_source_must_be_positive(): + with pytest.raises(Exception): + M.PromoGrant(ticket_type_id=ADDON, per_source=0) + + +def test_admits_defaults_and_survives_junk(): + assert S.ticket_admits(None) == 1 + assert S.ticket_admits(M.TicketType( + id="x", event_id="EV1", name="n", available_from=TODAY, + available_to="2026-12-31", extra={})) == 1 + assert S.ticket_admits(M.TicketType( + id="x", event_id="EV1", name="n", available_from=TODAY, + available_to="2026-12-31", extra={"admits": "4"})) == 4 + assert S.ticket_admits(M.TicketType( + id="x", event_id="EV1", name="n", available_from=TODAY, + available_to="2026-12-31", extra={"admits": "junk"})) == 1 + + +# --- the count itself --------------------------------------------------------- + +def test_sold_counts_is_committed_seats_only(): + def mk(i, ttype, paid=True, refunded=False, deactivated=False): + return M.Ticket( + id=i, wallet="w", event="EV1", name="n", email="a@b.c", registered=False, + paid=paid, time=datetime.now(timezone.utc), ticket_type_id=ttype, + extra=M.TicketExtra(refunded=refunded, deactivated=deactivated)) + tickets = [ + mk("1", "A"), mk("2", "A"), + mk("3", "A", paid=False), # unpaid basket + mk("4", "A", refunded=True), # refunded frees the seat + mk("5", "A", deactivated=True), # an upgrade voids its source + mk("6", "B"), + mk("7", None), # no type -> not attributable + ] + assert M.sold_counts(tickets) == {"A": 2, "B": 1} + + +def test_sold_is_derived_not_the_stored_column(): + """The stored `sold` drifts (imported tickets skip the increment). Readers + must derive it from the rows, so a wrong column cannot leak a cap.""" + async def run(): + await make_event() + tt = await crud.get_ticket_type(ADDON) + tt.sold = 999 + await crud.update_ticket_type(tt) + await make_ticket("S1", ADDON) + await make_ticket("S2", ADDON, paid=False) + types = {t.id: t for t in await crud.get_ticket_types("EV1")} + assert types[ADDON].sold == 1, "derived from rows, ignoring the stored 999" + asyncio.run(run()) + + +def test_addon_cap_counts_only_committed_seats(): + async def run(): + ev = await make_event(addon_cap=2) + await make_ticket("U1", ADDON, paid=False) # abandoned + await make_ticket("R1", ADDON, extra=M.TicketExtra(refunded=True)) + await gate(ev, items((WEEKEND, 2), (ADDON, 2))) # 2 committed -> fits + await make_ticket("P1", ADDON) # now 1 committed + with pytest.raises(ValueError): + await gate(ev, items((WEEKEND, 2), (ADDON, 2))) # 1+2 > 2 + await gate(ev, items((WEEKEND, 2), (ADDON, 1))) # only 1 left + asyncio.run(run()) + + +# --- the gate --------------------------------------------------------------- + +def test_addon_alone_is_refused(): + async def run(): + ev = await make_event() + with pytest.raises(ValueError) as exc: + await gate(ev, items((ADDON, 1))) + assert "qualifying ticket" in str(exc.value) + assert "Promo Code box" in str(exc.value), ( + "the message must say WHERE to put the ticket ID" + ) + asyncio.run(run()) + + +def test_weekend_in_the_basket_authorises_one(): + async def run(): + ev = await make_event() + await gate(ev, items((WEEKEND, 1), (ADDON, 1))) # allowed + with pytest.raises(ValueError): + await gate(ev, items((WEEKEND, 1), (ADDON, 2))) # one per person + await gate(ev, items((WEEKEND, 2), (ADDON, 2))) # two tickets, two seats + asyncio.run(run()) + + +def test_family_weekend_authorises_four(): + async def run(): + ev = await make_event() + await gate(ev, items((FAMILY, 1), (ADDON, 4))) # allowed + with pytest.raises(ValueError): + await gate(ev, items((FAMILY, 1), (ADDON, 5))) # one too many + asyncio.run(run()) + + +def test_presented_ticket_authorises_one(): + async def run(): + ev = await make_event() + await make_ticket("SRC1", WEEKEND) + await gate(ev, items((ADDON, 1)), presented=["SRC1"]) # allowed + with pytest.raises(ValueError): + await gate(ev, items((ADDON, 2)), presented=["SRC1"]) + asyncio.run(run()) + + +def test_presented_family_ticket_authorises_four(): + async def run(): + ev = await make_event() + await make_ticket("SRC2", FAMILY) + await gate(ev, items((ADDON, 4)), presented=["SRC2"]) # allowed + with pytest.raises(ValueError): + await gate(ev, items((ADDON, 5)), presented=["SRC2"]) + asyncio.run(run()) + + +def test_cross_event_source_keeps_its_admits(): + """A source ticket belonging to ANOTHER event must still be worth its + admits rather than defaulting to one: the allowance looks its type up.""" + async def run(): + ev = await make_event() + other = M.Event( + id="EV2", wallet="w1", name="Freedom Fest", info="", canceled=False, + event_start_date="2026-01-01", event_end_date="2026-12-31", + currency="GBP", admin_email=None, allow_fiat=False, fiat_currency="GBP", + amount_tickets=10, price_per_ticket=119, time=datetime.now(timezone.utc), + sold=0, banner=None, extra=M.EventExtra(promo_codes=[]), + ) + await crud.create_event(other) + await crud.create_ticket_type(M.TicketType( + id="FF_FAMILY", event_id="EV2", name="Family (Full Access)", price=119, + max_tickets=21, available_from=TODAY, available_to="2026-12-31", + sort_order=0, extra={"admits": 4}, + )) + rule = ev.extra.promo_codes[0] + rule.source.event_ids = ["EV1", "EV2"] + rule.source.ticket_type_ids = [WEEKEND, FAMILY, "FF_FAMILY"] + await crud.update_event(ev) + await crud.update_ticket(M.Ticket( + id="FF1", wallet="w1", event="EV2", name="Holder", email="a@b.c", + registered=False, paid=True, time=datetime.now(timezone.utc), + reg_timestamp=datetime.now(timezone.utc), ticket_type_id="FF_FAMILY", + basket_id=None, extra=M.TicketExtra())) + await gate(ev, items((ADDON, 4)), presented=["FF1"]) # four allowed + with pytest.raises(ValueError): + await gate(ev, items((ADDON, 5)), presented=["FF1"]) # not five + asyncio.run(run()) + + +def test_unpaid_source_is_refused(): + async def run(): + ev = await make_event() + await make_ticket("SRC3", WEEKEND, paid=False) + with pytest.raises(ValueError) as exc: + await gate(ev, items((ADDON, 1)), presented=["SRC3"]) + assert "qualifying ticket" in str(exc.value) + asyncio.run(run()) + + +def test_addon_is_never_its_own_source(): + async def run(): + ev = await make_event() + await make_ticket("A1", ADDON) + with pytest.raises(ValueError): + await gate(ev, items((ADDON, 2)), presented=["A1"]) + asyncio.run(run()) + + +def test_counted_cap_is_enforced(): + async def run(): + ev = await make_event(addon_cap=2) + await make_ticket("B1", ADDON) # 1 already issued + await gate(ev, items((WEEKEND, 2), (ADDON, 1))) # allowance 2, 1 left + with pytest.raises(ValueError) as exc: + await gate(ev, items((WEEKEND, 2), (ADDON, 2))) # 1 left of a cap of 2 + assert "left" in str(exc.value) + asyncio.run(run()) + + +def test_shared_cap_spans_events(): + """Types sharing extra.cap_group consume ONE pool, so issuing seats on + another event's checkout eats this one's headroom.""" + async def run(): + ev = await make_event(addon_cap=3) + await crud.create_ticket_type(M.TicketType( + id="ADDON_FF", event_id="EV2", name="Bitflix (Freedom Fest)", price=0, + max_tickets=3, available_from=TODAY, available_to="2026-12-31", + sort_order=0, extra={"admits": 1, "cap_group": "bitflix"})) + tt = await crud.get_ticket_type(ADDON) + tt.extra = {**tt.extra, "cap_group": "bitflix"} + await crud.update_ticket_type(tt) + await make_ticket("F1", "ADDON_FF", event="EV2") + await make_ticket("F2", "ADDON_FF", event="EV2") + # two weekend tickets authorise two seats, so the CAP is what refuses + await gate(ev, items((WEEKEND, 2), (ADDON, 1))) # 1 seat left of 3 + with pytest.raises(ValueError) as exc: + await gate(ev, items((WEEKEND, 2), (ADDON, 2))) # only 1 left + assert "left" in str(exc.value) + asyncio.run(run()) + + +def test_grant_rule_discounts_nothing(): + """A grant rule must not leak into the discount engine — not even when it + carries a retained discount value (the lossless-UI case).""" + async def run(): + ev = await make_event() + totals = await S.calculate_basket_total( + ev, [{"ticket_type_id": WEEKEND, "quantity": 1}], [] + ) + assert totals.total == 149 and totals.discount == 0, ( + "no discount applied for a grant rule with no source presented" + ) + # now make it carry a discount AND present an eligible source ticket + rule = ev.extra.promo_codes[0] + rule.discount_percent = 50 + await crud.update_event(ev) + await make_ticket("SRC-D", WEEKEND) + totals2 = await S.calculate_basket_total( + ev, [{"ticket_type_id": WEEKEND, "quantity": 1}], ["SRC-D"] + ) + assert totals2.discount == 0 and totals2.total == 149, ( + "a grant rule's retained discount must stay inert" + ) + asyncio.run(run()) + + +# --- end to end: the zero-total claim --------------------------------------- + +def test_zero_total_addon_basket_issues_and_activates(): + async def run(): + ev = await make_event() + await make_ticket("SRC9", WEEKEND) + S._send_basket_notifications_in_background = lambda *a, **k: None + data = M.CreateBasket( + name="Holder", email="a@b.c", + items=[M.BasketItem(ticket_type_id=ADDON, quantity=1)], + promo_codes=["SRC9"], + ) + basket, tickets, totals, charge = await S.create_basket_with_charge( + ev, data, "key", "https://example.test" + ) + assert totals.total == 0, "the add-on is free" + assert charge is None, "no payment is requested for a zero-total basket" + assert basket.paid is True, "zero-total basket activates immediately" + assert len(tickets) == 1 and tickets[0].ticket_type_id == ADDON + src = await crud.get_ticket("SRC9") + assert src.extra.ticket_id_voucher_redemptions[0]["status"] == "completed", ( + "the claim is finalized against the source ticket" + ) + asyncio.run(run()) diff --git a/static/js/ticket.js b/static/js/ticket.js index 6ff55b6..86f05a8 100644 --- a/static/js/ticket.js +++ b/static/js/ticket.js @@ -1,14 +1,41 @@ -window.PageEventsTicket = { +;window.PageEventsTicket = { template: '#page-events-ticket', data() { return { ticketId: null, ticket: null, + eventName: '', + ticketTypeName: '', + ticketName: '', + ticketEmail: '', + purchaseDate: '', + registered: false, + deactivated: false, + paid: false, printMode: false, qrSrc: '' } }, - methods: { + computed: { + ticketStatus() { + if (this.deactivated) return this.$t('events.ticket_deactivated') + if (this.paid) return this.$t('events.ticket_paid') + return this.$t('events.ticket_pending') + } + }, + methods: { + formatDate(value) { + if (!value) return '' + const d = new Date(value) + if (isNaN(d)) return '' + return Quasar.date.formatDate(d, 'YYYY-MM-DD HH:mm') + }, + copyTicketUrl() { + const url = `${window.location.origin}/events/ticket/${this.ticketId}` + navigator.clipboard.writeText(url).then(() => { + this.$q.notify({message: 'Link copied', type: 'positive'}) + }) + }, async printWindow() { this.printMode = true await this.$nextTick() @@ -39,6 +66,14 @@ window.PageEventsTicket = { `/events/api/v1/tickets/${this.ticketId}` ) this.ticket = data + this.eventName = data.event_name || '' + this.ticketTypeName = data.ticket_type_name || '' + this.ticketName = data.name || '' + this.ticketEmail = data.email || '' + this.purchaseDate = this.formatDate(data.time) + this.registered = !!data.registered + this.deactivated = !!data.deactivated + this.paid = !!data.paid } catch (error) { LNbits.utils.notifyApiError(error) } diff --git a/static/js/ticket.vue b/static/js/ticket.vue index a1ea2f8..a67a1c9 100644 --- a/static/js/ticket.vue +++ b/static/js/ticket.vue @@ -1,46 +1,76 @@