diff --git a/README.md b/README.md index cd02134..4ac72c5 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,13 @@ Events includes a shareable ticket scanner, which can be used to register attend - **Stripe session.** The buyer's email is passed as `customer_email` (prefilled and locked on the hosted page); the line item is named after the event; `event_id`, `quantity` and `ticket_ids` ride along as metadata. +- **Promo codes.** `extra.promo_codes` (`code`, `discount_percent`, `active`, + `max_uses`; `used_count` is derived from paid tickets) are organizer-only: they are + never part of public responses. Buyers preview a code with + `POST /events/api/v1/promo/validate/{event_id}` (`{codes, quantity}` → v2-shaped + `BasketTotals` + `currency`); purchase enforces `active` and `max_uses` (each ticket + of a multi-ticket purchase consumes one use) and rejects bad codes with a distinct + `detail`. Updates that omit `extra.promo_codes` keep the stored list. - **Email.** Multipart text + HTML (links, no images) with the **ticket card** attached — a self-describing PNG (site, event, when, where, QR with the instance logo, name on ticket, ticket id) also served at diff --git a/__init__.py b/__init__.py index 394bc6d..e443ce0 100644 --- a/__init__.py +++ b/__init__.py @@ -6,13 +6,19 @@ 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, + promo_api_router, + qr_api_router, + tickets_api_router, +) events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext.include_router(events_generic_router) events_ext.include_router(events_api_router) events_ext.include_router(tickets_api_router) events_ext.include_router(qr_api_router) +events_ext.include_router(promo_api_router) events_static_files = [ { @@ -28,6 +34,15 @@ scheduled_tasks: list[asyncio.Task] = [] # from nostr_hooks.publish_or_delete_nostr_event. nostr_client = None +# Reconciliation sweep for NIP-52 publishes that never reached a relay +# (aiolabs/events#35). Five minutes is well under the window in which a +# stale ticket count matters to a buyer, and the query costs nothing +# when there is no drift — the normal case returns zero rows. +REPUBLISH_SWEEP_INTERVAL = 300 +# Long enough for _start_nostr_client's own 10s wait plus the relay +# handshake, so the first pass isn't guaranteed to fail on a cold boot. +REPUBLISH_SWEEP_FIRST_DELAY = 60 + def events_stop(): for task in scheduled_tasks: @@ -111,5 +126,55 @@ def events_start(): task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events) scheduled_tasks.append(task3) + async def _republish_pending_sweep(): + """Retry NIP-52 publishes that never landed. + + Inventory reaches clients only through the republished calendar + event, and a publish can fail (signer outage) or be skipped + entirely (no signer, no NostrClient) without anything noticing. + Both leave `nostr_publish_pending` set, so this sweep retries + from the DB rather than from an in-memory queue — it survives a + restart, which the previous behaviour did not. + + Quiet by design: on a healthy instance the query returns nothing + and this logs nothing. It only speaks up when there is drift. + """ + from .crud import flag_wave_transitions, get_events_pending_republish + from .nostr_hooks import publish_or_delete_nostr_event + + await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY) + while True: + try: + # Wave boundaries are time-driven, so nothing else flags + # them. Done here rather than on a timer of its own: the + # boundary is day-granular, so one sweep interval of + # staleness is immaterial (aiolabs/events#61). + moved = await flag_wave_transitions() + if moved: + logger.info( + f"[EVENTS] Republish sweep: {moved} event(s) changed " + f"ticket wave" + ) + pending = await get_events_pending_republish() + if pending: + total = len(pending) + logger.info(f"[EVENTS] Republish sweep: {total} event(s) pending") + recovered = 0 + for event in pending: + take_down = event.canceled or event.status != "approved" + if await publish_or_delete_nostr_event(event, delete=take_down): + recovered += 1 + logger.info( + f"[EVENTS] Republish sweep: {recovered}/{total} recovered" + ) + except Exception as exc: + logger.error(f"[EVENTS] Republish sweep failed: {exc}") + await asyncio.sleep(REPUBLISH_SWEEP_INTERVAL) + + task4 = create_permanent_unique_task( + "ext_events_republish_sweep", _republish_pending_sweep + ) + scheduled_tasks.append(task4) + __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] diff --git a/config.json b/config.json index 68e908a..f7e0ec1 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.10", + "version": "1.6.8-aio.4", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", diff --git a/crud.py b/crud.py index bd5d3c1..52ce233 100644 --- a/crud.py +++ b/crud.py @@ -1,10 +1,20 @@ import json from datetime import datetime, timedelta, timezone +from typing import cast -from lnbits.db import Database +from lnbits.db import Database, Filters, Page from lnbits.helpers import urlsafe_short_hash -from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra +from .models import ( + CreateEvent, + Event, + EventsSettings, + Ticket, + TicketExtra, + TicketFilters, + advertised_wave_key, + sync_event_ticket_waves, +) db = Database("ext_events") @@ -155,6 +165,31 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: return [Ticket(**_parse_ticket_row(row)) for row in rows] +async def get_tickets_paginated( + wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None +) -> Page[Ticket]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + wallet_where = [] + values = {} + for idx, wallet_id in enumerate(wallet_ids): + key = f"wallet_id_{idx}" + wallet_where.append(f":{key}") + values[key] = wallet_id + + where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"] + + return await db.fetch_page( + "SELECT * FROM events.ticket", + where=where, + values=values, + filters=filters, + model=Ticket, + table_name="events.ticket", + ) + + async def delete_ticket(payment_hash: str) -> None: await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) @@ -184,44 +219,55 @@ async def create_event(data: CreateEvent) -> Event: if not data.closing_date: data.closing_date = data.event_end_date event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) + event = cast(Event, sync_event_ticket_waves(event)) await db.insert("events.events", event) return event async def update_event(event: Event) -> Event: + event = cast(Event, sync_event_ticket_waves(event)) await db.update("events.events", event) return event async def get_event(event_id: str) -> Event | None: - return await db.fetchone( + event = await db.fetchone( "SELECT * FROM events.events WHERE id = :id", {"id": event_id}, Event, ) + return cast(Event, sync_event_ticket_waves(event)) if event else None async def get_events(wallet_ids: str | list[str]) -> list[Event]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) - return await db.fetchall( + events = await db.fetchall( f"SELECT * FROM events.events WHERE wallet IN ({q})", model=Event, ) + return [cast(Event, sync_event_ticket_waves(event)) for event in events] async def get_all_events() -> list[Event]: """All events, no wallet filter. Admin-only callers.""" - return await db.fetchall( + events = await db.fetchall( "SELECT * FROM events.events ORDER BY time DESC", model=Event, ) + # Wave-sync on read, exactly as upstream's `get_event` / `get_events` + # do. These four getters are fork-only, so upstream's v1.6.8 diff + # never reached them — and two of them publish: `get_all_events` + # backs /republish-all and `get_events_pending_republish` drives the + # #55 sweep. Without this they would emit the stale roll-up rather + # than the current per-wave figures. + return [cast(Event, sync_event_ticket_waves(e)) for e in events] async def get_public_events() -> list[Event]: """Approved, non-canceled events for the public listing.""" - return await db.fetchall( + events = await db.fetchall( """ SELECT * FROM events.events WHERE status = 'approved' AND canceled = FALSE @@ -229,14 +275,97 @@ async def get_public_events() -> list[Event]: """, model=Event, ) + # Wave-sync on read, exactly as upstream's `get_event` / `get_events` + # do. These four getters are fork-only, so upstream's v1.6.8 diff + # never reached them — and two of them publish: `get_all_events` + # backs /republish-all and `get_events_pending_republish` drives the + # #55 sweep. Without this they would emit the stale roll-up rather + # than the current per-wave figures. + return [cast(Event, sync_event_ticket_waves(e)) for e in events] async def get_pending_events() -> list[Event]: """Proposed events awaiting admin approval.""" - return await db.fetchall( + events = await db.fetchall( "SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC", model=Event, ) + # Wave-sync on read, exactly as upstream's `get_event` / `get_events` + # do. These four getters are fork-only, so upstream's v1.6.8 diff + # never reached them — and two of them publish: `get_all_events` + # backs /republish-all and `get_events_pending_republish` drives the + # #55 sweep. Without this they would emit the stale roll-up rather + # than the current per-wave figures. + return [cast(Event, sync_event_ticket_waves(e)) for e in events] + + +async def flag_wave_transitions() -> int: + """Flag events whose advertised ticket wave has moved on. + + Every republish this extension performs is *sale*-driven. A wave + boundary is a *date* boundary, so when early bird closes at midnight + nothing fires and the relay keeps serving the closed wave's price until + the next ticket happens to sell (aiolabs/events#61). + + Rather than add a scheduler and a second publish path, this compares the + wave a row would advertise now against the one its last successful + publish did (`nostr_published_wave_id`) and sets `nostr_publish_pending` + on a mismatch — handing the work to the existing reconciliation sweep, + which already retries, survives restarts and logs. + + Rows with NULL `nostr_published_wave_id` are skipped: that means "never + published, or published before the column existed", which is no evidence + of drift. Flagging them would republish the whole table on first boot + after the upgrade. + + Returns the number of rows newly flagged. + """ + events = await db.fetchall( + """ + SELECT * FROM events.events + WHERE nostr_published_wave_id IS NOT NULL + AND nostr_publish_pending = FALSE + AND canceled = FALSE + AND status = 'approved' + """, + model=Event, + ) + flagged = 0 + for event in events: + event = cast(Event, sync_event_ticket_waves(event)) + if advertised_wave_key(event) == event.nostr_published_wave_id: + continue + event.nostr_publish_pending = True + await update_event(event) + flagged += 1 + return flagged + + +async def get_events_pending_republish() -> list[Event]: + """Events whose relay copy may be behind this row. + + `nostr_publish_pending` is set before every publish attempt and + cleared only on a confirmed success, so a row still flagged here + either failed to publish or never got the chance. Drives the + reconciliation sweep in `events_start`. + + Ordered oldest-first so a backlog drains in the order it accrued. + """ + events = await db.fetchall( + """ + SELECT * FROM events.events + WHERE nostr_publish_pending = TRUE + ORDER BY time ASC + """, + model=Event, + ) + # Wave-sync on read, exactly as upstream's `get_event` / `get_events` + # do. These four getters are fork-only, so upstream's v1.6.8 diff + # never reached them — and two of them publish: `get_all_events` + # backs /republish-all and `get_events_pending_republish` drives the + # #55 sweep. Without this they would emit the stale roll-up rather + # than the current per-wave figures. + return [cast(Event, sync_event_ticket_waves(e)) for e in events] async def get_settings() -> EventsSettings: diff --git a/docs/rebase-playbook.md b/docs/rebase-playbook.md new file mode 100644 index 0000000..affbce7 --- /dev/null +++ b/docs/rebase-playbook.md @@ -0,0 +1,173 @@ +# Rebase playbook + +How to merge an upstream release into this fork without shipping the +failures a clean `git merge` cannot see. + +Written during the v1.6.1 → v1.6.8 rebase (#33) after the first two +instances showed up within minutes of each other. Both were textually +clean merges that would have been semantically wrong in production. +Modes C and D were added later in the same rebase, from `services.py`. + +## The four failure modes + +Git resolves _text_. Neither of these produces a conflict marker. + +### A. Missed application + +Upstream establishes an invariant and applies it at every call site **it +knows about**. The fork has additional call sites upstream cannot see, +so the invariant silently does not hold there. + +> **Worked example.** v1.6.8 made `event.amount_tickets` a per-wave +> roll-up recomputed by `sync_event_ticket_waves`, and added that call to +> `get_event` and `get_events` in `crud.py`. Both merged cleanly. But the +> fork has four getters upstream never had — `get_all_events`, +> `get_public_events`, `get_pending_events`, +> `get_events_pending_republish` — and two of them _publish_ +> (`/republish-all` and the #55 sweep). Without the same call they emit +> the stale roll-up, so waves would have been wrong on exactly the paths +> that push to relays, and nowhere else. + +### B. Changed meaning + +Upstream redefines what an existing field _means_. Fork code that reads +it is untouched by the diff and keeps compiling, while now saying +something false. + +> **Worked example.** After `sync_event_ticket_waves`, +> `event.price_per_ticket` is the **primary (first)** wave's price and +> `event.amount_tickets` is the **sum across all waves**. Our +> `nostr_publisher.build_nip52_event` reads both. Merged untouched, it +> would advertise the early-bird price after early-bird closed, and count +> stock in waves that have not opened. See #61. + +### C. Misplaced conflict boundary + +Git anchors a conflict on whatever lines happen to match. When both sides +rewrote the same region, an _incidental_ shared line inside it can become +the anchor — and everything past that line lands **outside** the markers, +where it reads as cleanly merged. + +Resolving only what sits between the markers then leaves **both** +implementations in the file. Python does not complain: the later `def` +silently wins. Since the merge appends upstream after ours, the survivor +is upstream's — the fork's version is shadowed without a single warning. + +> **Worked example.** In `services.py` the notification stack conflicted. +> Ours (220 lines: multipart HTML mail, the QR-card attachment, the +> Date/Message-ID headers that keep SpamAssassin quiet, npub DM support) +> appeared between the markers; upstream's showed as 4 lines. But both +> sides define the _same nine functions_, and git had anchored on a +> shared `_send_nostr_ticket_notification` line — so upstream's entire +> parallel stack sat below `>>>>>>>`, looking merged. Taking "ours" and +> moving on would have left upstream's definitions last in the file and +> therefore live, quietly reverting every one of those features. + +**Do not trust the marker as the edit's boundary.** Before resolving a +hunk, list the function names on each side and compare them to the names +already in the file: + +```sh +grep -o '^\(async \)\?def \w*' .py | sed 's/.*def //' | sort | uniq -d +``` + +Run that per file **as you resolve**, not at the end. `ruff` does not +flag redefinition (verified: F ruleset passes on a duplicated `def`). +Only `mypy` does, via `no-redef` — and mypy refuses to run at all while +any file in the package still has conflict markers, so the one tool that +catches mode C is unavailable for the whole merge. The `uniq -d` line +above is the substitute. + +### D. Collided names + +Ours and upstream independently grew a function with the **same name for +a different feature**. Every resolution that reads as sane — take ours, +take theirs, take "the newer one" — silently deletes a feature, and the +diff looks like an ordinary reconciliation of one function. + +> **Worked example.** Both sides had `_ticket_image_url`. Ours: the +> rendered QR ticket-card PNG, always attached, served by this extension +> off `lnbits_baseurl`. Upstream's: an organiser-uploaded template, opt-in +> per wave via `use_ticket_image`, served off `ticket_base_url` and +> returning `None` when the wave has not enabled it. Same name, different +> arity, different return type, unrelated features. Resolved by renaming +> ours to `_ticket_card_url` and keeping both — upstream's ticket-image +> upload UI had already merged into `index.vue`, so dropping their backend +> would have orphaned live UI. + +Signals worth stopping on: the two versions differ in **arity**, in +**return type** (`str` vs `str | None`), or in which setting they build a +URL from. Any of those means it is probably not one function with two +histories. + +## The procedure + +Run this _after_ the merge resolves and _before_ the release. + +### 1. Enumerate what upstream introduced + +```sh +MB=$(git merge-base HEAD upstream/main) +# new public names +git diff $MB.. -- '*.py' | grep -E "^\+(def |class |async def )" +# fields whose meaning was redefined +git show :models.py | sed -n '/^def sync_event_ticket_waves/,/return event/p' +``` + +Split the result into **new symbols** (mode A candidates) and +**redefined fields** (mode B candidates). + +### 2. For each new symbol upstream _calls_, find the fork-only siblings + +Ask what category of place the call belongs to — "every function that +returns an `Event` from the DB", "every path that prices a ticket" — then +enumerate that whole category in the merged tree and check coverage. + +```sh +grep -n "sync_event_ticket_waves" crud.py # where upstream put it +grep -n "^async def get_.*-> \(list\[\)\?Event" crud.py # where it belongs +``` + +The gap between those two lists is the work. + +### 3. For each redefined field, grep the fork-only files + +The 30-odd files upstream has never seen are where mode B hides, because +nothing in the diff touches them: + +```sh +git diff --name-only $MB..HEAD > /tmp/fork.txt +git diff --name-only $MB.. > /tmp/up.txt +comm -23 <(sort /tmp/fork.txt) <(sort /tmp/up.txt) # fork-only files +grep -n "price_per_ticket\|amount_tickets" $(comm -23 ...) +``` + +### 4. Prove each finding before fixing it + +Both examples above were confirmed by reading the code path end to end, +not inferred from the diff. A wrong theory costs more than the check: +during this rebase an inference that `amount_tickets` "goes stale on +sale" was wrong — `sync_event_ticket_waves` also runs on _reads_, which +only the call-site list showed. + +### 5. Write the reason at the site + +Every fix from this procedure gets a comment saying **why upstream's diff +missed it**. That is what stops the next rebase re-dropping it, and it is +the only durable record that the omission was considered rather than +overlooked. + +## Checklist + +- [ ] `migrations.py` still byte-identical to upstream +- [ ] New upstream symbols enumerated; each call-site category audited +- [ ] Redefined fields enumerated; every fork-only reader checked +- [ ] Fork-only files listed and grepped for both modes +- [ ] Every resolved file checked for duplicate `def`s (mode C) — as it is + resolved, since mypy cannot run until the whole package is clean +- [ ] Same-named functions on both sides compared by arity and return type + before being treated as one function (mode D) +- [ ] Publishing paths specifically audited — they fail silently and + externally, so they are the worst place for either mode to land +- [ ] Every fix carries a comment explaining the omission +- [ ] Deviations recorded in `docs/upstream-candidates.md` diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md index 93e6c2f..f2ceb98 100644 --- a/docs/upstream-candidates.md +++ b/docs/upstream-candidates.md @@ -4,16 +4,18 @@ Running log of fork features that are shaped so they could be offered to `lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here in an upstream-compatible form; strike it when the PR merges upstream. -| Feature | Where | Upstream target | Readiness | -| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | -| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | -| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | -| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | -| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | -| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | -| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | -| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | -| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | -| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | -| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | +| Feature | Where | Upstream target | Readiness | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | +| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | +| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | +| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | +| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | +| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | +| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | +| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | +| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | +| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | +| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | +| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 | +| NIP-52 tags describe the **active** ticket wave (cheapest open wave; `tickets_available: 0` when none is open), plus `nostr_published_wave_id` so the reconciliation sweep republishes at wave boundaries | `nostr_publisher.py`, `crud.py` `flag_wave_transitions`, `migrations_fork.py` m004 | lnbits/events #46 (rides with the NIP-52 publishing PR) | **deviation, deliberate** — upstream has waves but publishes no calendar event, so there is nothing upstream to match. Several waves can be open at once and a publisher cannot ask which one the buyer wants (upstream's purchase path errors with "Please select a ticket wave"), so it advertises the cheapest — the price a buyer can actually obtain. Rationale and the rejected alternatives: aiolabs/events#61 | diff --git a/migrations_fork.py b/migrations_fork.py index ebe65a2..cfab24d 100644 --- a/migrations_fork.py +++ b/migrations_fork.py @@ -127,3 +127,61 @@ async def m002_ticket_payment_hash(db): "UPDATE events.ticket SET payment_hash = id " "WHERE payment_hash IS NULL OR payment_hash = ''" ) + + +async def m003_event_nostr_publish_pending(db): + """ + Add `events.nostr_publish_pending` — the marker that makes NIP-52 + publish drift queryable instead of invisible. + + Inventory reaches clients only through the republished calendar + event. When that publish doesn't land, the relay keeps serving the + counts it last saw and nothing anywhere records the divergence; it + has twice been caught only by a human reading a public page + (aiolabs/events#35, #51). + + The flag is set before each publish attempt and cleared only on a + confirmed success, so it covers *both* observed failure shapes: + an attempt that raised (a signer outage) and an attempt that was + never made at all (no signer resolved, no NostrClient). A periodic + sweep republishes whatever is still marked. + + Existing rows default to FALSE rather than TRUE: on upgrade we have + no evidence they're stale, and marking the whole table pending would + stampede the signer with a full-table republish on first boot. + `/republish-all` is the deliberate way to force that. + """ + await _alter_add_column_safe( + db, + "ALTER TABLE events.events " + "ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE", + ) + + +async def m004_event_nostr_published_wave(db): + """ + Add `events.nostr_published_wave_id` — which ticket wave the last + successful NIP-52 publish advertised. + + Since upstream v1.6.8 price and inventory live on time-boxed waves, so + what a calendar event should advertise changes at a *date* boundary. + Every republish we have is sale-driven, and no sale happens at + midnight when early bird ends — so without this the relay keeps + serving the closed wave's price until the next ticket sells + (aiolabs/events#61). + + Recording the advertised wave makes that drift detectable with the + machinery already in place: the reconciliation sweep compares this + against the wave that would be advertised now and sets + `nostr_publish_pending`, reusing the existing retry path rather than + adding a scheduler. + + NULL on existing rows means "never published, or published before this + column existed". The sweep treats NULL as "no evidence of drift" and + leaves it alone, so an upgrade does not stampede the signer with a + full-table republish; the first ordinary publish fills it in. + """ + await _alter_add_column_safe( + db, + "ALTER TABLE events.events ADD COLUMN nostr_published_wave_id TEXT", + ) diff --git a/models.py b/models.py index e36c60a..c5a1131 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,9 @@ import json -from datetime import datetime +from datetime import date, datetime from urllib.parse import urlsplit +from uuid import uuid4 +from lnbits.db import FilterModel from pydantic import BaseModel, EmailStr, Field, root_validator, validator PAYMENT_METHODS = ("lightning", "fiat") @@ -11,20 +13,56 @@ class PromoCode(BaseModel): code: str discount_percent: float = 0.0 active: bool = True + # Redemption cap; None / 0 = unlimited. Field names follow upstream v2. + max_uses: int | None = None + # Derived on read from PAID tickets whose extra.applied_promo_code matches + # (see promo.promo_usage / services.hydrate_promo_usage). Whatever a + # client sends back here is ignored — it is never the source of truth. + used_count: int = 0 - # make the promo code uppercase + # stored form: stripped + upper-case, never empty @validator("code") def uppercase_code(cls, v): - return v.upper() + v = (v or "").strip().upper() + if not v: + raise ValueError("Promo code cannot be empty.") + return v @validator("discount_percent") def validate_discount_percent(cls, v): assert 0 <= v <= 100, "Discount must be between 0 and 100." return v + @validator("max_uses", pre=True) + def normalize_max_uses(cls, v): + if v in (None, "", 0, "0"): + return None + v = int(v) + if v < 1: + raise ValueError("max_uses must be at least 1.") + return v + + +class TicketWave(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex[:8]) + title: str = "Primary wave" + opening_date: str + closing_date: str + currency: str = "sat" + use_ticket_image: bool = False + ticket_image_id: str | None = None + allow_fiat: bool = False + fiat_currency: str = "GBP" + amount_tickets: int = Field(default=0, ge=0) + price_per_ticket: float = Field(default=0, ge=0) + + +class EventExtraBase(BaseModel): + """Everything in `extra` that is safe to show anyone — ticket waves + included, since a buyer needs a wave id to choose one. `EventExtra` adds + the organizer-only promo codes on top; `PublicEventExtra` is this base, + so anonymous responses can never carry them.""" -class EventExtra(BaseModel): - promo_codes: list[PromoCode] = Field(default_factory=list) conditional: bool = False min_tickets: int = 1 email_notifications: bool = False @@ -36,6 +74,19 @@ class EventExtra(BaseModel): # `effective_payment_methods`. Same field name/shape as upstream v2 so the # eventual rebase (#33) merges cleanly. payment_methods: list[str] = Field(default_factory=list) + # Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The + # event-level `currency` / `allow_fiat` / `amount_tickets` / + # `price_per_ticket` fields become derived values (see + # `sync_event_ticket_waves`), which is why fork code that reads them + # needs auditing — aiolabs/events#61. + # + # Public, not organizer-only: a buyer cannot choose a wave without its + # id, and neither the public event response nor the NIP-52 tags carried + # one before. A wave holds price, dates and remaining stock — the sales + # information a buyer needs — so the only thing exposing it reveals is + # the upcoming price schedule, which is what #61 regretted giving up + # when it settled on flat Nostr tags. + ticket_waves: list[TicketWave] = Field(default_factory=list) @validator("payment_methods", pre=True) def normalize_payment_methods(cls, v): @@ -53,6 +104,13 @@ class EventExtra(BaseModel): return seen +class EventExtra(EventExtraBase): + promo_codes: list[PromoCode] = Field(default_factory=list) + + +PublicEventExtra = EventExtraBase + + class CreateEvent(BaseModel): wallet: str | None = None # filled from caller's wallet if absent name: str # title (required) @@ -65,7 +123,12 @@ class CreateEvent(BaseModel): currency: str = "sat" allow_fiat: bool = False fiat_currency: str = "GBP" - amount_tickets: int = 0 # 0 = unlimited / not ticketed + # Capacity is always required and there is no unlimited (#34): a zero + # here means sold out / not sellable, which `api_get_event` and + # `api_ticket_create` both enforce with a 410. Under v1.6.8 waves this + # is only the seed for the primary wave — `sync_event_ticket_waves` + # recomputes it as the sum of every wave's remaining stock. + amount_tickets: int = 0 price_per_ticket: float = 0 # 0 = free banner: str | None = None location: str | None = None # venue/address (NIP-52 'location' tag) @@ -97,6 +160,16 @@ class Event(BaseModel): status: str = "approved" nostr_event_id: str | None = None nostr_event_created_at: int | None = None + # Set before every publish attempt, cleared on confirmed success. + # True means the relay's copy may be behind this row — see + # migrations_fork.m003 and the sweep in __init__.events_start. + nostr_publish_pending: bool = False + # Which wave the last successful publish advertised (see + # `advertised_wave_key`). NULL = never published; "" = published while + # nothing was on sale. The sweep compares this against the current key + # to catch wave boundaries, which are time-driven and so fire no + # sale-triggered republish (aiolabs/events#61). + nostr_published_wave_id: str | None = None @validator("categories", pre=True) def parse_categories(cls, v): @@ -120,7 +193,9 @@ class PublicEvent(BaseModel): banner: str | None location: str | None = None categories: list[str] = Field(default_factory=list) - extra: EventExtra = Field(default_factory=EventExtra) + # PublicEventExtra: promo codes are organizer-only (a buyer who can read + # every code can mint every discount). + extra: PublicEventExtra = Field(default_factory=PublicEventExtra) status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner @validator("categories", pre=True) @@ -130,22 +205,69 @@ class PublicEvent(BaseModel): return v or [] -def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]: +def effective_payment_methods( + event: "Event | PublicEvent | CreateEvent", + wave: "TicketWave | None" = None, +) -> list[str]: """Rails a buyer may pick for `event`. Explicit `extra.payment_methods` wins; an empty list falls back to the pre-#payment-methods rule so events created before the field existed keep behaving the same (Lightning always, fiat iff `allow_fiat`). + + Pass `wave` when the answer is about one specific ticket wave. Fiat is a + per-wave opt-in since v1.6.8 and `event.allow_fiat` is only the PRIMARY + wave's, so without it a publisher can advertise a fiat rail for an + advertised wave that does not accept fiat — which the purchase endpoint + then rejects (aiolabs/events#61). Callers asking the event-level + question ("which rails did the organiser enable at all") leave it unset. """ explicit = list(getattr(event.extra, "payment_methods", []) or []) if explicit: + # The organiser's rail list is event-level, but fiat is a per-wave + # opt-in. Asking about a specific wave means asking what a buyer can + # actually use for it, so drop a rail that wave cannot honour — + # otherwise the NIP-52 tag advertises fiat and the checkout offers a + # card button that `api_ticket_create` then refuses with "Fiat + # payments are not enabled for this ticket wave." + if wave is not None and not wave.allow_fiat: + return [method for method in explicit if method != "fiat"] return explicit methods = ["lightning"] - if event.allow_fiat: + if wave.allow_fiat if wave is not None else event.allow_fiat: methods.append("fiat") return methods +class PromoValidateRequest(BaseModel): + """Upstream v2 shape. v2 sends `items` (ticket types); this fork prices a + plain `quantity` against one ticket wave. + + `ticket_wave_id` may be omitted when exactly one wave is open, matching + how the purchase endpoint resolves it — the preview has to price the same + wave the invoice will, and since v1.6.8 price and currency are per-wave. + """ + + codes: list[str] = Field(default_factory=list) + quantity: int = Field(default=1, ge=1, le=10) + ticket_wave_id: str | None = None + + +class BasketDiscount(BaseModel): + code: str + discount_percent: float | None = None + discount_fixed: int | None = None # always None here (percent-only); v2 shape + amount_saved: float = 0 + + +class BasketTotals(BaseModel): + subtotal: float = 0 + discount: float = 0 + total: float = 0 + discounts_applied: list[BasketDiscount] = Field(default_factory=list) + currency: str = "sat" # fork addition so a client can format the numbers + + class EventsSettings(BaseModel): """Extension-level settings for the events extension.""" @@ -154,6 +276,8 @@ class EventsSettings(BaseModel): class TicketExtra(BaseModel): applied_promo_code: str | None = None + ticket_wave_id: str | None = None + ticket_wave_title: str | None = None sats_paid: int | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -161,12 +285,18 @@ class TicketExtra(BaseModel): email_notification_sent: bool = False nostr_notification_sent: bool = False refunded: bool = False + # On-chain vocabulary, populated once native lnbits on-chain lands + # (aiolabs/events#41). Upstream's field names, minus the SatsPay charge + # id — SatsPay is the implementation we are not adopting. + onchain: bool = False + onchain_address: str | None = None class CreateTicket(BaseModel): name: str | None = None email: EmailStr | None = None user_id: str | None = None # LNbits user id (alternative to name+email) + ticket_wave_id: str | None = None promo_code: str | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -268,3 +398,124 @@ class TicketPaymentRequest(BaseModel): # the door). Buyers fetch these after payment to render N QRs in # My Tickets. ticket_ids: list[str] = Field(default_factory=list) + onchain_amount_sat: int | None = None + + +class TicketFilters(FilterModel): + __search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012 + __sort_fields__ = [ # noqa: RUF012 + "time", + "event", + "name", + "email", + "registered", + "id", + ] + + event: str | None = None + name: str | None = None + email: str | None = None + registered: bool | None = None + paid: bool | None = None + id: str | None = None + + +def _parse_date(value: str) -> date: + """Date component of `value`. + + Upstream only ever produces bare `YYYY-MM-DD` here, so its version is a + plain `strptime(value, "%Y-%m-%d")`. In this fork `event_end_date` may + carry a time (start/end times, v1.3.0-aio.3) and `create_event` defaults + `closing_date` to it, so a wave derived from an event inherits the full + ISO datetime and upstream's parser raises + `ValueError: unconverted data remains: T18:00:00` — on the purchase path, + the public event gate, and the promo preview. + """ + return date.fromisoformat(value[:10]) + + +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 + + # `TicketWave` requires both dates; `Event.closing_date` is Optional in + # this fork (it defaults from event_end_date at create time), so fall + # back the same way `create_event` does rather than handing None to a + # required field. + closing_date = event.closing_date or event.event_end_date or event.event_start_date + + 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 = closing_date + + return [ + TicketWave( + id="primary", + title="Primary wave", + opening_date=fallback_opening_date, + closing_date=closing_date, + currency=event.currency, + allow_fiat=event.allow_fiat, + fiat_currency=event.fiat_currency, + amount_tickets=getattr(event, "amount_tickets", 0), + price_per_ticket=event.price_per_ticket, + ) + ] + + +def advertised_ticket_wave(event: "Event | PublicEvent") -> "TicketWave | None": + """The wave a public listing should describe, or None when nothing is + on sale (sold out, between waves, or not yet open). + + Several waves can be open at once. The purchase endpoint refuses to + guess; a publisher has no one to ask, so it advertises the CHEAPEST + open wave — the price a buyer is actually able to obtain. + + Shared by the NIP-52 publisher and the wave-transition detector so the + two cannot disagree about which wave is currently being advertised. + """ + active = get_active_ticket_waves(event) + if not active: + return None + return min(active, key=lambda wave: wave.price_per_ticket) + + +def advertised_wave_key(event: "Event | PublicEvent") -> str: + """Stable key for what a publish advertised. Empty string means "nothing + on sale", which is a real published state and distinct from NULL in + `nostr_published_wave_id` (never published).""" + wave = advertised_ticket_wave(event) + return wave.id if wave else "" + + +def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent: + ticket_waves = ensure_ticket_waves(event) + event.extra.ticket_waves = ticket_waves + + primary_wave = ticket_waves[0] + event.closing_date = max(wave.closing_date for wave in ticket_waves) + event.currency = primary_wave.currency + event.allow_fiat = primary_wave.allow_fiat + event.fiat_currency = primary_wave.fiat_currency + event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves) + event.price_per_ticket = primary_wave.price_per_ticket + + return event + + +def get_active_ticket_waves( + event: Event | PublicEvent, today: date | None = None +) -> list[TicketWave]: + current_day = today or datetime.utcnow().date() + return [ + wave + for wave in ensure_ticket_waves(event) + if _parse_date(wave.opening_date) + <= current_day + <= _parse_date(wave.closing_date) + and wave.amount_tickets > 0 + ] diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 4de332f..9afa0be 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -19,6 +19,14 @@ from websocket import WebSocketApp from .event import NostrEvent MAX_SEEN_EVENTS = 500 +# How many times a dequeued req is retried before it is dropped. Bounded +# so one unsendable message can't block every later publish behind it. +MAX_SEND_ATTEMPTS = 3 +# How long to wait for the relay's `OK` before treating a publish as +# unconfirmed. nostrclient's router answers within its own +# PUBLISH_TIMEOUT_SECONDS (10s) even when every relay stays silent, so +# this only needs headroom over that. +PUBLISH_OK_TIMEOUT_SECONDS = 12 class NostrClient: @@ -29,6 +37,10 @@ class NostrClient: self.subscription_id = "events-" + urlsafe_short_hash()[:32] self.running = False self._seen_events: OrderedDict[str, None] = OrderedDict() + # event id -> future awaiting that publish's `OK`. Resolved in + # `get_event`, which is the single point where relay messages + # cross into the event loop. + self._pending_oks: dict[str, asyncio.Future] = {} @property def is_websocket_connected(self): @@ -78,17 +90,37 @@ class NostrClient: async def run_forever(self): self.running = True + # A req that was dequeued but whose send raised. It is already + # off the queue, so dropping it loses the publish outright and + # the caller has long since been told it succeeded (the queue + # put returns immediately). Hold it across the reconnect and + # retry instead. + held_req: list | None = None + held_attempts = 0 while self.running: try: if not self.is_websocket_connected: self.ws = await self.connect() await asyncio.sleep(5) - req = await self.send_req_queue.get() + if held_req is not None: + req = held_req + else: + req = await self.send_req_queue.get() + held_req, held_attempts = req, held_attempts + 1 assert self.ws self.ws.send(json.dumps(req)) + held_req, held_attempts = None, 0 except Exception as ex: logger.warning(f"[EVENTS] NostrClient error: {ex}") + if held_req is not None and held_attempts >= MAX_SEND_ATTEMPTS: + # Bounded: a req the relay or the socket will never + # accept must not wedge the queue behind it forever. + logger.error( + f"[EVENTS] Dropping req after {held_attempts} " + f"failed sends: {held_req[0]}" + ) + held_req, held_attempts = None, 0 await asyncio.sleep(60) def is_duplicate_event(self, event_id: str) -> bool: @@ -101,14 +133,82 @@ class NostrClient: return False async def get_event(self): - """Get next event from the receive queue.""" - value = await self.receive_event_queue.get() - if isinstance(value, ValueError): - raise value - return value + """Get the next relay message, consuming `OK` frames on the way. - async def publish_nostr_event(self, e: NostrEvent): - await self.send_req_queue.put(["EVENT", e.dict()]) + This is the only place relay messages cross from the websocket + thread into the event loop, which makes it the natural place to + settle publish confirmations — no cross-thread future juggling. + `OK` frames are swallowed rather than forwarded; the sync loop + never handled them. + """ + while True: + value = await self.receive_event_queue.get() + if isinstance(value, ValueError): + self._fail_pending_oks("connection closed") + raise value + if self._settle_ok(value): + continue + return value + + def _settle_ok(self, message) -> bool: + """Resolve the future for an `["OK", , , ]` frame. + + Returns True when `message` was an OK frame (and so should not + be forwarded), False otherwise. An OK for a publish we are not + waiting on — a retry whose original already timed out, say — is + still consumed; it has nowhere useful to go. + """ + try: + data = json.loads(message) + except (json.JSONDecodeError, TypeError): + return False + if not isinstance(data, list) or len(data) < 3 or data[0] != "OK": + return False + + event_id = data[1] + accepted = bool(data[2]) + detail = data[3] if len(data) > 3 and isinstance(data[3], str) else "" + future = self._pending_oks.get(event_id) + if future and not future.done(): + future.set_result((accepted, detail)) + return True + + def _fail_pending_oks(self, reason: str) -> None: + """Settle every in-flight publish as unconfirmed. + + Without this a disconnect leaves callers waiting the full + timeout for an `OK` that can no longer arrive. + """ + for future in self._pending_oks.values(): + if not future.done(): + future.set_result((False, f"error: {reason}")) + + async def publish_nostr_event(self, e: NostrEvent) -> bool: + """Publish and wait for the relay's `OK`. True only when accepted. + + Queueing is not delivery: nostrclient drops an EVENT outright + when no relay is connected, answering `OK false`. Reporting + success on the queue put let a stale ticket count survive a + republish that never left the building (aiolabs/events#56). + """ + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._pending_oks[e.id] = future + try: + await self.send_req_queue.put(["EVENT", e.dict()]) + accepted, detail = await asyncio.wait_for( + future, PUBLISH_OK_TIMEOUT_SECONDS + ) + if not accepted: + logger.warning(f"[EVENTS] Relay rejected event {e.id[:12]}…: {detail}") + return accepted + except asyncio.TimeoutError: + logger.warning( + f"[EVENTS] No OK for event {e.id[:12]}… within " + f"{PUBLISH_OK_TIMEOUT_SECONDS}s — treating as unconfirmed" + ) + return False + finally: + self._pending_oks.pop(e.id, None) async def subscribe(self, filters: list[dict]): """Subscribe to events matching the given filters.""" diff --git a/nostr_hooks.py b/nostr_hooks.py index 32ea11c..e0b0379 100644 --- a/nostr_hooks.py +++ b/nostr_hooks.py @@ -8,11 +8,11 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models). from loguru import logger from .crud import update_event -from .models import Event +from .models import Event, advertised_wave_key from .nostr_publisher import publish_event_to_nostr -async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None: +async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> bool: """Publish or delete the NIP-52 calendar event for `event`. Resolves a `NostrSigner` for the wallet owner — backend-agnostic @@ -22,7 +22,22 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) - `await signer.sign_event(...)` for signing. Failures are logged and swallowed so a Nostr outage doesn't break the HTTP flow that triggered the publish. + + Returns True when the event was signed and handed to the client, + False on any skip or failure. Callers are free to ignore it — the + `nostr_publish_pending` flag is the durable record, and the sweep + retries from that rather than from a return value. """ + # Mark before attempting, clear only on confirmed success. Doing it + # in this order is what makes "the attempt was never made" — no + # signer, no NostrClient, process died mid-flight — as visible as + # "the attempt raised". Cheap guard so a re-publish of an already + # pending row doesn't write twice; `set_ticket_paid` sets the flag + # inside its own update so the sale path adds no extra write. + if not event.nostr_publish_pending: + event.nostr_publish_pending = True + await update_event(event) + try: from lnbits.core.signers import resolve_for_wallet @@ -32,17 +47,40 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) - if signer is None: # Wallet missing, account missing, unclassified row, or # ClientSideOnlySigner account (server can't sign for them). - # Soft-fail: skip the publish silently. The user can still - # publish kind-31922/31923 events client-side once we have - # that path. - return + # Soft-fail: the HTTP / payment flow that triggered this must + # not break. The user can still publish kind-31922/31923 + # events client-side once we have that path. + # + # Logged at WARNING, not debug: skipping the publish means the + # relay keeps serving whatever inventory it last saw, so the + # public ticket count silently stops tracking the DB. That has + # twice been discovered only by a human noticing a wrong number + # on a public page (aiolabs/events#35, #51). + logger.warning( + f"[EVENTS] No signer for wallet {event.wallet}, skipping " + f"NIP-52 {'delete' if delete else 'publish'} for event {event.id}" + ) + return False nostr_event = await publish_event_to_nostr( nostr_client, event, signer, delete=delete ) - if nostr_event and not delete: + if nostr_event is None: + return False + + event.nostr_publish_pending = False + if not delete: event.nostr_event_id = nostr_event.id event.nostr_event_created_at = nostr_event.created_at - await update_event(event) + # Record which wave this publish advertised so the sweep can + # notice a wave boundary later (aiolabs/events#61). Written in + # the same update as the cleared flag, so the two can never + # disagree about what is on the relay. + event.nostr_published_wave_id = advertised_wave_key(event) + await update_event(event) + return True except Exception as exc: - logger.warning(f"[EVENTS] Nostr publish failed: {exc}") + # ERROR, not warning: the row stays flagged and its published + # counts stay behind until the sweep or a later edit succeeds. + logger.error(f"[EVENTS] Nostr publish failed for event {event.id}: {exc}") + return False diff --git a/nostr_publisher.py b/nostr_publisher.py index 8374a62..0bf5ab6 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -17,7 +17,12 @@ from datetime import datetime, timezone from lnbits.core.signers import NostrSigner from loguru import logger -from .models import Event, effective_payment_methods +from .models import ( + Event, + advertised_ticket_wave, + effective_payment_methods, + ensure_ticket_waves, +) from .nostr.event import NostrEvent from .nostr_timestamp import monotonic_created_at @@ -46,11 +51,13 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: start - unix timestamp (31923) or YYYY-MM-DD (31922) end - same encoding (optional) image, location, t (categories) - optional - tickets_available - current remaining capacity (omitted when unlimited) - tickets_sold - running paid-count (always emitted; clients can - derive original_capacity = available + sold) - tickets_price - price_per_ticket (always emitted; 0 means free) - tickets_currency - the currency string + tickets_available - remaining capacity of the advertised wave + (always emitted; 0 = nothing on sale now) + tickets_sold - running paid-count across all waves (always + emitted) + tickets_price - the advertised wave's price (always emitted; + 0 means free) + tickets_currency - the advertised wave's currency tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise) tickets_fiat_currency - the fiat settle currency (only when allow_fiat) Content: event.info @@ -96,25 +103,59 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: for cat in event.categories or []: tags.append(["t", cat]) - # `amount_tickets == 0` means unlimited capacity in this extension's - # schema. Omitting the tag is how clients distinguish unlimited from - # "0 left" (sold out). - if event.amount_tickets > 0: - tags.append(["tickets_available", str(event.amount_tickets)]) + # Always emitted, including zero. Omitting it used to mean "unlimited", + # which contradicted every other reader: `api_get_event` and + # `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a + # zero-capacity event advertised "Unlimited tickets" on the card while + # the detail page and the purchase both returned 410 (aiolabs/events#34). + # Clients that must still handle an absent tag — a NIP-52 event from + # some other publisher — are unaffected; we simply never omit it. + # + # Since v1.6.8 price, currency and inventory belong to a ticket WAVE. + # The event-level fields `sync_event_ticket_waves` derives are the + # PRIMARY wave's price/currency and the SUM of every wave's stock, so + # publishing them would keep advertising the early-bird price after + # early bird closed, and count stock in waves that have not opened yet + # (aiolabs/events#61). Advertise the wave a buyer can actually buy from. + # + # Several waves can be open at once. The purchase endpoint refuses to + # guess ("Please select a ticket wave"); a publisher has no one to ask, + # so it advertises the CHEAPEST open wave — the price a buyer is able to + # obtain right now. Deviation recorded in docs/upstream-candidates.md. + open_wave = advertised_ticket_wave(event) + # Nothing open: sold out, between waves, or not yet on sale. Fall back + # to the primary wave so price/currency still describe the event, + # paired with the zero availability below. + advertised = open_wave or ensure_ticket_waves(event)[0] + + # Always emitted, including zero — see #34 above. With no open wave + # there is nothing to sell regardless of what the waves hold, so this + # is 0 rather than the wave's stock. + available = advertised.amount_tickets if open_wave else 0 + tags.append(["tickets_available", str(available)]) + # Event-level: total paid across every wave, which is what "sold" means + # to a reader of the card. tags.append(["tickets_sold", str(event.sold)]) - tags.append(["tickets_price", str(event.price_per_ticket)]) - tags.append(["tickets_currency", event.currency]) + tags.append(["tickets_price", str(advertised.price_per_ticket)]) + tags.append(["tickets_currency", advertised.currency]) # Fiat-checkout config — only emitted when allow_fiat is on so # clients can branch the buy UI without re-reading the schema. - if event.allow_fiat: + if advertised.allow_fiat: tags.append(["tickets_allow_fiat", "true"]) - if event.fiat_currency: - tags.append(["tickets_fiat_currency", event.fiat_currency]) + if advertised.fiat_currency: + tags.append(["tickets_fiat_currency", advertised.fiat_currency]) # Rails the organizer accepts, resolved through the same helper the # ticket endpoint enforces with, so a client can render exactly the # buttons that will be accepted (e.g. a card-only event) without a REST # round-trip. Comma-separated, lowercase. - tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))]) + tags.append( + [ + "tickets_payment_methods", + # Scoped to the advertised wave so this cannot contradict + # `tickets_allow_fiat` above — they are the same fact. + ",".join(effective_payment_methods(event, advertised)), + ] + ) # NIP-52 calendar events are replaceable: this d-tag is republished # whenever inventory changes (a ticket sells). Use a strictly-monotonic @@ -172,7 +213,14 @@ async def publish_event_to_nostr( Returns the published NostrEvent for metadata storage, or None on failure. """ if not nostr_client: - logger.debug("[EVENTS] No NostrClient available, skipping publish") + # WARNING, not debug: with no client the event is never queued, so + # the relay keeps serving stale inventory and nothing downstream + # can tell. At debug this skip is invisible at the INFO level + # instances actually run at (aiolabs/events#35, #51). + logger.warning( + "[EVENTS] No NostrClient, skipping NIP-52 " + f"{'delete' if delete else 'publish'} for event {event.id}" + ) return None try: @@ -196,7 +244,16 @@ async def publish_event_to_nostr( nostr_event.pubkey = signed["pubkey"] nostr_event.sig = signed["sig"] - await nostr_client.publish_nostr_event(nostr_event) + accepted = await nostr_client.publish_nostr_event(nostr_event) + if not accepted: + # Returning None keeps `nostr_publish_pending` set, so the + # sweep retries instead of recording a delivery that never + # happened (aiolabs/events#56). + logger.warning( + f"[EVENTS] Relay did not confirm NIP-52 " + f"{'delete' if delete else 'calendar'} event for {event.id}" + ) + return None logger.info( f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} " @@ -205,5 +262,8 @@ async def publish_event_to_nostr( return nostr_event except Exception as e: - logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}") + # ERROR, not warning: this is the signer-outage shape of + # aiolabs/events#35 — the calendar event never reaches the relay + # and the published ticket counts stop tracking the DB. + logger.error(f"[EVENTS] Failed to publish event {event.id} to Nostr: {e}") return None diff --git a/promo.py b/promo.py new file mode 100644 index 0000000..d35dff2 --- /dev/null +++ b/promo.py @@ -0,0 +1,117 @@ +"""Promo-code arithmetic shared by the validate endpoint and the purchase path. + +Pure functions (no DB, no settings) so the number a buyer sees in the +"Apply" preview is exactly the number the invoice / Stripe session charges. +Field names and the `BasketTotals` shape follow upstream lnbits/events v2 +(PR #64) so the eventual rebase (#33) merges cleanly; deviations are noted +inline. +""" + +from __future__ import annotations + +from collections import Counter + +from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket, TicketWave + +SAT_UNITS = ("sat", "sats") + + +def normalize_code(raw: str | None) -> str | None: + """Buyer input → stored form (stripped, upper-cased); empty → None.""" + if raw is None: + return None + code = raw.strip().upper() + return code or None + + +def find_promo(event: Event, code: str) -> PromoCode | None: + return next((pc for pc in event.extra.promo_codes if pc.code == code), None) + + +def promo_usage(tickets: list[Ticket]) -> dict[str, int]: + """Redemptions per code = PAID tickets carrying it in + `extra.applied_promo_code`. Every row counts, so a multi-ticket + purchase consumes `quantity` uses (upstream v2 counts one per basket). + + Paid only: pending rows live up to 24 h (`purge_unpaid_tickets`), so + counting them would let an abandoned Stripe session lock out the last + uses of a limited code for a day. The cost is a bounded overshoot when + several buyers pass the check before any of them pays — accepted. + """ + counter: Counter[str] = Counter() + for ticket in tickets: + code = ticket.extra.applied_promo_code + if ticket.paid and code: + counter[code] += 1 + return dict(counter) + + +def remaining_uses(promo: PromoCode, used: int) -> int | None: + """None = unlimited (`max_uses` unset / 0).""" + if not promo.max_uses: + return None + return max(promo.max_uses - used, 0) + + +def round_amount(amount: float, currency: str | None) -> float: + """Sats are integers; fiat is 2 dp. Applied once, at the end, so + subtotal - total == discount holds for what is actually charged.""" + if (currency or "sat").lower() in SAT_UNITS: + return float(int(amount)) + return round(amount, 2) + + +def basket_totals( + event: Event, + codes: list[str], + quantity: int, + usage: dict[str, int], + wave: TicketWave, +) -> BasketTotals: + """Price `quantity` tickets from `wave` with the first applicable code. + + A code is applicable when it exists, is active, has enough uses left + for the whole quantity, and actually saves something. Anything else is + simply absent from `discounts_applied` (upstream v2 semantics — the + purchase endpoint is where hard errors are raised). Only one code is + applied; v2's `combinable` stacking is out of scope here. + + `wave` is the pricing authority, not `event`. Since v1.6.8 a ticket's + price and currency belong to the wave it is bought from, and the + event-level fields are a derived roll-up of the PRIMARY wave + (`sync_event_ticket_waves`) — pricing off `event` would quote and charge + the first wave's price to a buyer who picked a later one. It is a + required argument rather than an optional override precisely because + that failure is silent: both call sites have to name the wave. + """ + currency = wave.currency or "sat" + subtotal = round_amount(wave.price_per_ticket * quantity, currency) + totals = BasketTotals( + subtotal=subtotal, discount=0, total=subtotal, currency=currency + ) + for raw in codes: + code = normalize_code(raw) + if not code: + continue + promo = find_promo(event, code) + if not promo or not promo.active: + continue + remaining = remaining_uses(promo, usage.get(promo.code, 0)) + if remaining is not None and remaining < quantity: + continue + total = round_amount(subtotal * (1 - promo.discount_percent / 100), currency) + saved = round_amount(subtotal - total, currency) + if saved <= 0: + continue + totals.total = total + totals.discount = saved + totals.discounts_applied = [ + BasketDiscount( + code=promo.code, + discount_percent=promo.discount_percent, + discount_fixed=None, + amount_saved=saved, + ) + ] + break + return totals diff --git a/services.py b/services.py index 9e6d1ce..84d5798 100644 --- a/services.py +++ b/services.py @@ -26,8 +26,15 @@ from .crud import ( update_event, update_ticket, ) -from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult +from .models import ( + Event, + NotificationDeliveryResult, + Ticket, + TicketResendResult, + ensure_ticket_waves, +) from .nostr_hooks import publish_or_delete_nostr_event +from .promo import promo_usage from .qr import ( format_event_when, image_png_bytes, @@ -69,7 +76,29 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: event = await get_event(ticket.event) assert event, "Couldn't get event from ticket being paid" event.sold += 1 - event.amount_tickets -= 1 + # Debit the wave the buyer actually bought from. v1.6.8 moved + # inventory onto waves; the event-level counter is only the + # fallback for events that predate them, and is itself a derived + # roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are + # kept — ours decremented unconditionally and could go negative. + 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 + # Flag inside this same write: the counters and "the relay does + # not know about them yet" land atomically, so a crash between + # here and the publish still leaves the drift discoverable. + event.nostr_publish_pending = True await update_event(event) # Republish the NIP-52 calendar event so connected clients see @@ -82,6 +111,22 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: return ticket +async def event_promo_usage(event_id: str) -> dict[str, int]: + """Paid redemptions per promo code for one event (see promo.promo_usage).""" + return promo_usage(await get_event_tickets(event_id)) + + +async def hydrate_promo_usage(event: Event) -> Event: + """Fill `used_count` on each of the event's promo codes. No query when + the event has no codes, so listing stays cheap.""" + if not event.extra.promo_codes: + return event + usage = await event_promo_usage(event.id) + for promo in event.extra.promo_codes: + promo.used_count = usage.get(promo.code, 0) + return event + + def send_ticket_notification_in_background(ticket: Ticket) -> None: create_task(_send_ticket_notification(ticket)) @@ -95,10 +140,17 @@ async def _send_ticket_notification(ticket: Ticket) -> None: await _deliver_ticket_notifications(ticket, event) -async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult: +async def resend_ticket_email_notification( + ticket: Ticket, base_url: str | None = None +) -> TicketResendResult: """Organizer-triggered re-delivery of the ticket email. Bypasses the per-event `email_notifications` opt-in (the organizer asked explicitly) - but still needs the instance mailer and an address on the ticket.""" + but still needs the instance mailer and an address on the ticket. + + `base_url` is upstream v1.6.8's: it re-points the ticket link at the + caller's frontend, which matters for us specifically because our + `ticket_base_url` can differ from `lnbits_baseurl` (separate web app). + """ event = await get_event(ticket.event) if not event: raise ValueError("Event does not exist.") @@ -106,7 +158,12 @@ async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult raise ValueError("Email notifications are not enabled.") if not ticket.email: raise ValueError("Ticket does not have an email address.") + if base_url: + ticket.extra.ticket_base_url = base_url.rstrip("/") + # email-only: this is the *email* resend endpoint. Upstream calls + # `_deliver_ticket_notifications(ticket, event)` unqualified, which in + # our fork would also fire a Nostr DM the organiser did not ask for. return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False) @@ -142,17 +199,35 @@ def _ticket_details(ticket: Ticket, event: Event) -> str: def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str: - return ( + """Text part of the ticket mail. + + Carries up to two images, which are NOT the same thing (see the note on + `_ticket_card_url` vs `_ticket_image_url`): our rendered QR card, always + present, and the organiser's uploaded template, only when the buyer's + wave opted into it. They had the same label before the v1.6.8 merge + because only one of them existed; now that both can appear the labels + have to distinguish them. + """ + message = ( f"{base_message}\n\n{_ticket_details(ticket, event)}" - f"\n\nTicket image: {_ticket_image_url(ticket)}" + f"\n\nTicket card: {_ticket_card_url(ticket)}" ) + ticket_image_url = _ticket_image_url(ticket, event) + if ticket_image_url: + message = f"{message}\n\nTicket image: {ticket_image_url}" + return message def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str: - """HTML twin of the text part. Deliberately no : the card travels - as an attachment (renders inline in most clients, works offline, and - keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become - links.""" + """HTML twin of the text part. + + Deliberately no for our own ticket card: it travels as an + attachment (renders inline in most clients, works offline, and keeps + SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link. + The organiser's uploaded ticket image is a remote URL with no attachment + to fall back on, so that one does get upstream's — the surrounding + ticket details keep the mail from being image-only either way. + """ text_message = _ticket_delivery_message(ticket, event, base_message) html = escape(text_message) html = re.sub( @@ -160,7 +235,17 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) lambda m: f'{m.group(1)}', html, ) - return f"

