diff --git a/README.md b/README.md index 4ac72c5..cd02134 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,6 @@ 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 a0b330d..394bc6d 100644 --- a/__init__.py +++ b/__init__.py @@ -6,19 +6,13 @@ from loguru import logger from .crud import db from .tasks import wait_for_paid_invoices from .views import events_generic_router -from .views_api import ( - events_api_router, - promo_api_router, - qr_api_router, - tickets_api_router, -) +from .views_api import events_api_router, qr_api_router, tickets_api_router events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext.include_router(events_generic_router) events_ext.include_router(events_api_router) events_ext.include_router(tickets_api_router) events_ext.include_router(qr_api_router) -events_ext.include_router(promo_api_router) events_static_files = [ { diff --git a/config.json b/config.json index ecf0b13..68e908a 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.12", + "version": "1.6.1-aio.10", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md index 56e2ee7..93e6c2f 100644 --- a/docs/upstream-candidates.md +++ b/docs/upstream-candidates.md @@ -4,17 +4,16 @@ Running log of fork features that are shaped so they could be offered to `lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here in an upstream-compatible form; strike it when the PR merges upstream. -| Feature | Where | Upstream target | Readiness | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | -| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | -| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | -| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | -| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | -| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | -| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | -| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | -| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | -| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | -| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | -| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 | +| Feature | Where | Upstream target | Readiness | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | +| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | +| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | +| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | +| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | +| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | +| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | +| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | +| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | +| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | +| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | diff --git a/models.py b/models.py index b349794..e36c60a 100644 --- a/models.py +++ b/models.py @@ -11,41 +11,20 @@ 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 - # stored form: stripped + upper-case, never empty + # make the promo code uppercase @validator("code") def uppercase_code(cls, v): - v = (v or "").strip().upper() - if not v: - raise ValueError("Promo code cannot be empty.") - return v + return v.upper() @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 EventExtraBase(BaseModel): - """Everything in `extra` that is safe to show anyone. `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 @@ -74,13 +53,6 @@ class EventExtraBase(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) @@ -148,9 +120,7 @@ class PublicEvent(BaseModel): banner: str | None location: str | None = None categories: list[str] = Field(default_factory=list) - # PublicEventExtra: promo codes are organizer-only (a buyer who can read - # every code can mint every discount). - extra: PublicEventExtra = Field(default_factory=PublicEventExtra) + extra: EventExtra = Field(default_factory=EventExtra) status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner @validator("categories", pre=True) @@ -176,29 +146,6 @@ def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> lis return methods -class PromoValidateRequest(BaseModel): - """Upstream v2 shape. v2 sends `items` (ticket types); this fork has one - price per event, so a plain `quantity` replaces it.""" - - codes: list[str] = Field(default_factory=list) - quantity: int = Field(default=1, ge=1, le=10) - - -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.""" diff --git a/promo.py b/promo.py deleted file mode 100644 index 4bfa8ff..0000000 --- a/promo.py +++ /dev/null @@ -1,108 +0,0 @@ -"""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 - -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], -) -> BasketTotals: - """Price `quantity` tickets with the first applicable code in `codes`. - - 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. - """ - currency = event.currency or "sat" - subtotal = round_amount(event.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 f69b76e..9e6d1ce 100644 --- a/services.py +++ b/services.py @@ -28,7 +28,6 @@ from .crud import ( ) from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult from .nostr_hooks import publish_or_delete_nostr_event -from .promo import promo_usage from .qr import ( format_event_when, image_png_bytes, @@ -83,22 +82,6 @@ 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)) diff --git a/static/js/display.js b/static/js/display.js index f687512..ad7e358 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -94,7 +94,6 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' - this.formDialog.data.promo_code = '' this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning' }, diff --git a/static/js/index.js b/static/js/index.js index 0e8b7fa..646b37b 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -411,9 +411,7 @@ window.PageEvents = { .filter(code => code.code?.trim() !== '') .map(code => ({ ...code, - code: code.code.trim().toUpperCase(), - // blank / 0 = unlimited; used_count is derived server-side - max_uses: code.max_uses ? Number(code.max_uses) : null + code: code.code.trim().toUpperCase() })) } const methods = data.extra?.payment_methods || [] diff --git a/static/js/index.vue b/static/js/index.vue index a5b39e3..502d9ad 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -671,22 +671,12 @@ class="row q-col-gutter-sm q-mt-md" >