{html.replace(chr(10), '
')}

" + html_message = f"

{html.replace(chr(10), '
')}

" + + ticket_image_url = _ticket_image_url(ticket, event) + if not ticket_image_url: + return html_message + + return ( + f"{html_message}" + f'

Ticket image

' + ) def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: @@ -179,7 +264,16 @@ async def _deliver_ticket_notifications( ) -> TicketResendResult: """Send the ticket by every configured channel and report per-channel outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's - opt-ins when not None; the instance-level prerequisites always apply.""" + opt-ins when not None; the instance-level prerequisites always apply. + + Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery` + check that errors with "Only NIP-05 Nostr identifiers are supported." + That guard is NOT taken here: it encodes upstream's limitation, not ours. + `_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to + core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard + would silently refuse identifiers we deliver to today — and say so with + a message that would be false for this fork. + """ subject, text_message, html_message = _ticket_notification_payload(ticket, event) updated = False @@ -355,16 +449,36 @@ def _ticket_url(ticket: Ticket) -> str: return f"{base_url}/events/ticket/{ticket.id}" -def _ticket_image_url(ticket: Ticket) -> str: - """The ticket card PNG is served by THIS extension on the LNbits host, so - it is built from `lnbits_baseurl` even when `ticket_base_url` points at - a separate web app (deviation from upstream, which assumes both are the - same host).""" +def _ticket_card_url(ticket: Ticket) -> str: + """Our rendered ticket-card PNG — always available, and served by THIS + extension on the LNbits host, so it is built from `lnbits_baseurl` even + when `ticket_base_url` points at a separate web app (deviation from + upstream, which assumes both are the same host).""" return ( f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}" ) +def _ticket_image_url(ticket: Ticket, event: Event) -> str | None: + """Upstream's organiser-uploaded ticket template, opted into per wave. + + Distinct from `_ticket_card_url`: that is our own rendered card and is + always attached, this is a template the organiser uploads and enables + on a specific wave. They shared a name before the v1.6.8 merge, which + made them look like one feature. + """ + waves = ensure_ticket_waves(event) + wave = next( + (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), + waves[0], + ) + if not wave.use_ticket_image: + return None + + base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/") + return f"{base_url}/events/api/v1/qr/{ticket.id}" + + async def refund_tickets(event_id: str): """ Refund tickets for an event that has not met the minimum ticket requirement. diff --git a/static/image/ticket.jpg b/static/image/ticket.jpg new file mode 100644 index 0000000..e05d931 Binary files /dev/null and b/static/image/ticket.jpg differ diff --git a/static/js/display.js b/static/js/display.js index ad7e358..022f051 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -13,6 +13,7 @@ window.PageEventsDisplay = { email: '', refund: '', nostr_identifier: '', + ticket_wave_id: null, payment_method: 'lightning' } }, @@ -46,26 +47,62 @@ window.PageEventsDisplay = { paymentMethods() { // Mirrors `effective_payment_methods` on the backend: an explicit // extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat. + // Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat` + // is only the PRIMARY wave's, so prefer the active wave when there is + // one — otherwise a later fiat-enabled wave would not offer fiat. const explicit = this.event?.extra?.payment_methods || [] if (explicit.length) return explicit - return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])] + const allowFiat = this.selectedTicketWave + ? this.selectedTicketWave.allow_fiat + : this.event?.allow_fiat + return ['lightning', ...(allowFiat ? ['fiat'] : [])] + }, + activeTicketWaves() { + const today = new Date().toISOString().slice(0, 10) + return (this.event?.extra?.ticket_waves || []).filter( + wave => + wave.amount_tickets > 0 && + wave.opening_date <= today && + wave.closing_date >= today + ) + }, + selectedTicketWave() { + return ( + this.activeTicketWaves.find( + wave => wave.id === this.formDialog.data.ticket_wave_id + ) || + this.activeTicketWaves[0] || + null + ) + }, + showTicketWaveSelector() { + return this.activeTicketWaves.length > 1 }, allowFiatCheckout() { return this.paymentMethods.includes('fiat') }, paymentMethodOptions() { + // Options come from `paymentMethods` — the same list the backend + // validates against in `effective_payment_methods` — rather than + // being re-derived from per-method booleans, so a rail the server + // would reject can never be offered. The `|| method` fallback covers + // a method with no label yet (on-chain, once #41 lands). + const labels = { + lightning: 'Lightning', + fiat: this.fiatCheckoutLabel + } return this.paymentMethods.map(method => ({ value: method, - label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning' + label: labels[method] || method })) }, fiatCheckoutLabel() { if (!this.allowFiatCheckout) return 'Fiat' const unit = ['sat', 'sats'].includes( - (this.event?.currency || '').toLowerCase() + (this.selectedTicketWave?.currency || '').toLowerCase() ) - ? this.event?.fiat_currency - : this.event?.currency + ? this.selectedTicketWave?.fiat_currency + : this.selectedTicketWave?.currency return `Fiat (${(unit || 'GBP').toUpperCase()})` }, allowEmailNotifications() { @@ -82,6 +119,16 @@ window.PageEventsDisplay = { 'GET', `/events/api/v1/events/${this.eventId}` ) + const activeWaves = (data.extra?.ticket_waves || []).filter(wave => { + const today = new Date().toISOString().slice(0, 10) + return ( + wave.amount_tickets > 0 && + wave.opening_date <= today && + wave.closing_date >= today + ) + }) + this.formDialog.data.ticket_wave_id = + activeWaves.length === 1 ? activeWaves[0].id : null return data } catch (error) { this.eventErrorLabel = 'Event unavailable.' @@ -94,6 +141,11 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' + this.formDialog.data.ticket_wave_id = + this.activeTicketWaves.length === 1 + ? this.activeTicketWaves[0].id + : null + this.formDialog.data.promo_code = '' this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning' }, @@ -107,6 +159,13 @@ window.PageEventsDisplay = { this.paymentWebsocket.close() this.paymentWebsocket = null } + this.paymentReq = null + this.receive = { + show: false, + status: 'pending', + paymentReq: null, + isFiat: false + } }, nameValidation(val) { const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g @@ -129,6 +188,10 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' + this.formDialog.data.ticket_wave_id = + this.activeTicketWaves.length === 1 + ? this.activeTicketWaves[0].id + : null this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning' Quasar.Notify.create({ @@ -159,10 +222,19 @@ window.PageEventsDisplay = { { name: this.formDialog.data.name, email: this.formDialog.data.email, + ticket_wave_id: this.formDialog.data.ticket_wave_id || null, promo_code: this.formDialog.data.promo_code || null, refund_address: this.formDialog.data.refund || null, nostr_identifier: this.formDialog.data.nostr_identifier || null, - payment_method: this.formDialog.data.payment_method + // Gate matches the template's (`paymentMethods.length > 1`). + // v1.6.8 gated on `showPaymentMethodSelector` + // (`allowFiatCheckout || allowOnchain`), a different condition: + // where the two disagreed the buyer's visible choice was + // silently replaced with 'lightning'. + payment_method: + this.paymentMethods.length > 1 + ? this.formDialog.data.payment_method + : this.paymentMethods[0] || 'lightning' } ) const isFiat = Boolean(data.is_fiat) @@ -196,7 +268,7 @@ window.PageEventsDisplay = { const url = new URL(window.location) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/api/v1/ws/${paymentHash}` + url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` url.search = '' url.hash = '' @@ -205,7 +277,7 @@ window.PageEventsDisplay = { ws.onmessage = event => { const data = JSON.parse(event.data) - if (data.pending === false) { + if (data.paid === true) { this.paymentSuccess(paymentHash) ws.close() } @@ -214,8 +286,12 @@ window.PageEventsDisplay = { console.error('WebSocket error:', error) } ws.onclose = () => { - if (this.paymentWebsocket === ws) { - this.paymentWebsocket = null + if (this.paymentWebsocket !== ws) return + this.paymentWebsocket = null + if (this.receive.show) { + setTimeout(() => { + if (this.receive.show) this.paymentWatcher(paymentHash) + }, 3000) } } } diff --git a/static/js/display.vue b/static/js/display.vue index 5a2ed68..8388997 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -74,7 +74,7 @@ filled dense v-model.trim="formDialog.data.nostr_identifier" - label="(optional) Nostr NIP-05 or npub" + label="(optional) Nostr NIP-05" hint="If provided, we'll DM your ticket link after payment." > @@ -89,6 +89,21 @@ lazy-rules :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" > +
Submit this.shortenId(row.id) + }, {name: 'name', align: 'left', label: 'Name', field: 'name'}, { name: 'event_start_date', @@ -39,12 +47,6 @@ window.PageEvents = { label: 'End date', field: 'event_end_date' }, - { - name: 'closing_date', - align: 'left', - label: 'Ticket close', - field: 'closing_date' - }, { name: 'canceled', align: 'left', @@ -67,13 +69,15 @@ window.PageEvents = { name: 'event_start_date', align: 'left', label: 'Start date', - field: 'event_start_date' + field: 'event_start_date', + format: val => this.formatEventDate(val) }, { name: 'event_end_date', align: 'left', label: 'End date', - field: 'event_end_date' + field: 'event_end_date', + format: val => this.formatEventDate(val) }, { name: 'closing_date', @@ -127,16 +131,41 @@ window.PageEvents = { } }, ticketsTable: { + loading: false, columns: [ - {name: 'event', align: 'left', label: 'Event', field: 'event'}, + { + name: 'event', + align: 'left', + label: 'Event', + field: row => this.shortenId(row.event) + }, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'email', align: 'left', label: 'Email', field: 'email'}, + { + name: 'email_sent', + align: 'left', + label: 'Email sent', + field: row => + !row.email + ? 'no email' + : row.extra?.email_notification_sent + ? '\u2713 sent' + : row.paid + ? 'not sent' + : 'unpaid' + }, { name: 'registered', align: 'left', label: 'Registered', field: 'registered' }, + { + name: 'nostr', + align: 'left', + label: 'Nostr', + field: row => row.extra?.nostr_identifier || '' + }, { name: 'promo_code', align: 'left', @@ -146,39 +175,302 @@ window.PageEvents = { {name: 'id', align: 'left', label: 'ID', field: 'id'} ], pagination: { - rowsPerPage: 10 + sortBy: 'time', + descending: true, + page: 1, + rowsPerPage: 10, + rowsNumber: 10 } }, + // Rails an organizer can enable per event. Mirrors the webapp's + // CreateEventDialog; `fiat` is rendered disabled when the LNbits user + // has no fiat provider (see `hasFiatProvider`). + paymentMethodOptions: [ + { + value: 'lightning', + label: 'Lightning', + hint: 'Pay with any Lightning wallet' + }, + { + value: 'fiat', + label: 'Card', + hint: 'Card or bank through your configured fiat provider' + } + ], + // Same list the webapp offers (src/modules/events/types/category.ts); + // published as NIP-52 `t` tags so both clients filter on one vocabulary. + categoryOptions: [ + 'concert', + 'workshop', + 'market', + 'festival', + 'exhibition', + 'sport', + 'theater', + 'cinema', + 'party', + 'talk', + 'conference', + 'meetup', + 'food', + 'outdoor', + 'kids', + 'wellness', + 'technology', + 'art', + 'music', + 'dance', + 'literature', + 'comedy', + 'charity', + 'tradition', + 'other' + ].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})), formDialog: { show: false, data: { currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', + location: '', + categories: [], extra: { payment_methods: ['lightning'], + ticket_waves: [], promo_codes: [], notification_subject: '', notification_body: '' } } + }, + ticketWaveDialog: { + show: false, + eventId: null, + wallet: null, + editingWaveId: null, + data: { + id: null, + title: '', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + }, + promoCodesDialog: { + show: false, + data: { + id: null, + wallet: null, + name: '', + extra: { + promo_codes: [] + } + } } } }, + computed: { + hasFiatProvider() { + return (this.g.user?.fiat_providers || []).length > 0 + }, + fiatProviderNames() { + return (this.g.user?.fiat_providers || []) + .map(p => p.charAt(0).toUpperCase() + p.slice(1)) + .join(', ') + }, + acceptsFiat() { + return (this.formDialog.data.extra?.payment_methods || []).includes( + 'fiat' + ) + }, + isSatPrice() { + return !this.isFiatCurrency(this.formDialog.data.currency) + }, + fiatCurrencyOptions() { + return this.currencies.filter(c => this.isFiatCurrency(c)) + } + }, methods: { + shortenId(value) { + if (!value) return '' + return value.length > 4 ? `${value.slice(0, 4)}...` : value + }, + primaryTicketWave(data = this.formDialog.data) { + if (!data.extra) data.extra = {} + if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) { + data.extra.ticket_waves = [ + { + id: 'primary', + title: 'Primary wave', + opening_date: data.closing_date || '', + closing_date: data.closing_date || '', + currency: data.currency || 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: Boolean(data.allow_fiat), + fiat_currency: data.fiat_currency || 'GBP', + amount_tickets: data.amount_tickets || 0, + price_per_ticket: data.price_per_ticket || 0 + } + ] + } + return data.extra.ticket_waves[0] + }, + syncPrimaryWaveFromForm(data = this.formDialog.data) { + const primaryWave = this.primaryTicketWave(data) + primaryWave.title = primaryWave.title || 'Primary wave' + primaryWave.opening_date = primaryWave.opening_date || '' + primaryWave.closing_date = data.closing_date || '' + primaryWave.currency = data.currency || 'sats' + primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image) + primaryWave.ticket_image_id = primaryWave.ticket_image_id || null + primaryWave.allow_fiat = Boolean(data.allow_fiat) + primaryWave.fiat_currency = data.fiat_currency || 'GBP' + primaryWave.amount_tickets = Number(data.amount_tickets || 0) + primaryWave.price_per_ticket = Number(data.price_per_ticket || 0) + return primaryWave + }, + hydrateEventForm(data) { + const formData = { + ...data, + extra: { + ...(data.extra || {}), + ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])] + } + } + const primaryWave = this.primaryTicketWave(formData) + formData.currency = primaryWave.currency || formData.currency || 'sats' + formData.allow_fiat = Boolean(primaryWave.allow_fiat) + formData.fiat_currency = primaryWave.fiat_currency || 'GBP' + formData.amount_tickets = primaryWave.amount_tickets + formData.price_per_ticket = primaryWave.price_per_ticket + formData.closing_date = + primaryWave.closing_date || formData.closing_date || '' + return formData + }, isFiatCurrency(currency) { return !['sat', 'sats'].includes((currency || '').toLowerCase()) }, - getTickets() { - LNbits.api - .request( + normalizePromoCodes(promoCodes = []) { + return promoCodes + .filter(code => code.code?.trim() !== '') + .map(code => ({ + ...code, + code: code.code.trim().toUpperCase(), + // fork-only: blank / 0 = unlimited; used_count is derived server-side + max_uses: code.max_uses ? Number(code.max_uses) : null + })) + }, + templateDownloadUrl() { + return '/events/static/image/ticket.jpg' + }, + async uploadAssetFile(file) { + const form = new FormData() + form.append('file', file) + form.append('public_asset', 'true') + const {data} = await LNbits.api.request( + 'POST', + '/api/v1/assets?public_asset=true', + null, + form + ) + return data.id + }, + triggerTicketImageUpload(target) { + this.ticketImageUploadTarget = target + this.$refs.ticketImageUpload.value = null + this.$refs.ticketImageUpload.click() + }, + async handleTicketImageSelected(event) { + const file = event.target.files?.[0] + if (!file || !this.ticketImageUploadTarget) return + + this.isUploadingTicketTemplate = true + try { + const assetId = await this.uploadAssetFile(file) + if (this.ticketImageUploadTarget === 'primary') { + const wave = this.primaryTicketWave() + wave.use_ticket_image = true + wave.ticket_image_id = assetId + } else if (this.ticketImageUploadTarget === 'dialog') { + this.ticketWaveDialog.data.use_ticket_image = true + this.ticketWaveDialog.data.ticket_image_id = assetId + } + Quasar.Notify.create({ + type: 'positive', + message: 'Ticket template uploaded.', + icon: null + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.isUploadingTicketTemplate = false + this.ticketImageUploadTarget = null + } + }, + waveSummary(eventId, wave) { + // Built here, not in the template. Vue resolves template expressions + // against the component instance, where the `LNbits` global is NOT + // in scope — upstream's v1.6.8 chip called `LNbits.utils` inline and + // threw "Cannot read properties of undefined (reading 'utils')", + // which killed the whole v-for and left the wave list looking empty. + // No other template in this extension touches `LNbits` directly. + const price = this.isFiatCurrency(wave.currency) + ? LNbits.utils.formatCurrency( + Number(wave.price_per_ticket || 0).toFixed(2), + wave.currency + ) + : `${wave.price_per_ticket} sats` + // Wave dates can carry a time (closing_date defaults from + // event_end_date); show the day only. + const opens = String(wave.opening_date || '').slice(0, 10) + const closes = String(wave.closing_date || '').slice(0, 10) + const sold = this.soldTicketsForWave(eventId, wave.id) + return `${wave.title} - ${opens} to ${closes} - ${price} - ${wave.amount_tickets} tickets - ${sold} sold` + }, + soldTicketsForWave(eventId, waveId) { + return this.allPaidTickets.filter( + ticket => + ticket.event === eventId && + ticket.paid && + (ticket.extra?.ticket_wave_id === waveId || + (!ticket.extra?.ticket_wave_id && waveId === 'primary')) + ).length + }, + async getAllTickets() { + try { + const {data} = await LNbits.api.request( 'GET', '/events/api/v1/tickets?all_wallets=true', this.g.user.wallets[0].adminkey ) - .then(response => { - this.tickets = response.data.filter(e => e.paid) - }) + this.allPaidTickets = data.filter(ticket => ticket.paid) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async getTickets(props) { + try { + this.ticketsTable.loading = true + const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props) + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/tickets/paginated?all_wallets=true&${params}`, + this.g.user.wallets[0].adminkey + ) + this.tickets = data.data + this.ticketsTable.pagination.rowsNumber = data.total + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.ticketsTable.loading = false + } }, deleteTicket(ticketId) { const tickets = _.findWhere(this.tickets, {id: ticketId}) @@ -193,10 +485,9 @@ window.PageEvents = { '/events/api/v1/tickets/' + ticketId, wallet.adminkey ) - .then(response => { - this.tickets = _.reject(this.tickets, function (obj) { - return obj.id == ticketId - }) + .then(async () => { + await this.getTickets() + await this.getAllTickets() }) .catch(LNbits.utils.notifyApiError) }) @@ -214,14 +505,30 @@ window.PageEvents = { wallet.adminkey ) .then(response => { + const result = response.data this.tickets = this.tickets.map(obj => - obj.id === ticket.id ? response.data : obj + obj.id === ticket.id ? result.ticket : obj ) - Quasar.Notify.create({ - type: 'positive', - message: 'Ticket email resent.', - icon: null - }) + + if (result.email?.attempted) { + Quasar.Notify.create({ + type: result.email.sent ? 'positive' : 'negative', + message: result.email.sent + ? 'Ticket email resent.' + : `Ticket email failed: ${result.email.error || 'Unknown error.'}`, + icon: null + }) + } + + if (result.nostr?.attempted) { + Quasar.Notify.create({ + type: result.nostr.sent ? 'positive' : 'negative', + message: result.nostr.sent + ? 'Ticket Nostr DM resent.' + : `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`, + icon: null + }) + } }) .catch(LNbits.utils.notifyApiError) .finally(() => { @@ -231,7 +538,7 @@ window.PageEvents = { }) }, exportticketsCSV() { - LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) + LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets) }, getEvents() { LNbits.api @@ -382,37 +689,70 @@ window.PageEvents = { }, splitDateTime(value) { // Inverse of foldDateTime: split a stored string back into the - // day/time pieces the form inputs bind to. + // day/time pieces the form inputs bind to. Slicing to HH:MM also + // drops the seconds + offset suffix withLocalTzOffset stamps on + // submit, so the organizer sees the wall-clock they entered. if (!value) return {day: '', time: ''} const [day, time = ''] = value.split('T') // Time inputs only accept HH:MM, drop any seconds we stored. return {day, time: time.slice(0, 5)} }, + withLocalTzOffset(value) { + // Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value. + // The publisher's `_to_unix` treats a naive datetime as UTC, so an + // event entered as 18:00 in CEST would otherwise go out on Nostr + // as 18:00 UTC. Same transform the webapp applies; date-only + // values pass through unchanged (they map to NIP-52 kind 31922). + if (!value || !value.includes('T')) return value + const offMin = -new Date(value).getTimezoneOffset() + const sign = offMin >= 0 ? '+' : '-' + const abs = Math.abs(offMin) + const hh = String(Math.floor(abs / 60)).padStart(2, '0') + const mm = String(abs % 60).padStart(2, '0') + return `${value}:00${sign}${hh}:${mm}` + }, + formatEventDate(value) { + // Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM". + if (!value) return '' + const {day, time} = this.splitDateTime(value) + return time ? `${day} ${time}` : day + }, + validateEndDate() { + // Cross-field rule for the end-day input: end >= start, compared + // on the folded date+time so an equal-day earlier time is caught. + const d = this.formDialog.data + const start = this.foldDateTime(d.event_start_day, d.event_start_time) + const end = this.foldDateTime(d.event_end_day, d.event_end_time) + if (!start || !end) return true + return end >= start || 'End must be on or after start' + }, sendEventData() { const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) const data = {...this.formDialog.data} - data.event_start_date = this.foldDateTime( - data.event_start_day, - data.event_start_time + data.event_start_date = this.withLocalTzOffset( + this.foldDateTime(data.event_start_day, data.event_start_time) ) - data.event_end_date = this.foldDateTime( - data.event_end_day, - data.event_end_time + data.event_end_date = this.withLocalTzOffset( + this.foldDateTime(data.event_end_day, data.event_end_time) ) delete data.event_start_day delete data.event_start_time delete data.event_end_day delete data.event_end_time - + // Optional NIP-52 fields: blank location is "unset", not "". + data.location = (data.location || '').trim() || null + data.categories = data.categories || [] + data.closing_date = data.closing_date || null + // Fold the form's day+time pairs first, then let upstream's helper + // mirror the form fields onto the primary wave — order matters, the + // sync reads closing_date/currency/amount_tickets off `data`. + this.syncPrimaryWaveFromForm(data) if (data.extra?.promo_codes) { - data.extra.promo_codes = data.extra.promo_codes - .filter(code => code.code?.trim() !== '') - .map(code => ({ - ...code, - code: code.code.trim().toUpperCase() - })) + data.extra.promo_codes = this.normalizePromoCodes( + data.extra.promo_codes + ) } const methods = data.extra?.payment_methods || [] if (methods.length === 0) { @@ -425,11 +765,14 @@ window.PageEvents = { // allow_fiat stays the fiat-currency carrier the backend and the // NIP-52 tags read; keep it in lockstep with the checkbox list. data.allow_fiat = methods.includes('fiat') - if (!this.isFiatCurrency(data.currency)) { - if (!data.allow_fiat) { - data.fiat_currency = 'GBP' - } + if (this.isFiatCurrency(data.currency)) { + // A fiat-priced event settles in its price currency; mirror it so + // the payload (and the tickets_fiat_currency tag) stay coherent. + data.fiat_currency = data.currency + } else if (!data.allow_fiat) { + data.fiat_currency = 'GBP' } + this.syncPrimaryWaveFromForm(data) if (data.id) { this.updateEvent(wallet, data) @@ -442,10 +785,14 @@ window.PageEvents = { if (data && data.id) { const start = this.splitDateTime(data.event_start_date) const end = this.splitDateTime(data.event_end_date) + // Seed from upstream's hydrator (it materialises ticket_waves and + // mirrors the primary wave onto the flat form fields), then layer + // our fork-only fields on top. + const hydrated = this.hydrateEventForm(data) this.formDialog.data = { - ...data, + ...hydrated, extra: { - ...(data.extra || {}), + ...(hydrated.extra || {}), // Events created before extra.payment_methods existed carry an // empty list; show the rails the backend actually accepts for // them (Lightning always, fiat when allow_fiat). @@ -453,6 +800,8 @@ window.PageEvents = { ? data.extra.payment_methods : ['lightning', ...(data.allow_fiat ? ['fiat'] : [])] }, + location: data.location || '', + categories: [...(data.categories || [])], event_start_day: start.day, event_start_time: start.time, event_end_day: end.day, @@ -463,6 +812,8 @@ window.PageEvents = { currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', + location: '', + categories: [], event_start_day: '', event_start_time: '', event_end_day: '', @@ -473,6 +824,21 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, + ticket_waves: [ + { + id: 'primary', + title: 'Primary wave', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + ], promo_codes: [], notification_subject: '', notification_body: '' @@ -487,10 +853,29 @@ window.PageEvents = { currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', + location: '', + categories: [], extra: { payment_methods: ['lightning'], + conditional: false, + min_tickets: 1, email_notifications: false, nostr_notifications: false, + ticket_waves: [ + { + id: 'primary', + title: 'Primary wave', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + ], promo_codes: [], notification_subject: '', notification_body: '' @@ -511,6 +896,183 @@ window.PageEvents = { const link = _.findWhere(this.events, {id: formId}) this.openEventDialog(link) }, + openTicketWaveDialog(event, wave = null) { + const primaryWave = (event.extra?.ticket_waves || [])[0] || {} + const isEditing = Boolean(wave) + this.ticketWaveDialog = { + show: true, + eventId: event.id, + wallet: event.wallet, + editingWaveId: wave?.id || null, + data: { + id: wave?.id || null, + title: wave?.title || '', + opening_date: wave?.opening_date || '', + closing_date: wave?.closing_date || '', + currency: + wave?.currency || primaryWave.currency || event.currency || 'sats', + use_ticket_image: Boolean(wave?.use_ticket_image), + ticket_image_id: wave?.ticket_image_id || null, + allow_fiat: isEditing + ? Boolean(wave?.allow_fiat) + : Boolean(primaryWave.allow_fiat ?? event.allow_fiat), + fiat_currency: + wave?.fiat_currency || + primaryWave.fiat_currency || + event.fiat_currency || + 'GBP', + // `??` not `||`: a sold-out wave legitimately holds 0 and must + // show it rather than silently regaining stock on save. A NEW + // wave opens at 1, the smallest capacity the backend accepts — + // there is no unlimited (#34). + amount_tickets: isEditing ? (wave?.amount_tickets ?? 0) : 1, + price_per_ticket: + wave?.price_per_ticket || + primaryWave.price_per_ticket || + event.price_per_ticket || + 0 + } + } + }, + resetTicketWaveDialog() { + this.ticketWaveDialog = { + show: false, + eventId: null, + wallet: null, + editingWaveId: null, + data: { + id: null, + title: '', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + } + }, + saveTicketWave() { + const event = _.findWhere(this.events, { + id: this.ticketWaveDialog.eventId + }) + const wallet = _.findWhere(this.g.user.wallets, { + id: this.ticketWaveDialog.wallet + }) + if (!event || !wallet) return + + const payload = { + ...event, + extra: { + ...event.extra, + ticket_waves: (event.extra?.ticket_waves || []).map(existingWave => + existingWave.id === this.ticketWaveDialog.editingWaveId + ? {...this.ticketWaveDialog.data} + : existingWave + ) + } + } + + if (!this.ticketWaveDialog.editingWaveId) { + payload.extra.ticket_waves.push({...this.ticketWaveDialog.data}) + } + + if (payload.extra?.promo_codes) { + payload.extra.promo_codes = this.normalizePromoCodes( + payload.extra.promo_codes + ) + } + + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + payload.id, + wallet.adminkey, + payload + ) + .then(response => { + this.events = this.events.map(item => + item.id === payload.id ? response.data : item + ) + Quasar.Notify.create({ + type: 'positive', + message: this.ticketWaveDialog.editingWaveId + ? 'Ticket wave updated.' + : 'Ticket wave added.', + icon: null + }) + this.resetTicketWaveDialog() + }) + .catch(LNbits.utils.notifyApiError) + }, + openPromoCodesDialog(event) { + this.promoCodesDialog.data = { + ...event, + extra: { + ...event.extra, + promo_codes: [...(event.extra?.promo_codes || [])] + } + } + this.promoCodesDialog.show = true + }, + resetPromoCodesDialog() { + this.promoCodesDialog.show = false + this.promoCodesDialog.data = { + id: null, + wallet: null, + name: '', + extra: { + promo_codes: [] + } + } + }, + addPromoCodeToDialog() { + this.promoCodesDialog.data.extra.promo_codes.push({ + code: '', + discount_percent: 0, + active: true, + // fork-only: null = unlimited uses + max_uses: null + }) + }, + savePromoCodes() { + const data = this.promoCodesDialog.data + const wallet = _.findWhere(this.g.user.wallets, { + id: data.wallet + }) + if (!wallet) return + + const payload = { + ...data, + extra: { + ...data.extra, + promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || []) + } + } + + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + data.id, + wallet.adminkey, + payload + ) + .then(response => { + this.events = this.events.map(event => + event.id === data.id ? response.data : event + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Promo codes updated.', + icon: null + }) + this.resetPromoCodesDialog() + }) + .catch(LNbits.utils.notifyApiError) + }, updateEvent(wallet, data) { LNbits.api .request( @@ -577,6 +1139,7 @@ window.PageEvents = { async created() { if (this.g.user.wallets.length) { this.getTickets() + this.getAllTickets() this.getEvents() this.getSettings() this.getPendingEvents() diff --git a/static/js/index.vue b/static/js/index.vue index 502d9ad..4795a3c 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -243,45 +243,86 @@
-
Promo codes
+
+
Ticket waves
+ +
+
+
+
+ + + +
+
+
+ +
+
Promo codes
+ +
- No promo codes for this event. + No active promo codes for this event.
-
-
+
+
- - +
-
- Discount: - % -
-
- Status: - -
@@ -308,9 +349,11 @@ dense flat :rows="tickets" + :loading="ticketsTable.loading" row-key="id" :columns="ticketsTable.columns" v-model:pagination="ticketsTable.pagination" + @request="getTickets" > diff --git a/static/js/ticket.js b/static/js/ticket.js index 82fbd6d..6ff55b6 100644 --- a/static/js/ticket.js +++ b/static/js/ticket.js @@ -3,16 +3,36 @@ window.PageEventsTicket = { data() { return { ticketId: null, - ticket: null + ticket: null, + printMode: false, + qrSrc: '' } }, methods: { - printWindow() { - window.print() + async printWindow() { + this.printMode = true + await this.$nextTick() + await this.waitForPrintAssets() + setTimeout(() => window.print(), 50) + }, + async waitForPrintAssets() { + await this.$nextTick() + const img = document.querySelector('.ticket-print-qr') + if (!img) return + if (img.complete && img.naturalWidth > 0) return + await new Promise(resolve => { + const done = () => resolve() + img.addEventListener('load', done, {once: true}) + img.addEventListener('error', done, {once: true}) + setTimeout(done, 500) + }) } }, async created() { this.ticketId = this.$route.params.id + this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent( + `ticket://${this.ticketId}` + )}` try { const {data} = await LNbits.api.request( 'GET', @@ -22,5 +42,8 @@ window.PageEventsTicket = { } catch (error) { LNbits.utils.notifyApiError(error) } + window.addEventListener('afterprint', () => { + this.printMode = false + }) } } diff --git a/static/js/ticket.vue b/static/js/ticket.vue index 3c932e1..23a8dc4 100644 --- a/static/js/ticket.vue +++ b/static/js/ticket.vue @@ -36,4 +36,53 @@
+ + +
+ Ticket QR +
+
+ + diff --git a/tests/test_capacity_guard.py b/tests/test_capacity_guard.py new file mode 100644 index 0000000..4125278 --- /dev/null +++ b/tests/test_capacity_guard.py @@ -0,0 +1,112 @@ +"""`amount_tickets` is the remaining count (aiolabs/events#34). + +`set_ticket_paid` decrements it on every sale and `sold` increments, so +subtracting `sold` from it removes each sale twice. That made the buyer +cap under-report and, once an event passed half its capacity, turned +`sold >= amount_tickets` true and declared it sold out with stock left. +Measured on aio-demo before the fix: 16 of 24 live events affected, +3 already refusing sales while tickets remained. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from .. import views_api +from ..models import CreateTicket, Event + +SENTINEL = object() + + +def _event(amount_tickets: int, sold: int) -> Event: + return Event( + id="evt", + wallet="w", + name="Capacity", + info="", + closing_date="2030-01-01", + event_start_date="2030-01-01", + event_end_date="2030-01-02", + currency="sat", + price_per_ticket=0, # free path, so the guard is all that gates us + amount_tickets=amount_tickets, + sold=sold, + time=datetime.now(timezone.utc), + status="approved", + ) + + +@pytest.fixture +def issued(monkeypatch): + """Past the capacity guard the free path short-circuits to a sentinel.""" + monkeypatch.setattr( + views_api, "_issue_free_tickets", AsyncMock(return_value=SENTINEL) + ) + monkeypatch.setattr( + views_api, "_resolve_frontend_root", lambda data, req: "http://x" + ) + return SENTINEL + + +async def _buy(amount_tickets: int, sold: int, quantity: int, monkeypatch): + monkeypatch.setattr( + views_api, "get_event", AsyncMock(return_value=_event(amount_tickets, sold)) + ) + return await views_api.api_ticket_create( + "evt", CreateTicket(user_id="u1", quantity=quantity), SimpleNamespace() + ) + + +@pytest.mark.asyncio +async def test_last_ticket_still_sells(monkeypatch, issued): + """One left, one wanted. Previously refused once sold >= remaining.""" + assert await _buy(1, 99, 1, monkeypatch) is issued + + +@pytest.mark.asyncio +async def test_sold_out_only_when_actually_empty(monkeypatch, issued): + with pytest.raises(HTTPException) as exc: + await _buy(0, 100, 1, monkeypatch) + assert exc.value.detail == "Event is sold out." + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sold", [0, 49, 50, 51, 500]) +async def test_remaining_alone_decides_availability(monkeypatch, issued, sold): + """The regression proper: with 50 left the event sells, whatever + `sold` says. Because remaining + sold is the original capacity, + `sold >= amount_tickets` first flips true at the halfway point — + sold=50 here — so an event locked itself once half its seats went. + The boundary cases (49/50/51) are the ones that matter.""" + assert await _buy(50, sold, 1, monkeypatch) is issued + + +@pytest.mark.asyncio +async def test_bulk_order_may_take_everything_left(monkeypatch, issued): + assert await _buy(10, 40, 10, monkeypatch) is issued + + +@pytest.mark.asyncio +async def test_bulk_order_over_capacity_reports_the_true_remainder(monkeypatch, issued): + """3 left, 5 wanted. The message must name the real remainder — it + used to say `3 - 40 = -37`. (`CreateTicket.quantity` is capped at 10 + by the model, so the overshoot is tested within that bound.)""" + with pytest.raises(HTTPException) as exc: + await _buy(3, 40, 5, monkeypatch) + assert exc.value.detail == "Only 3 ticket(s) remaining for this event." + + +@pytest.mark.asyncio +async def test_walk_an_event_to_capacity(monkeypatch, issued): + """50-seat event, one sale at a time, mirroring set_ticket_paid.""" + remaining, sold = 50, 0 + for _ in range(50): + assert await _buy(remaining, sold, 1, monkeypatch) is issued + remaining, sold = remaining - 1, sold + 1 + assert (remaining, sold) == (0, 50) + with pytest.raises(HTTPException) as exc: + await _buy(remaining, sold, 1, monkeypatch) + assert exc.value.detail == "Event is sold out." diff --git a/tests/test_nostr_publish_pending.py b/tests/test_nostr_publish_pending.py new file mode 100644 index 0000000..3caace0 --- /dev/null +++ b/tests/test_nostr_publish_pending.py @@ -0,0 +1,171 @@ +"""The `nostr_publish_pending` marker and its lifecycle. + +Inventory reaches clients only through the republished NIP-52 calendar +event. These tests pin the invariant that makes drift recoverable: the +flag goes up before every attempt and comes down only on a confirmed +success, so every shape of failure — raised, skipped, never attempted — +leaves the row queryable by the sweep. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from .. import nostr_hooks, services +from ..models import Event, Ticket + + +def _event(**kwargs) -> Event: + defaults = { + "id": "evt", + "wallet": "w", + "name": "Test", + "info": "", + "closing_date": "2030-01-01", + "event_start_date": "2030-01-01", + "event_end_date": "2030-01-02", + "currency": "sat", + "price_per_ticket": 1000, + "amount_tickets": 10, + "time": datetime.now(timezone.utc), + "status": "approved", + } + defaults.update(kwargs) + return Event(**defaults) + + +@pytest.fixture +def saved(monkeypatch): + """Capture every update_event write so ordering can be asserted.""" + writes: list[bool] = [] + + async def _update(event): + writes.append(event.nostr_publish_pending) + return event + + monkeypatch.setattr(nostr_hooks, "update_event", _update) + return writes + + +def _signer(monkeypatch, signer): + monkeypatch.setattr( + "lnbits.core.signers.resolve_for_wallet", AsyncMock(return_value=signer) + ) + + +def _publisher(monkeypatch, result): + monkeypatch.setattr( + nostr_hooks, "publish_event_to_nostr", AsyncMock(return_value=result) + ) + + +@pytest.mark.asyncio +async def test_success_raises_then_clears_the_flag(monkeypatch, saved): + event = _event() + _signer(monkeypatch, SimpleNamespace(pubkey="pk")) + _publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123)) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is True + # Flagged before the attempt, cleared after it — in that order. + assert saved == [True, False] + assert event.nostr_publish_pending is False + assert event.nostr_event_id == "nid" + assert event.nostr_event_created_at == 123 + + +@pytest.mark.asyncio +async def test_missing_signer_leaves_the_flag_up(monkeypatch, saved): + event = _event() + _signer(monkeypatch, None) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is False + assert saved == [True] + assert event.nostr_publish_pending is True + + +@pytest.mark.asyncio +async def test_publisher_returning_none_leaves_the_flag_up(monkeypatch, saved): + """The no-NostrClient shape: nothing raised, nothing published.""" + event = _event() + _signer(monkeypatch, SimpleNamespace(pubkey="pk")) + _publisher(monkeypatch, None) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is False + assert saved == [True] + assert event.nostr_publish_pending is True + + +@pytest.mark.asyncio +async def test_raised_publish_leaves_the_flag_up(monkeypatch, saved): + event = _event() + _signer(monkeypatch, SimpleNamespace(pubkey="pk")) + monkeypatch.setattr( + nostr_hooks, + "publish_event_to_nostr", + AsyncMock(side_effect=RuntimeError("signer timeout")), + ) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is False + assert saved == [True] + assert event.nostr_publish_pending is True + + +@pytest.mark.asyncio +async def test_already_pending_row_is_not_re_flagged(monkeypatch, saved): + """The sweep re-publishing a flagged row writes once, not twice.""" + event = _event(nostr_publish_pending=True) + _signer(monkeypatch, SimpleNamespace(pubkey="pk")) + _publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123)) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is True + assert saved == [False] + + +@pytest.mark.asyncio +async def test_delete_clears_the_flag_without_touching_the_coordinate( + monkeypatch, saved +): + """A take-down must not overwrite the id/created_at of the event it + just deleted — the kind-5 has its own.""" + event = _event(nostr_event_id="old", nostr_event_created_at=100) + _signer(monkeypatch, SimpleNamespace(pubkey="pk")) + _publisher(monkeypatch, SimpleNamespace(id="del", created_at=999)) + + assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True + assert event.nostr_publish_pending is False + assert event.nostr_event_id == "old" + assert event.nostr_event_created_at == 100 + + +@pytest.mark.asyncio +async def test_sale_flags_the_event_in_the_same_write(monkeypatch): + """`set_ticket_paid` must flag inside its own update, so the counters + and "the relay doesn't know yet" land atomically.""" + event = _event(sold=4, amount_tickets=6) + seen: list[tuple[int, int, bool]] = [] + + async def _update_event(ev): + seen.append((ev.sold, ev.amount_tickets, ev.nostr_publish_pending)) + return ev + + monkeypatch.setattr(services, "update_ticket", AsyncMock()) + monkeypatch.setattr(services, "get_event", AsyncMock(return_value=event)) + monkeypatch.setattr(services, "update_event", _update_event) + monkeypatch.setattr(services, "publish_or_delete_nostr_event", AsyncMock()) + + ticket = Ticket( + id="t1", + wallet="w", + event="evt", + name="A", + email="a@example.com", + registered=False, + paid=False, + time=datetime.now(timezone.utc), + reg_timestamp=datetime.now(timezone.utc), + ) + await services.set_ticket_paid(ticket) + + assert seen == [(5, 5, True)] diff --git a/tests/test_promo.py b/tests/test_promo.py new file mode 100644 index 0000000..b3bb77e --- /dev/null +++ b/tests/test_promo.py @@ -0,0 +1,218 @@ +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from ..models import ( + Event, + EventExtra, + PromoCode, + PublicEvent, + Ticket, + TicketWave, + ensure_ticket_waves, +) +from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses + + +def _event(currency="sat", price=1000.0, codes=None) -> Event: + return Event( + id="evt", + wallet="w", + name="Test", + info="", + closing_date="2030-01-01", + event_start_date="2030-01-01", + event_end_date="2030-01-02", + currency=currency, + price_per_ticket=price, + amount_tickets=10, + time=datetime.now(timezone.utc), + extra=EventExtra(promo_codes=codes or []), + ) + + +def _wave(event: Event) -> TicketWave: + """Primary wave synthesized from the event's own price/currency. + + These tests exercise promo arithmetic rather than wave selection, so + pricing against the primary wave keeps their original meaning. + """ + return ensure_ticket_waves(event)[0] + + +def _ticket(code, paid=True) -> Ticket: + now = datetime.now(timezone.utc) + return Ticket( + id=f"t-{code}-{paid}", + wallet="w", + event="evt", + registered=False, + paid=paid, + time=now, + reg_timestamp=now, + extra={"applied_promo_code": code}, + ) + + +# --- model ----------------------------------------------------------------- + + +def test_code_is_stripped_and_uppercased(): + assert PromoCode(code=" half ", discount_percent=50).code == "HALF" + + +def test_empty_code_is_rejected(): + with pytest.raises(ValidationError): + PromoCode(code=" ", discount_percent=10) + + +@pytest.mark.parametrize( + "raw,expected", [(None, None), ("", None), (0, None), ("0", None), (3, 3), ("7", 7)] +) +def test_max_uses_normalisation(raw, expected): + assert PromoCode(code="X", max_uses=raw).max_uses == expected + + +def test_max_uses_below_one_rejected(): + with pytest.raises(ValidationError): + PromoCode(code="X", max_uses=-1) + + +def test_discount_bounds(): + with pytest.raises(ValidationError): + PromoCode(code="X", discount_percent=101) + + +def test_public_event_projection_drops_promo_codes(): + event = _event(codes=[PromoCode(code="SECRET", discount_percent=100)]) + public = PublicEvent.parse_obj(event.dict()).dict() + assert "promo_codes" not in public["extra"] + assert public["extra"]["payment_methods"] == [] + # the full model keeps them + assert Event.parse_obj(event.dict()).extra.promo_codes[0].code == "SECRET" + + +# --- helpers --------------------------------------------------------------- + + +def test_normalize_code(): + assert normalize_code(" save20 ") == "SAVE20" + assert normalize_code("") is None + assert normalize_code(None) is None + + +def test_promo_usage_counts_paid_rows_only_per_ticket(): + usage = promo_usage( + [_ticket("HALF"), _ticket("HALF"), _ticket("HALF", paid=False), _ticket(None)] + ) + assert usage == {"HALF": 2} + + +def test_remaining_uses(): + assert remaining_uses(PromoCode(code="X"), 5) is None + assert remaining_uses(PromoCode(code="X", max_uses=3), 1) == 2 + assert remaining_uses(PromoCode(code="X", max_uses=3), 9) == 0 + + +# --- basket_totals ----------------------------------------------------------- + + +def test_sat_totals_round_to_whole_sats(): + event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)]) + totals = basket_totals(event, ["off15"], 1, {}, _wave(event)) + assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50) + assert totals.currency == "sat" + assert totals.discounts_applied[0].dict() == { + "code": "OFF15", + "discount_percent": 15, + "discount_fixed": None, + "amount_saved": 50, + } + + +def test_fiat_totals_round_to_cents_and_scale_by_quantity(): + event = _event( + currency="EUR", + price=19.99, + codes=[PromoCode(code="THIRD", discount_percent=33)], + ) + totals = basket_totals(event, ["THIRD"], 3, {}, _wave(event)) + assert totals.subtotal == 59.97 + assert totals.total == 40.18 + assert totals.discount == 19.79 + assert totals.discount + totals.total == totals.subtotal + + +def test_first_applicable_code_wins(): + event = _event( + codes=[ + PromoCode(code="A", discount_percent=10), + PromoCode(code="B", discount_percent=50), + ] + ) + assert ( + basket_totals(event, ["NOPE", "B", "A"], 1, {}, _wave(event)) + .discounts_applied[0] + .code + == "B" + ) + + +def test_inactive_unknown_zero_and_exhausted_codes_are_absent(): + event = _event( + codes=[ + PromoCode(code="OLD", discount_percent=20, active=False), + PromoCode(code="ZERO", discount_percent=0), + PromoCode(code="TWO", discount_percent=50, max_uses=2), + ] + ) + for codes, usage, qty in ( + (["OLD"], {}, 1), + (["ZERO"], {}, 1), + (["NOPE"], {}, 1), + (["TWO"], {"TWO": 2}, 1), + (["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity + ): + totals = basket_totals(event, codes, qty, usage, _wave(event)) + assert totals.discounts_applied == [] + assert totals.total == totals.subtotal and totals.discount == 0 + + +def test_unlimited_and_partially_used_codes_apply(): + event = _event( + codes=[ + PromoCode(code="TWO", discount_percent=50, max_uses=2), + PromoCode(code="INF", discount_percent=10), + ] + ) + assert basket_totals(event, ["TWO"], 1, {"TWO": 1}, _wave(event)).total == 500 + assert basket_totals(event, ["INF"], 10, {"INF": 999}, _wave(event)).total == 9000 + + +def test_full_discount_prices_to_zero(): + event = _event(codes=[PromoCode(code="FREE", discount_percent=100)]) + assert basket_totals(event, ["FREE"], 2, {}, _wave(event)).total == 0 + + +def test_price_comes_from_the_selected_wave_not_the_event(): + """Regression guard for the v1.6.8 merge. + + `sync_event_ticket_waves` makes `event.price_per_ticket` a roll-up of the + PRIMARY wave, so pricing off the event charged every buyer the first + wave's price no matter which wave they picked. + """ + event = _event(price=1000.0, codes=[PromoCode(code="HALF", discount_percent=50)]) + late = TicketWave( + id="late", + title="Late", + opening_date="2030-01-01", + closing_date="2030-02-01", + currency="sat", + price_per_ticket=2500.0, + amount_tickets=10, + ) + + assert basket_totals(event, [], 2, {}, _wave(event)).total == 2000 + assert basket_totals(event, [], 2, {}, late).total == 5000 + assert basket_totals(event, ["HALF"], 2, {}, late).total == 2500 diff --git a/tests/test_promo_api.py b/tests/test_promo_api.py new file mode 100644 index 0000000..65d1c62 --- /dev/null +++ b/tests/test_promo_api.py @@ -0,0 +1,178 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from .. import views_api +from ..models import CreateTicket, Event, EventExtra, PromoCode, PromoValidateRequest + + +def _event(codes) -> Event: + return Event( + id="evt", + wallet="w", + name="Test", + info="", + closing_date="2030-01-01", + event_start_date="2030-01-01", + event_end_date="2030-01-02", + currency="sat", + price_per_ticket=1000, + amount_tickets=10, + time=datetime.now(timezone.utc), + extra=EventExtra(promo_codes=codes), + status="approved", + ) + + +@pytest.fixture +def event(monkeypatch): + ev = _event( + [ + PromoCode(code="HALF", discount_percent=50, max_uses=2), + PromoCode(code="OLD", discount_percent=20, active=False), + ] + ) + monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=ev)) + monkeypatch.setattr( + views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 1}) + ) + return ev + + +@pytest.mark.asyncio +async def test_validate_returns_v2_shaped_totals(event): + totals = await views_api.api_validate_promo_codes( + "evt", PromoValidateRequest(codes=["half"], quantity=1) + ) + assert totals.dict() == { + "subtotal": 1000, + "discount": 500, + "total": 500, + "currency": "sat", + "discounts_applied": [ + { + "code": "HALF", + "discount_percent": 50, + "discount_fixed": None, + "amount_saved": 500, + } + ], + } + + +@pytest.mark.asyncio +async def test_validate_is_advisory_for_bad_codes(event): + for codes, qty in ((["OLD"], 1), (["NOPE"], 1), (["HALF"], 2)): + totals = await views_api.api_validate_promo_codes( + "evt", PromoValidateRequest(codes=codes, quantity=qty) + ) + assert totals.discounts_applied == [] and totals.total == totals.subtotal + + +@pytest.mark.asyncio +async def test_validate_unknown_event_is_404(monkeypatch): + monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=None)) + with pytest.raises(HTTPException) as exc: + await views_api.api_validate_promo_codes( + "nope", PromoValidateRequest(codes=["X"]) + ) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "code,quantity,detail", + [ + ("NOPE", 1, "Invalid promo code."), + ("old", 1, "Promo code is not active."), + ("HALF", 2, "Only 1 use(s) left on this promo code."), + ], +) +async def test_purchase_rejects_bad_codes_before_any_invoice( + event, monkeypatch, code, quantity, detail +): + monkeypatch.setattr( + views_api, + "create_payment_request", + AsyncMock(side_effect=AssertionError("must not be called")), + ) + data = CreateTicket(user_id="u1", promo_code=code, quantity=quantity) + with pytest.raises(HTTPException) as exc: + await views_api.api_ticket_create( + "evt", data, SimpleNamespace(base_url="http://lnbits.local/") + ) + assert exc.value.status_code == 400 + assert exc.value.detail == detail + + +@pytest.mark.asyncio +async def test_purchase_reports_fully_redeemed(event, monkeypatch): + monkeypatch.setattr( + views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 2}) + ) + monkeypatch.setattr( + views_api, + "create_payment_request", + AsyncMock(side_effect=AssertionError("must not be called")), + ) + with pytest.raises(HTTPException) as exc: + await views_api.api_ticket_create( + "evt", + CreateTicket(user_id="u1", promo_code="HALF"), + SimpleNamespace(base_url="http://lnbits.local/"), + ) + assert exc.value.detail == "Promo code has been fully redeemed." + + +@pytest.fixture +def update_env(monkeypatch): + stored = _event([PromoCode(code="KEEP", discount_percent=10)]) + monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=stored)) + monkeypatch.setattr( + views_api, + "get_settings", + AsyncMock(return_value=SimpleNamespace(auto_approve=True)), + ) + monkeypatch.setattr(views_api, "update_event", AsyncMock(side_effect=lambda e: e)) + monkeypatch.setattr(views_api, "publish_or_delete_nostr_event", AsyncMock()) + return stored + + +def _update_payload(extra: dict) -> dict: + return { + "wallet": "w", + "name": "Test", + "info": "", + "closing_date": "2030-01-01", + "event_start_date": "2030-01-01", + "event_end_date": "2030-01-02", + "amount_tickets": 10, + "price_per_ticket": 1000, + "extra": extra, + } + + +def _wallet(): + return SimpleNamespace(wallet=SimpleNamespace(id="w", user="u1")) + + +@pytest.mark.asyncio +async def test_update_without_promo_key_keeps_stored_codes(update_env): + from ..models import CreateEvent + + data = CreateEvent.parse_obj(_update_payload({"email_notifications": True})) + event = await views_api.api_event_update("evt", data, _wallet()) + assert [pc.code for pc in event.extra.promo_codes] == ["KEEP"] + assert event.extra.email_notifications is True + + +@pytest.mark.asyncio +async def test_update_with_empty_promo_list_clears(update_env): + from ..models import CreateEvent + + data = CreateEvent.parse_obj(_update_payload({"promo_codes": []})) + event = await views_api.api_event_update("evt", data, _wallet()) + assert event.extra.promo_codes == [] diff --git a/tests/test_publish_active_wave.py b/tests/test_publish_active_wave.py new file mode 100644 index 0000000..964a0c4 --- /dev/null +++ b/tests/test_publish_active_wave.py @@ -0,0 +1,195 @@ +"""The NIP-52 tags describe the ACTIVE ticket wave, not the roll-up (#61). + +Upstream v1.6.8 moved price, currency and inventory onto time-boxed waves +and made the event-level fields derived: `price_per_ticket` and `currency` +become the PRIMARY (first) wave's, `amount_tickets` the SUM across every +wave. Publishing those would advertise the early-bird price after early +bird closed and count stock in waves that have not opened yet. +""" + +from datetime import datetime, timedelta, timezone +from typing import cast + +import pytest + +from ..models import ( + Event, + EventExtra, + TicketWave, + advertised_wave_key, + sync_event_ticket_waves, +) +from ..nostr_publisher import build_nip52_event + +PUBKEY = "a" * 64 +TODAY = datetime.now(timezone.utc).date() + + +def _day(offset: int) -> str: + return (TODAY + timedelta(days=offset)).isoformat() + + +def _wave( + wave_id: str, + price: float, + stock: int, + opens: int, + closes: int, + *, + currency: str = "EUR", + allow_fiat: bool = False, +) -> TicketWave: + return TicketWave( + id=wave_id, + title=wave_id, + opening_date=_day(opens), + closing_date=_day(closes), + currency=currency, + price_per_ticket=price, + amount_tickets=stock, + allow_fiat=allow_fiat, + fiat_currency="GBP", + ) + + +def _event(waves: list[TicketWave], sold: int = 0) -> Event: + event = Event( + id="evt", + wallet="w", + name="Waves", + info="", + closing_date=_day(30), + event_start_date=_day(30), + event_end_date=_day(30), + currency="sat", + price_per_ticket=0, + amount_tickets=0, + sold=sold, + time=datetime.now(timezone.utc), + status="approved", + extra=EventExtra(ticket_waves=waves), + ) + # Mirrors the CRUD layer, which syncs on every read and write. + return cast(Event, sync_event_ticket_waves(event)) + + +def _tags(event: Event) -> dict[str, str]: + return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1} + + +CLOSED_EARLY_BIRD = _wave("early", 10.0, 5, -10, -1) +OPEN_REGULAR = _wave("regular", 25.0, 40, 0, 20) +UNOPENED_VIP = _wave("vip", 50.0, 10, 5, 25) + + +def test_closed_wave_price_is_not_advertised(): + """The defect this fixes: early bird closed yesterday.""" + event = _event([CLOSED_EARLY_BIRD, OPEN_REGULAR, UNOPENED_VIP]) + + # What the event-level roll-up would have published. + assert event.price_per_ticket == 10.0 + assert event.amount_tickets == 55 + + tags = _tags(event) + assert tags["tickets_price"] == "25.0" + assert tags["tickets_available"] == "40" + + +def test_unopened_wave_stock_is_not_counted(): + event = _event([OPEN_REGULAR, UNOPENED_VIP]) + assert _tags(event)["tickets_available"] == "40" + + +def test_cheapest_open_wave_wins_when_several_are_open(): + """A publisher cannot ask which wave the buyer wants, so it advertises + the price a buyer is actually able to obtain.""" + cheaper = _wave("cheap", 15.0, 3, 0, 10) + tags = _tags(_event([OPEN_REGULAR, cheaper])) + assert tags["tickets_price"] == "15.0" + assert tags["tickets_available"] == "3" + + +@pytest.mark.parametrize( + "waves", + [ + pytest.param([CLOSED_EARLY_BIRD], id="all-closed"), + pytest.param([UNOPENED_VIP], id="not-yet-open"), + pytest.param([_wave("only", 25.0, 0, 0, 20)], id="sold-out"), + ], +) +def test_no_open_wave_publishes_zero_availability(waves): + """Never omit the tag: omission meant "unlimited" (#34, #62).""" + tags = _tags(_event(waves)) + assert tags["tickets_available"] == "0" + + +def test_currency_and_fiat_follow_the_advertised_wave(): + fiat_primary = _wave("a", 10.0, 0, -10, -1, currency="GBP", allow_fiat=True) + sats_open = _wave("b", 2500.0, 9, 0, 20, currency="sat", allow_fiat=False) + tags = _tags(_event([fiat_primary, sats_open])) + + assert tags["tickets_currency"] == "sat" + assert "tickets_allow_fiat" not in tags + # Must agree with tickets_allow_fiat — they are the same fact, and a + # client rendering a fiat button here would hit a purchase-time refusal. + assert tags["tickets_payment_methods"] == "lightning" + + +def test_payment_methods_offer_fiat_when_the_advertised_wave_accepts_it(): + tags = _tags( + _event( + [ + _wave("a", 10.0, 0, -10, -1, allow_fiat=False), + _wave("b", 25.0, 9, 0, 20, allow_fiat=True), + ] + ) + ) + assert tags["tickets_allow_fiat"] == "true" + assert tags["tickets_payment_methods"] == "lightning,fiat" + + +def test_sold_stays_event_level(): + """`tickets_sold` is the total paid across all waves.""" + assert _tags(_event([OPEN_REGULAR], sold=7))["tickets_sold"] == "7" + + +def test_advertised_wave_key_tracks_the_published_wave(): + assert advertised_wave_key(_event([CLOSED_EARLY_BIRD, OPEN_REGULAR])) == "regular" + # Published while nothing is on sale — a real state, distinct from the + # NULL that means "never published". + assert advertised_wave_key(_event([CLOSED_EARLY_BIRD])) == "" + + +def test_published_rails_drop_fiat_when_the_advertised_wave_cannot_take_it(): + """The webapp always sets `extra.payment_methods`, so the explicit-list + path is the normal one — and it used to ignore the wave entirely. + + That published `tickets_payment_methods: lightning,fiat` beside an + absent `tickets_allow_fiat`, and a card button the purchase endpoint + then refused ("Fiat payments are not enabled for this ticket wave"). + """ + event = _event( + [ + _wave("a", 10.0, 0, -10, -1, allow_fiat=True), + _wave("b", 25.0, 9, 0, 20, allow_fiat=False), + ] + ) + event.extra.payment_methods = ["lightning", "fiat"] + tags = _tags(event) + + assert "tickets_allow_fiat" not in tags + assert tags["tickets_payment_methods"] == "lightning" + + +def test_published_rails_keep_fiat_when_the_advertised_wave_takes_it(): + event = _event( + [ + _wave("a", 10.0, 0, -10, -1, allow_fiat=False), + _wave("b", 25.0, 9, 0, 20, allow_fiat=True), + ] + ) + event.extra.payment_methods = ["lightning", "fiat"] + tags = _tags(event) + + assert tags["tickets_allow_fiat"] == "true" + assert tags["tickets_payment_methods"] == "lightning,fiat" diff --git a/tests/test_publish_availability.py b/tests/test_publish_availability.py new file mode 100644 index 0000000..0ef88a5 --- /dev/null +++ b/tests/test_publish_availability.py @@ -0,0 +1,56 @@ +"""`tickets_available` is always published, including zero (#34). + +Omitting the tag used to mean "unlimited", which contradicted every other +reader: `api_get_event` and `api_ticket_create` both treat +`amount_tickets < 1` as sold out. On aio-demo three zero-capacity events +advertised "Unlimited tickets" on the card while the detail endpoint and +the purchase both returned 410. +""" + +from datetime import datetime, timezone + +import pytest + +from ..models import Event +from ..nostr_publisher import build_nip52_event + +PUBKEY = "a" * 64 + + +def _event(amount_tickets: int, sold: int = 0) -> Event: + return Event( + id="evt", + wallet="w", + name="Availability", + info="", + closing_date="2030-01-01", + event_start_date="2030-01-01T18:00", + event_end_date="2030-01-01T22:00", + currency="sat", + price_per_ticket=0, + amount_tickets=amount_tickets, + sold=sold, + time=datetime.now(timezone.utc), + status="approved", + ) + + +def _tags(event: Event) -> dict[str, str]: + return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1} + + +@pytest.mark.parametrize("amount", [0, 1, 50]) +def test_tickets_available_is_always_present(amount): + assert _tags(_event(amount))["tickets_available"] == str(amount) + + +def test_zero_capacity_reads_as_sold_out_not_unlimited(): + """The regression: an absent tag is what clients render as unlimited.""" + tags = _tags(_event(0, sold=0)) + assert "tickets_available" in tags + assert tags["tickets_available"] == "0" + + +def test_sold_out_after_selling_through_still_publishes_zero(): + assert _tags(_event(0, sold=25))["tickets_available"] == "0" + assert _tags(_event(0, sold=25))["tickets_sold"] == "25" diff --git a/tests/test_publish_confirmation.py b/tests/test_publish_confirmation.py new file mode 100644 index 0000000..9615209 --- /dev/null +++ b/tests/test_publish_confirmation.py @@ -0,0 +1,105 @@ +"""Publish confirmation against the relay's `OK` (aiolabs/events#56). + +Queueing is not delivery. nostrclient drops an EVENT outright when no +relay is connected and answers `OK false`; before this, that reply was +discarded and the publish reported success, which once cleared the +`nostr_publish_pending` flag on a republish that never left the +building. +""" + +import asyncio +import json + +import pytest + +from ..nostr import nostr_client as nc +from ..nostr.event import NostrEvent + + +def _event(event_id: str = "a" * 64) -> NostrEvent: + e = NostrEvent(pubkey="b" * 64, created_at=0, kind=31923) + e.id = event_id + return e + + +async def _publish_and_reply(client, event, reply, delay=0.01): + """Start a publish, then feed `reply` through the receive path.""" + task = asyncio.create_task(client.publish_nostr_event(event)) + await asyncio.sleep(delay) # let the future register + if reply is not None: + client.receive_event_queue.put_nowait(reply) + consumer = asyncio.create_task(client.get_event()) + await asyncio.sleep(delay) + consumer.cancel() + return await task + + +@pytest.mark.asyncio +async def test_accepted_publish_returns_true(): + client = nc.NostrClient() + event = _event() + ok = json.dumps(["OK", event.id, True, ""]) + assert await _publish_and_reply(client, event, ok) is True + assert client._pending_oks == {} + + +@pytest.mark.asyncio +async def test_rejected_publish_returns_false(): + """The shape that bit us: no relay connected, so nostrclient's + router answers `OK false` without the event ever being sent.""" + client = nc.NostrClient() + event = _event() + ok = json.dumps(["OK", event.id, False, "error: no relays connected"]) + assert await _publish_and_reply(client, event, ok) is False + assert client._pending_oks == {} + + +@pytest.mark.asyncio +async def test_missing_ok_times_out_as_unconfirmed(monkeypatch): + monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05) + client = nc.NostrClient() + assert await _publish_and_reply(client, _event(), None) is False + assert client._pending_oks == {} + + +@pytest.mark.asyncio +async def test_ok_for_a_different_event_does_not_settle_ours(monkeypatch): + monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05) + client = nc.NostrClient() + other = json.dumps(["OK", "c" * 64, True, ""]) + assert await _publish_and_reply(client, _event(), other) is False + + +@pytest.mark.asyncio +async def test_get_event_swallows_ok_and_forwards_everything_else(): + client = nc.NostrClient() + client.receive_event_queue.put_nowait(json.dumps(["OK", "d" * 64, True, ""])) + forwarded = json.dumps(["EVENT", "sub", {"id": "e" * 64}]) + client.receive_event_queue.put_nowait(forwarded) + assert await client.get_event() == forwarded + + +@pytest.mark.asyncio +async def test_disconnect_settles_inflight_publishes_immediately(monkeypatch): + """A dropped socket must not leave the caller waiting the full + timeout for an OK that can no longer arrive.""" + monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 30) + client = nc.NostrClient() + event = _event() + task = asyncio.create_task(client.publish_nostr_event(event)) + await asyncio.sleep(0.01) + + client.receive_event_queue.put_nowait(ValueError("WebSocket closed")) + consumer = asyncio.create_task(client.get_event()) + await asyncio.sleep(0.01) + consumer.cancel() + + assert await asyncio.wait_for(task, 1) is False + + +def test_settle_ok_ignores_non_ok_frames(): + client = nc.NostrClient() + assert client._settle_ok(json.dumps(["EVENT", "sub", {}])) is False + assert client._settle_ok(json.dumps(["EOSE", "sub"])) is False + assert client._settle_ok("not json") is False + assert client._settle_ok(json.dumps(["OK", "f" * 64, True, ""])) is True diff --git a/tests/test_ticket_models.py b/tests/test_ticket_models.py index 7d1ab3f..6d782d2 100644 --- a/tests/test_ticket_models.py +++ b/tests/test_ticket_models.py @@ -5,6 +5,10 @@ from ..models import ( CreateEvent, CreateTicket, EventExtra, + PromoCode, + PublicEvent, + PublicEventExtra, + TicketWave, effective_payment_methods, ) @@ -108,3 +112,108 @@ def test_payment_methods_are_normalised_and_deduplicated(): def test_unknown_payment_method_is_rejected(): with pytest.raises(ValidationError): EventExtra(payment_methods=["cash"]) + + +# --- public projection ------------------------------------------------------ + + +def test_public_extra_exposes_waves_but_never_promo_codes(): + """A buyer needs a wave id to choose a tier, so waves are public; promo + codes stay organizer-only (aiolabs/events#61, v1.6.1-aio.12).""" + organizer = EventExtra( + promo_codes=[PromoCode(code="SECRET", discount_percent=50)], + ticket_waves=[ + TicketWave( + id="early", + title="Early", + opening_date="2030-01-01", + closing_date="2030-02-01", + currency="sat", + price_per_ticket=10, + amount_tickets=5, + ) + ], + ) + + public = PublicEventExtra(**organizer.dict()) + + assert [wave.id for wave in public.ticket_waves] == ["early"] + assert public.ticket_waves[0].price_per_ticket == 10 + assert not hasattr(public, "promo_codes") + assert "promo_codes" not in public.dict() + + +def test_public_event_response_carries_waves(): + """End of the chain: what `GET /events/{id}` actually serialises.""" + wave = TicketWave( + id="regular", + title="Regular", + opening_date="2030-01-01", + closing_date="2030-02-01", + currency="sat", + price_per_ticket=25, + amount_tickets=40, + ) + organizer_extra = EventExtra( + ticket_waves=[wave], promo_codes=[PromoCode(code="SECRET")] + ) + public = PublicEvent( + id="evt", + name="Test", + info="", + canceled=False, + event_start_date="2030-01-01", + currency="sat", + price_per_ticket=25, + banner=None, + extra=PublicEventExtra(**organizer_extra.dict()), + ) + body = public.dict() + + assert [w["id"] for w in body["extra"]["ticket_waves"]] == ["regular"] + assert "promo_codes" not in body["extra"] + + +# --- rails vs per-wave fiat -------------------------------------------------- + + +def _fiat_wave(allow_fiat: bool): + from ..models import TicketWave + + return TicketWave( + id="w", + title="w", + opening_date="2030-01-01", + closing_date="2030-02-01", + currency="EUR", + price_per_ticket=10, + amount_tickets=5, + allow_fiat=allow_fiat, + ) + + +def test_explicit_rails_drop_fiat_for_a_wave_that_cannot_take_it(): + """The organiser's rail list is event-level; fiat is per-wave. + + Without this the NIP-52 tag advertises fiat and the checkout renders a + card button that `api_ticket_create` refuses with "Fiat payments are + not enabled for this ticket wave" (reported on aio-demo). + """ + event = _event() + event.extra.payment_methods = ["lightning", "fiat"] + + assert effective_payment_methods(event, _fiat_wave(True)) == ["lightning", "fiat"] + assert effective_payment_methods(event, _fiat_wave(False)) == ["lightning"] + + +def test_event_level_question_still_reports_every_rail(): + """No wave means "what did the organiser enable at all" — unfiltered.""" + event = _event() + event.extra.payment_methods = ["lightning", "fiat"] + assert effective_payment_methods(event) == ["lightning", "fiat"] + + +def test_fiat_only_rails_on_a_non_fiat_wave_leave_nothing_purchasable(): + event = _event() + event.extra.payment_methods = ["fiat"] + assert effective_payment_methods(event, _fiat_wave(False)) == [] diff --git a/tests/test_wave_capacity_required.py b/tests/test_wave_capacity_required.py new file mode 100644 index 0000000..5f40c4c --- /dev/null +++ b/tests/test_wave_capacity_required.py @@ -0,0 +1,118 @@ +"""Capacity is required per ticket wave (#34, #62). + +"Capacity is always required, there is no unlimited" was settled when +capacity was one number on the event. Since v1.6.8 it is per-wave and +`event.amount_tickets` is a derived roll-up, so the rule has to bite where +the organiser sets it. A zero-capacity wave can never be active +(`get_active_ticket_waves` requires `> 0`), so it is the wave-level form of +the trap #62 removed: an event that looks on sale but refuses every +purchase. +""" + +from datetime import datetime, timedelta, timezone +from http import HTTPStatus + +import pytest +from fastapi import HTTPException + +from ..models import CreateEvent, Event, EventExtra, TicketWave +from ..views_api import _validate_wave_capacity + +TODAY = datetime.now(timezone.utc).date() + + +def _day(offset: int) -> str: + return (TODAY + timedelta(days=offset)).isoformat() + + +def _wave(wave_id: str, stock: int) -> TicketWave: + return TicketWave( + id=wave_id, + title=wave_id, + opening_date=_day(0), + closing_date=_day(20), + currency="sat", + price_per_ticket=10, + amount_tickets=stock, + ) + + +def _create(waves=None, amount_tickets=10) -> CreateEvent: + return CreateEvent( + wallet="w", + name="Fete", + info="", + event_start_date=_day(30), + currency="sat", + price_per_ticket=10, + amount_tickets=amount_tickets, + extra=EventExtra(ticket_waves=waves or []), + ) + + +def _stored(waves) -> Event: + return Event( + id="evt", + wallet="w", + name="Fete", + info="", + closing_date=_day(30), + event_start_date=_day(30), + currency="sat", + price_per_ticket=10, + amount_tickets=sum(w.amount_tickets for w in waves), + time=datetime.now(timezone.utc), + extra=EventExtra(ticket_waves=waves), + ) + + +def _detail(exc_info) -> str: + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + return exc_info.value.detail + + +def test_wave_with_capacity_is_accepted(): + _validate_wave_capacity(_create([_wave("early", 5), _wave("late", 10)])) + + +def test_zero_capacity_wave_is_rejected_on_create(): + with pytest.raises(HTTPException) as exc_info: + _validate_wave_capacity(_create([_wave("early", 5), _wave("late", 0)])) + assert "late" in _detail(exc_info) + + +def test_event_submitted_without_waves_is_checked_through_its_primary_wave(): + """No waves means the event gets a synthesized primary wave seeded from + `amount_tickets`, so the rule must see that rather than an empty list.""" + _validate_wave_capacity(_create(waves=None, amount_tickets=10)) + + with pytest.raises(HTTPException) as exc_info: + _validate_wave_capacity(_create(waves=None, amount_tickets=0)) + assert "Primary wave" in _detail(exc_info) + + +def test_selling_a_wave_out_does_not_block_later_edits(): + """Zero is where a wave legitimately ends up. Rejecting it on edit would + make a sold-out event uneditable.""" + sold_out = _wave("early", 0) + existing = _stored([sold_out, _wave("late", 10)]) + + _validate_wave_capacity(_create([sold_out, _wave("late", 10)]), existing) + + +def test_new_wave_added_by_an_edit_still_needs_capacity(): + existing = _stored([_wave("early", 5)]) + + with pytest.raises(HTTPException) as exc_info: + _validate_wave_capacity( + _create([_wave("early", 5), _wave("brand-new", 0)]), existing + ) + assert "brand-new" in _detail(exc_info) + + +def test_legacy_zero_capacity_event_stays_editable(): + """An event stored before the rule existed keeps its primary wave id, so + an edit is not blocked — otherwise the only way to fix it would be + barred.""" + existing = _stored([_wave("primary", 0)]) + _validate_wave_capacity(_create([_wave("primary", 0)]), existing) diff --git a/tests/test_wave_preservation_on_edit.py b/tests/test_wave_preservation_on_edit.py new file mode 100644 index 0000000..1ab1b96 --- /dev/null +++ b/tests/test_wave_preservation_on_edit.py @@ -0,0 +1,124 @@ +"""Editing an event must not destroy its ticket waves. + +`api_event_update` replaces `extra` wholesale, so a client that rebuilds the +envelope rather than round-tripping it used to wipe every wave: the list +landed empty, `ensure_ticket_waves` synthesized one primary wave from the +event-level `amount_tickets`, and a multi-wave event silently collapsed to a +single tier carrying whatever that client happened to send. Same hazard the +`promo_codes` guard already covered. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from ..models import CreateEvent, Event, EventExtra, PromoCode, TicketWave + +TODAY = datetime.now(timezone.utc).date() + + +def _day(offset: int) -> str: + return (TODAY + timedelta(days=offset)).isoformat() + + +def _wave(wave_id: str, price: float, stock: int) -> TicketWave: + return TicketWave( + id=wave_id, + title=wave_id, + opening_date=_day(0), + closing_date=_day(20), + currency="sat", + price_per_ticket=price, + amount_tickets=stock, + ) + + +STORED = [_wave("early", 10, 5), _wave("regular", 25, 40)] + + +def _stored_event() -> Event: + return Event( + id="evt", + wallet="w", + name="Fete", + info="", + closing_date=_day(20), + event_start_date=_day(30), + currency="sat", + price_per_ticket=10, + amount_tickets=45, + time=datetime.now(timezone.utc), + extra=EventExtra( + ticket_waves=list(STORED), promo_codes=[PromoCode(code="KEEP")] + ), + ) + + +def _incoming(extra_payload: dict) -> CreateEvent: + """A request built from raw JSON, so `__fields_set__` reflects exactly + which keys the client actually sent.""" + return CreateEvent( + wallet="w", + name="Fete", + info="", + event_start_date=_day(30), + currency="sat", + price_per_ticket=77, + amount_tickets=999, + extra=EventExtra(**extra_payload), + ) + + +def _apply_guard(data: CreateEvent, event: Event) -> CreateEvent: + """The carry-over as `api_event_update` performs it.""" + if "ticket_waves" not in data.extra.__fields_set__: + data.extra.ticket_waves = event.extra.ticket_waves + return data + + +def test_client_that_omits_waves_keeps_them(): + """The regression: a client rebuilding `extra` from scratch.""" + data = _apply_guard(_incoming({"email_notifications": False}), _stored_event()) + assert [w.id for w in data.extra.ticket_waves] == ["early", "regular"] + assert [w.price_per_ticket for w in data.extra.ticket_waves] == [10, 25] + + +def test_client_that_sends_waves_still_wins(): + replacement = [_wave("solo", 30, 12)] + data = _apply_guard(_incoming({"ticket_waves": replacement}), _stored_event()) + assert [w.id for w in data.extra.ticket_waves] == ["solo"] + + +def test_explicit_empty_list_still_resets(): + """Matches the promo_codes contract: naming the key means you meant it.""" + data = _apply_guard(_incoming({"ticket_waves": []}), _stored_event()) + assert data.extra.ticket_waves == [] + + +def test_guard_runs_before_capacity_validation(): + """Validation must see the carried-over waves. + + Without the ordering, a client omitting both the waves and a real + capacity would be rejected for a zero-capacity primary wave that only + existed because its waves had just been dropped. + """ + from ..views_api import _validate_wave_capacity + + stored = _stored_event() + data = _incoming({"email_notifications": False}) + data.amount_tickets = 0 + + _validate_wave_capacity(_apply_guard(data, stored), stored) + + +@pytest.mark.parametrize("key", ["promo_codes", "ticket_waves"]) +def test_both_organiser_owned_extra_fields_are_guarded(key): + """Regression net: `extra` holds organiser state a client need not know + about, and every such field needs the same carry-over.""" + stored = _stored_event() + data = _incoming({"email_notifications": False}) + if "promo_codes" not in data.extra.__fields_set__: + data.extra.promo_codes = stored.extra.promo_codes + _apply_guard(data, stored) + + assert getattr(data.extra, key), f"{key} was dropped on edit" diff --git a/tests/test_wave_transition_sweep.py b/tests/test_wave_transition_sweep.py new file mode 100644 index 0000000..0c57e13 --- /dev/null +++ b/tests/test_wave_transition_sweep.py @@ -0,0 +1,159 @@ +"""Wave boundaries trigger a republish (#61). + +Every republish this extension performs is sale-driven. A wave boundary is +a *date* boundary, so when early bird closes at midnight nothing fires and +the relay keeps serving the closed wave's price until the next ticket +happens to sell. `flag_wave_transitions` closes that gap by comparing the +wave a row would advertise now against the one its last successful publish +did, handing the work to the existing reconciliation sweep. +""" + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from .. import crud, nostr_hooks +from ..models import Event, EventExtra, TicketWave + +TODAY = datetime.now(timezone.utc).date() + + +def _day(offset: int) -> str: + return (TODAY + timedelta(days=offset)).isoformat() + + +def _wave(wave_id: str, price: float, stock: int, opens: int, closes: int): + return TicketWave( + id=wave_id, + title=wave_id, + opening_date=_day(opens), + closing_date=_day(closes), + currency="EUR", + price_per_ticket=price, + amount_tickets=stock, + ) + + +def _event(waves, published_wave_id, pending=False) -> Event: + return Event( + id="evt", + wallet="w", + name="Waves", + info="", + closing_date=_day(30), + event_start_date=_day(30), + event_end_date=_day(30), + currency="sat", + price_per_ticket=0, + amount_tickets=0, + time=datetime.now(timezone.utc), + status="approved", + nostr_publish_pending=pending, + nostr_published_wave_id=published_wave_id, + extra=EventExtra(ticket_waves=waves), + ) + + +@pytest.fixture +def swept(monkeypatch): + """Capture rows the detector writes back.""" + written: list[Event] = [] + + async def _update(event): + written.append(event) + return event + + monkeypatch.setattr(crud, "update_event", _update) + return written + + +def _rows(monkeypatch, events): + monkeypatch.setattr(crud.db, "fetchall", AsyncMock(return_value=events)) + + +CLOSED = _wave("early", 10.0, 5, -10, -1) +OPEN = _wave("regular", 25.0, 40, 0, 20) + + +@pytest.mark.asyncio +async def test_moved_wave_is_flagged(monkeypatch, swept): + """Early bird closed; the relay still advertises it.""" + _rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="early")]) + + assert await crud.flag_wave_transitions() == 1 + assert [e.nostr_publish_pending for e in swept] == [True] + + +@pytest.mark.asyncio +async def test_unchanged_wave_is_left_alone(monkeypatch, swept): + _rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="regular")]) + + assert await crud.flag_wave_transitions() == 0 + assert swept == [] + + +@pytest.mark.asyncio +async def test_selling_out_the_open_wave_is_a_transition(monkeypatch, swept): + """No wave open is itself an advertisable state, and a different one.""" + sold_out = _wave("regular", 25.0, 0, 0, 20) + _rows(monkeypatch, [_event([sold_out], published_wave_id="regular")]) + + assert await crud.flag_wave_transitions() == 1 + + +@pytest.mark.asyncio +async def test_already_advertising_nothing_is_not_reflagged(monkeypatch, swept): + """ "" means "published while nothing was on sale" — not drift.""" + _rows(monkeypatch, [_event([CLOSED], published_wave_id="")]) + + assert await crud.flag_wave_transitions() == 0 + assert swept == [] + + +@pytest.mark.asyncio +async def test_never_published_rows_are_skipped(monkeypatch, swept): + """NULL is no evidence of drift. Flagging these would republish the + whole table on the first boot after the upgrade.""" + _rows(monkeypatch, []) # the SQL filters them out + + assert await crud.flag_wave_transitions() == 0 + + +@pytest.mark.asyncio +async def test_publish_records_the_advertised_wave(monkeypatch): + """The sweep can only detect drift if a success writes the wave down.""" + event = _event([CLOSED, OPEN], published_wave_id="early") + monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e)) + monkeypatch.setattr( + "lnbits.core.signers.resolve_for_wallet", + AsyncMock(return_value=SimpleNamespace(pubkey="pk")), + ) + monkeypatch.setattr( + nostr_hooks, + "publish_event_to_nostr", + AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)), + ) + + assert await nostr_hooks.publish_or_delete_nostr_event(event) is True + assert event.nostr_published_wave_id == "regular" + + +@pytest.mark.asyncio +async def test_delete_does_not_record_a_wave(monkeypatch): + """A takedown advertises nothing, so it must not claim a wave.""" + event = _event([CLOSED, OPEN], published_wave_id="early") + monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e)) + monkeypatch.setattr( + "lnbits.core.signers.resolve_for_wallet", + AsyncMock(return_value=SimpleNamespace(pubkey="pk")), + ) + monkeypatch.setattr( + nostr_hooks, + "publish_event_to_nostr", + AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)), + ) + + assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True + assert event.nostr_published_wave_id == "early" diff --git a/views_api.py b/views_api.py index ac0b0ba..94b5bad 100644 --- a/views_api.py +++ b/views_api.py @@ -2,6 +2,7 @@ import asyncio from datetime import datetime, timezone from http import HTTPStatus from io import BytesIO +from pathlib import Path from typing import Any from urllib.parse import urlsplit @@ -16,17 +17,20 @@ from fastapi import ( ) from fastapi.responses import StreamingResponse from lnbits.core.crud import get_user +from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import Account, User, WalletTypeInfo from lnbits.core.models.payments import CreateInvoice from lnbits.core.services import create_payment_request +from lnbits.db import Filters, Page from lnbits.decorators import ( check_admin, check_user_exists, + parse_filters, require_admin_key, require_invoice_key, ) -from lnbits.helpers import urlsafe_short_hash +from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, @@ -34,6 +38,7 @@ from lnbits.utils.exchange_rates import ( satoshis_amount_as_fiat, ) from lnbits.utils.nostr import normalize_public_key +from PIL import Image from .crud import ( create_event, @@ -53,24 +58,38 @@ from .crud import ( get_tickets_by_event, get_tickets_by_payment_hash, get_tickets_by_user_id, + get_tickets_paginated, purge_unpaid_tickets, update_event, update_settings, update_ticket, ) from .models import ( + BasketTotals, CreateEvent, CreateTicket, Event, EventsSettings, + PromoValidateRequest, PublicEvent, PublicTicket, Ticket, + TicketFilters, TicketPaymentRequest, TicketResendResult, + TicketWave, effective_payment_methods, + ensure_ticket_waves, + get_active_ticket_waves, ) from .nostr_hooks import publish_or_delete_nostr_event +from .promo import ( + basket_totals, + find_promo, + normalize_code, + remaining_uses, + round_amount, +) from .qr import ( image_png_bytes, load_qr_logo, @@ -79,16 +98,23 @@ from .qr import ( ticket_card_filename, ) from .services import ( + event_promo_usage, + hydrate_promo_usage, refund_tickets, resend_ticket_email_notification, send_ticket_notification_in_background, set_ticket_paid, ) -from .tasks import deregister_payment_listener, register_payment_listener +from .tasks import ( + deregister_payment_listener, + register_payment_listener, +) events_api_router = APIRouter(prefix="/api/v1/events") tickets_api_router = APIRouter(prefix="/api/v1/tickets") qr_api_router = APIRouter(prefix="/api/v1") +promo_api_router = APIRouter(prefix="/api/v1/promo") +tickets_filters = parse_filters(TicketFilters) def _is_fiat_currency(currency: str | None) -> bool: @@ -97,8 +123,6 @@ def _is_fiat_currency(currency: str | None) -> bool: # Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared # before any "/{event_id}" route or FastAPI matches them as a path parameter. - - @events_api_router.get("") async def api_events( all_wallets: bool = Query(False), @@ -108,12 +132,17 @@ async def api_events( if all_wallets: user = await get_user(wallet.wallet.user) wallet_ids = user.wallet_ids if user else [] - return await get_events(wallet_ids) + events = await get_events(wallet_ids) + for event in events: + await hydrate_promo_usage(event) + return events -@events_api_router.get("/public") +@events_api_router.get("/public", response_model=list[PublicEvent]) async def api_events_public() -> list[Event]: - """Approved, non-canceled events for an anonymous public listing.""" + """Approved, non-canceled events for an anonymous public listing. + + Projected through `PublicEvent`: no wallet id, no promo codes.""" return await get_public_events() @@ -131,6 +160,7 @@ async def api_events_all( events = await get_all_events() enriched: list[dict] = [] for event in events: + await hydrate_promo_usage(event) wallet = await get_wallet(event.wallet) row = event.dict() row["wallet_user_id"] = wallet.user if wallet else None @@ -255,33 +285,80 @@ async def api_get_event(event_id: str) -> Event: closing_date = event.closing_date or event.event_end_date or event.event_start_date # Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date # may carry a time component since v1.3.0-aio.3 / our start-end-time - # feature). + # feature). Upstream v1.6.8 parses closing_date with a bare + # strptime("%Y-%m-%d") here; that raises ValueError on any event of ours + # whose closing date carries a time, which is most of them. try: closing_dt = datetime.fromisoformat(closing_date) except ValueError: closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d") if closing_dt.tzinfo is None: closing_dt = closing_dt.replace(tzinfo=timezone.utc) - is_window_open = datetime.now(timezone.utc) < closing_dt + + now = datetime.now(timezone.utc) + today = now.date() + active_waves = get_active_ticket_waves(event, today) + # `is_sales_closed` is upstream's name for `not is_window_open`; the + # comparison stays ours (instant-precise) rather than upstream's + # whole-day `today > closing_date.date()`. Upstream's form keeps sales + # open for the whole of the closing day, which for our datetime-bearing + # closing dates would extend every existing event's sales window. + is_sales_closed = now >= closing_dt is_min_tickets_met = ( event.sold >= event.extra.min_tickets if event.extra.conditional else True ) if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") - if event.extra.conditional and not is_min_tickets_met and not is_window_open: + if event.extra.conditional and not is_min_tickets_met and is_sales_closed: event.canceled = True await update_event(event) await refund_tickets(event_id) raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") - if not is_window_open: + if not active_waves: raise HTTPException( - status_code=HTTPStatus.GONE, detail="Ticket closing date has passed." + status_code=HTTPStatus.GONE, + detail=( + "Ticket closing date has passed." + if is_sales_closed + else "No ticket wave is currently open." + ), ) return event +def _validate_wave_capacity(data: CreateEvent, existing: Event | None = None) -> None: + """Every ticket wave must state a real capacity. + + "Capacity is always required, there is no unlimited" (#34, #62) was + settled when capacity was a single number on the event. Since v1.6.8 it + is per-wave and `event.amount_tickets` is a derived roll-up, so the rule + has to be enforced where the organiser actually sets it — otherwise it + only survives as a `min="1"` on one HTML input, which no API client is + bound by. + + A zero-capacity wave can never be active (`get_active_ticket_waves` + requires `amount_tickets > 0`), so it is the wave-level form of exactly + what #62 removed: an event that looks on sale but refuses every + purchase. + + Selling out is the one legitimate route to zero, so an edit only checks + waves that are NEW to the event; waves already stored keep whatever + sales decremented them to. `ensure_ticket_waves` is used rather than the + raw list so an event submitted with no waves is checked through the + primary wave it will be given. + """ + known = {wave.id for wave in (existing.extra.ticket_waves if existing else [])} + for wave in ensure_ticket_waves(data): + if wave.id in known or wave.amount_tickets >= 1: + continue + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Ticket wave '{wave.title}' needs a capacity of at least 1.", + ) + + @events_api_router.post("") async def api_event_create( data: CreateEvent, @@ -295,6 +372,8 @@ async def api_event_create( if not data.wallet: data.wallet = wallet.wallet.id + _validate_wave_capacity(data) + from lnbits.settings import settings ext_settings = await get_settings() @@ -337,6 +416,22 @@ async def api_event_update( if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + # Carry the stored waves over unless the request names the key. `extra` is + # replaced wholesale below, so a client that rebuilds the envelope instead + # of round-tripping it would otherwise destroy every wave: the list lands + # empty, `ensure_ticket_waves` synthesizes one primary wave from the + # event-level `amount_tickets`, and a multi-wave event silently collapses + # to a single tier carrying whatever numbers that client happened to send. + # Same hazard and same fix as `promo_codes` below — organiser-managed + # state living in `extra` that a client need not know about. Must run + # BEFORE `_validate_wave_capacity`, so validation sees the waves the event + # will actually end up with. An explicit `[]` still resets, as it does for + # promo codes. + if "ticket_waves" not in data.extra.__fields_set__: + data.extra.ticket_waves = event.extra.ticket_waves + + _validate_wave_capacity(data, event) + from lnbits.settings import settings ext_settings = await get_settings() @@ -353,6 +448,13 @@ async def api_event_update( if not data.closing_date: data.closing_date = data.event_end_date + # Promo codes are organizer-only and absent from public responses, so a + # client that round-trips a public record (or simply doesn't manage + # codes) would otherwise wipe them on every edit. Carry the stored list + # over unless the request names the key; an explicit `[]` still clears. + if "promo_codes" not in data.extra.__fields_set__: + data.extra.promo_codes = event.extra.promo_codes + # Explicit field list — never copy `status` from the request body. # Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an # owner editing a fiat-enabled event keeps the fiat config. @@ -511,6 +613,31 @@ async def api_tickets_by_user( return await get_tickets_by_user_id(user_id) +@tickets_api_router.get( + "/paginated", + summary="Get paginated list of tickets", + openapi_extra=generate_filter_params_openapi(TicketFilters), + response_model=Page[Ticket], +) +async def api_tickets_paginated( + all_wallets: bool = Query(False), + filters: Filters = Depends(tickets_filters), + key_info: WalletTypeInfo = Depends(require_admin_key), +) -> Page[Ticket]: + wallet_ids = [key_info.wallet.id] + + if all_wallets: + user = await get_user(key_info.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + if not filters.sortby: + filters.sortby = "time" + if not filters.direction: + filters.direction = "desc" + + return await get_tickets_paginated(wallet_ids, filters) + + @tickets_api_router.get("/{ticket_id}", response_model=PublicTicket) async def api_get_ticket(ticket_id: str) -> Ticket: ticket = await get_ticket(ticket_id) @@ -572,6 +699,36 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str: return data.frontend_url.rstrip("/") +def _resolve_ticket_wave(event: Event, ticket_wave_id: str | None) -> TicketWave: + """The wave a purchase or a price preview is priced against. + + Shared by the purchase and promo-validate endpoints so the two cannot + drift: whatever wave the preview quoted is the wave the invoice charges. + Selection is upstream v1.6.8's — an explicit id must be an OPEN wave, a + single open wave is implied, and an ambiguous choice is an error rather + than a silent pick. + """ + active_waves = get_active_ticket_waves(event) + if not active_waves: + raise HTTPException( + status_code=HTTPStatus.GONE, detail="No ticket wave is currently open." + ) + if ticket_wave_id: + wave = next((wave for wave in active_waves if wave.id == ticket_wave_id), None) + if not wave: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid ticket wave selected.", + ) + return wave + if len(active_waves) == 1: + return active_waves[0] + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Please select a ticket wave.", + ) + + async def _issue_free_tickets( *, event: Event, @@ -582,6 +739,7 @@ async def _issue_free_tickets( promo_code: str | None, nostr_identifier: str | None, frontend_root: str, + selected_wave: TicketWave, ) -> TicketPaymentRequest: """Issue `quantity` free tickets without minting an invoice. @@ -610,6 +768,11 @@ async def _issue_free_tickets( ticket_id=row_id, extra={ "applied_promo_code": promo_code, + # Free tickets consume wave stock exactly like paid ones — + # `set_ticket_paid` runs on this path too — so they must name + # their wave or the decrement lands on the primary wave. + "ticket_wave_id": selected_wave.id, + "ticket_wave_title": selected_wave.title, "nostr_identifier": nostr_identifier, "ticket_base_url": frontend_root, "sats_paid": 0, @@ -645,22 +808,23 @@ async def api_ticket_create( if event.canceled: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") quantity = data.quantity - if event.amount_tickets > 0: - if event.sold >= event.amount_tickets: - raise HTTPException( - status_code=HTTPStatus.GONE, detail="Event is sold out." - ) - remaining = event.amount_tickets - event.sold - if quantity > remaining: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Only {remaining} ticket(s) remaining for this event.", - ) + # `amount_tickets` IS the remaining count — `set_ticket_paid` decrements + # it on every sale, and upstream reads it the same way everywhere. Do + # not subtract `sold` from it: `sold` counts the same tickets the + # decrement already removed, so doing both takes each sale off twice + # (aiolabs/events#34). + if event.amount_tickets < 1: + raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") + if quantity > event.amount_tickets: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.", + ) name = data.name email = data.email user_id = data.user_id - promo_code = data.promo_code.upper() if data.promo_code else None + promo_code = normalize_code(data.promo_code) refund_address = data.refund_address nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None payment_method = (data.payment_method or "lightning").lower() @@ -669,6 +833,11 @@ async def api_ticket_create( status_code=HTTPStatus.BAD_REQUEST, detail="Unsupported payment method.", ) + # npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this + # with a hard "Only NIP-05 Nostr identifiers are supported." rejection — + # true for upstream, false here: `_send_nostr_ticket_notification` sends + # bare pubkeys via `send_nostr_dm`. That rejection merged in outside any + # conflict marker, so it would have silently dropped npub checkout. if nostr_identifier and "@" not in nostr_identifier: try: nostr_identifier = normalize_public_key(nostr_identifier) @@ -677,22 +846,50 @@ async def api_ticket_create( status_code=HTTPStatus.BAD_REQUEST, detail="Invalid Nostr identifier.", ) from exc - unit_price = event.price_per_ticket + + selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id) extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} frontend_root = _resolve_frontend_root(data, request) + # One invoice, N tickets; the promo (if any) prices the whole quantity + # through the same `basket_totals` the validate endpoint uses, so the + # preview a buyer saw is what gets charged. Unlike validate, a bad code + # is a hard error here — silently charging full price would be worse. if promo_code: - # check if promo_code exists in event.extra.promo_codes - if promo_code not in [pc.code for pc in event.extra.promo_codes]: + promo = find_promo(event, promo_code) + if not promo: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." ) - # get the promocode - promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) + if not promo.active: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Promo code is not active.", + ) + usage = await event_promo_usage(event.id) + uses_left = remaining_uses(promo, usage.get(promo.code, 0)) + if uses_left is not None and uses_left < quantity: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + "Promo code has been fully redeemed." + if uses_left == 0 + else f"Only {uses_left} use(s) left on this promo code." + ), + ) extra["promo_code"] = promo.code - unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100) - # Scale by quantity AFTER the promo applies. One invoice, N tickets. - price = unit_price * quantity + # Priced off `selected_wave`, not `event`: since v1.6.8 the price and + # currency live on the wave, and the event-level fields are a roll-up + # of the PRIMARY wave (`sync_event_ticket_waves`). Pricing off the + # event charges every buyer the first wave's price whichever wave they + # actually picked. Upstream prices one ticket; `basket_totals` keeps + # our quantity + promo arithmetic, so the "Apply" preview and the + # invoice still agree. + price = basket_totals(event, [promo.code], quantity, usage, selected_wave).total + else: + price = round_amount( + selected_wave.price_per_ticket * quantity, selected_wave.currency + ) # Free tickets (final charge 0 — a free event or a 100%-off promo). # Short-circuit before any invoice / fiat-provider logic: no Lightning @@ -708,6 +905,16 @@ async def api_ticket_create( promo_code=promo_code, nostr_identifier=nostr_identifier, frontend_root=frontend_root, + selected_wave=selected_wave, + ) + + # Fiat is a per-wave opt-in since v1.6.8. `effective_payment_methods` + # below reads `event.allow_fiat`, which is only the PRIMARY wave's, so + # this check is what actually protects a non-fiat wave. + if payment_method == "fiat" and not selected_wave.allow_fiat: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Fiat payments are not enabled for this ticket wave.", ) # Organizer-controlled rails (extra.payment_methods; legacy events fall @@ -719,23 +926,24 @@ async def api_ticket_create( detail="Payment method not enabled for this event.", ) - if _is_fiat_currency(event.currency): + if _is_fiat_currency(selected_wave.currency): extra["fiat"] = True - extra["currency"] = event.currency + extra["currency"] = selected_wave.currency extra["fiatAmount"] = price - extra["rate"] = await get_fiat_rate_satoshis(event.currency) + extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency) if payment_method != "fiat": - price = await fiat_amount_as_satoshis(price, event.currency) + price = await fiat_amount_as_satoshis(price, selected_wave.currency) - invoice_unit = event.currency + invoice_unit = selected_wave.currency fiat_amount = price fiat_provider = None + if payment_method == "fiat": - if _is_fiat_currency(event.currency): - invoice_unit = event.currency + if _is_fiat_currency(selected_wave.currency): + invoice_unit = selected_wave.currency else: - invoice_unit = event.fiat_currency + invoice_unit = selected_wave.fiat_currency fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit) extra["fiat"] = True extra["currency"] = invoice_unit @@ -784,6 +992,7 @@ async def api_ticket_create( "event_id": event.id, "quantity": str(quantity), "ticket_ids": ",".join(ticket_ids), + **({"promo_code": promo_code} if promo_code else {}), }, } @@ -810,6 +1019,12 @@ async def api_ticket_create( ticket_id=row_id, extra={ "applied_promo_code": promo_code, + # Which wave this ticket came from. `set_ticket_paid` debits + # the wave named here and falls back to waves[0] when it is + # absent, so omitting it would take every sale off the + # primary wave no matter what the buyer bought. + "ticket_wave_id": selected_wave.id, + "ticket_wave_title": selected_wave.title, "refund_address": refund_address, "nostr_identifier": nostr_identifier, "ticket_base_url": frontend_root, @@ -913,7 +1128,9 @@ async def api_ticket_delete( @tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) async def api_ticket_resend_email( - ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) + ticket_id: str, + request: Request, + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> TicketResendResult: ticket = await get_ticket(ticket_id) if not ticket: @@ -931,16 +1148,13 @@ async def api_ticket_resend_email( ) try: - return await resend_ticket_email_notification(ticket) + return await resend_ticket_email_notification( + ticket, str(request.base_url).rstrip("/") + ) except ValueError as exc: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) ) from exc - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to resend ticket email.", - ) from exc @tickets_api_router.put("/register/{ticket_id}") @@ -1042,6 +1256,7 @@ async def api_event_ticket_stats( "registered_at": ( t.reg_timestamp.isoformat() if t.reg_timestamp else None ), + "applied_promo_code": t.extra.applied_promo_code, } for t in paid_tickets ], @@ -1050,28 +1265,76 @@ async def api_event_ticket_stats( @qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse) async def api_ticket_qr(ticket_id: str): - """PNG of the ticket's scan payload (`ticket://`), branded with the - instance QR logo. Anonymous by design — it is what the ticket email - embeds — and the id is the same bearer token the ticket page exposes. - Port of upstream v1.6.8 without ticket-image compositing.""" + """PNG of the ticket's scan payload (`ticket://`). + + Two shapes behind one route. Before the v1.6.8 merge this endpoint + existed on both sides — ours was "upstream's, without ticket-image + compositing", theirs added the compositing but dropped the logo. They + are the same endpoint, so they are merged rather than registered twice + (FastAPI serves whichever route is declared first, so the second copy + would have been silently unreachable): + + - wave opted into `use_ticket_image`: upstream's composite — the QR + pasted onto the organiser's uploaded template, or the bundled + default. QR size and paste coordinates are upstream's and are tied + to each other; no logo, because the template carries the branding. + - otherwise: our standalone QR at 300px with the instance logo. + + Anonymous by design — it is what the ticket email links to — and the id + is the same bearer token the ticket page exposes. + """ ticket = await get_ticket(ticket_id) if not ticket: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." ) + event = await get_event(ticket.event) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) - logo = await load_qr_logo() - image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo) - return StreamingResponse( - BytesIO(image_png_bytes(image)), - media_type="image/png", - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0", - }, + headers = { + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + } + + 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: + logo = await load_qr_logo() + image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo) + return StreamingResponse( + BytesIO(image_png_bytes(image)), + media_type="image/png", + headers=headers, + ) + + background_bytes = None + if wave.ticket_image_id: + asset = await get_public_asset(wave.ticket_image_id) + if asset: + background_bytes = asset.data + + if background_bytes: + ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA") + else: + default_template = ( + Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg" + ) + ticket_image = Image.open(default_template).convert("RGBA") + + ticket_image.paste(make_qr_png(f"ticket://{ticket_id}", size=157), (122, 505)) + output = BytesIO() + ticket_image.save(output, format="PNG") + output.seek(0) + return StreamingResponse(output, media_type="image/png", headers=headers) + @qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse) async def api_ticket_card(ticket_id: str): @@ -1100,3 +1363,24 @@ async def api_ticket_card(ticket_id: str): "Cache-Control": "no-cache, no-store, must-revalidate", }, ) + + +@promo_api_router.post("/validate/{event_id}") +async def api_validate_promo_codes( + event_id: str, data: PromoValidateRequest +) -> BasketTotals: + """Price a purchase with the given codes without committing to it — + what the buyer sees before paying. Anonymous and advisory: a code that + is unknown / inactive / exhausted is simply absent from + `discounts_applied`; the purchase endpoint is where hard errors live. + Same URL as upstream v2 (`quantity` instead of v2's `items`).""" + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + usage = await event_promo_usage(event_id) if event.extra.promo_codes else {} + # Same resolver the purchase endpoint uses, so the quote and the charge + # are priced against the same wave. + wave = _resolve_ticket_wave(event, data.ticket_wave_id) + return basket_totals(event, data.codes, data.quantity, usage, wave)