Compare commits

...

7 commits

Author SHA1 Message Date
93cc95fe18 docs: promo-code contract
Some checks failed
lint.yml / docs: promo-code contract (pull_request) Failing after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-13 18:43:48 +02:00
07519bec1b feat(admin): max uses column + used count in the promo editor
The Quasar editor gains a "Max uses" input per code (blank = unlimited,
normalised to null on save) and shows "Used n / max" from the derived
count. The public buy page's Clear button now also clears the promo field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-13 18:43:34 +02:00
8602bd71e3 feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public
Promo handling was inherited from upstream unchanged and had four gaps
the webapp was about to put in front of buyers:

- `active` was decorative: purchase never read it, so a deactivated code
  kept discounting. Now rejected with "Promo code is not active."
- No redemption cap (#32). `PromoCode.max_uses` (None/0 = unlimited) with
  `used_count` DERIVED from paid tickets carrying the code in
  `extra.applied_promo_code` — each ticket of a multi-ticket purchase
  consumes one use (upstream v2 counts one per basket; documented).
  Paid-only counting so an abandoned Stripe session can't lock out the
  last uses for the 24 h unpaid-row lifetime; bounded overshoot under
  concurrency accepted.
- Every code was readable by anyone: `PublicEvent.extra` was the full
  `EventExtra` and `/events/public` returned the untrimmed `Event`
  (wallet id included). `EventExtraBase` / `PublicEventExtra` project
  them out; `/public` now goes through `PublicEvent`. Organizer and admin
  listings keep the full model, now hydrated with `used_count`.
- No preview: `POST /events/api/v1/promo/validate/{event_id}` (same URL as
  upstream v2; `quantity` replaces v2's `items` since this fork has no
  ticket types) returns v2-shaped `BasketTotals` + `currency`. Advisory:
  bad codes are simply absent from `discounts_applied`; purchase still
  hard-fails them with distinct details.

All pricing (validate, invoice, Stripe amount) goes through one pure
`basket_totals` with a single rounding rule (whole sats / 2 dp fiat), so
the preview equals the charge. Stripe metadata carries `promo_code`; the
organizer stats rows carry `applied_promo_code`.

`api_event_update` keeps stored codes when the request omits
`extra.promo_codes` (explicit `[]` still clears): now that public
records don't carry them, a client round-tripping one would otherwise
wipe the organizer's codes on every edit.

Closes #32

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-13 18:43:34 +02:00
4c3b1bca31 chore(release): v1.6.1-aio.14
Some checks failed
lint.yml / chore(release): v1.6.1-aio.14 (push) Failing after 0s
Since v1.6.1-aio.13: "Email sent" column in the admin tickets table (#48).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-13 18:42:39 +02:00
c230e61031 Merge pull request 'feat(admin): "Email sent" column in the tickets table' (#48) from feat/ticket-email-sent-column into main
Some checks failed
lint.yml / Merge pull request 'feat(admin): "Email sent" column in the tickets table' (#48) from feat/ticket-email-sent-column into main (push) Failing after 0s
Reviewed-on: #48
2026-09-13 16:41:34 +00:00
87c74ce13f feat(admin): "Email sent" column in the tickets table
Some checks failed
lint.yml / feat(admin): "Email sent" column in the tickets table (pull_request) Failing after 0s
Organizers had no way to see whether a ticket email went out short of
reading the Postfix journal. Derive a column from the flag the mailer
already sets on each ticket (`extra.email_notification_sent`):
"✓ sent" / "not sent" (paid, no delivery yet) / "unpaid" / "no email".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-13 18:39:52 +02:00
f835766935 feat(admin): event form parity with the webapp (1.6.1-aio.13)
Some checks failed
lint.yml / feat(admin): event form parity with the webapp (1.6.1-aio.13) (pull_request) Failing after 0s
lint.yml / feat(admin): event form parity with the webapp (1.6.1-aio.13) (push) Failing after 0s
The LNbits admin form lagged the webapp's CreateEventDialog:

- Payment methods never rendered. c2d9a96 wired the template to
  `paymentMethodOptions` / `acceptsFiat` but never defined them, so the
  q-option-group got `options=undefined` and the fiat-currency select
  was gated on `undefined`. Rails are now two q-checkboxes; Card is
  disabled with an explanatory tooltip when `g.user.fiat_providers` is
  empty (same rule as the webapp) and names the providers otherwise.
- Location (NIP-52 `location` tag) and Categories (NIP-52 `t` tags,
  same 25-item list as the webapp's category.ts) were missing from the
  form even though the model, CRUD and publisher already carry them.
- Datetimes are stamped with the browser's UTC offset on submit, as the
  webapp does; `_to_unix` treats naive values as UTC, so 18:00 CEST
  entered here went out on Nostr as 18:00 UTC. Table columns render
  "YYYY-MM-DD HH:MM" instead of the raw ISO string.
- Validation: title + start date required, end >= start on the folded
  date+time, fiat currency required when a sat-priced event accepts
  card. Create is enabled once wallet + title + start are set; info,
  closing date, tickets and price were all effectively required before
  because the disable check compared undefined fields to null.
- Labels follow the payment-rails vocabulary: "Unit" -> "Price
  currency", "Fiat checkout currency" -> "Fiat currency"; ticket
  closing date and end date explain their defaults.
- A fiat-priced event mirrors `fiat_currency = currency` on save so the
  payload and the `tickets_fiat_currency` tag stay coherent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b1bDExMX7W3a47wcgFUjb
2026-09-10 13:43:53 +02:00
13 changed files with 905 additions and 95 deletions

View file

@ -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` - **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 (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. 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** - **Email.** Multipart text + HTML (links, no images) with the **ticket card**
attached — a self-describing PNG (site, event, when, where, QR with the attached — a self-describing PNG (site, event, when, where, QR with the
instance logo, name on ticket, ticket id) also served at instance logo, name on ticket, ticket id) also served at

View file

@ -6,13 +6,19 @@ from loguru import logger
from .crud import db from .crud import db
from .tasks import wait_for_paid_invoices from .tasks import wait_for_paid_invoices
from .views import events_generic_router 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: APIRouter = APIRouter(prefix="/events", tags=["Events"])
events_ext.include_router(events_generic_router) events_ext.include_router(events_generic_router)
events_ext.include_router(events_api_router) events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router) events_ext.include_router(tickets_api_router)
events_ext.include_router(qr_api_router) events_ext.include_router(qr_api_router)
events_ext.include_router(promo_api_router)
events_static_files = [ events_static_files = [
{ {

View file

@ -1,6 +1,6 @@
{ {
"id": "events", "id": "events",
"version": "1.6.1-aio.10", "version": "1.6.1-aio.14",
"name": "Events", "name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events", "repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",

View file

@ -4,16 +4,17 @@ 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 `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. in an upstream-compatible form; strike it when the PR merges upstream.
| Feature | Where | Upstream target | Readiness | | 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 | | `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 | | 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.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 | | `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 | | `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 | | 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 | | 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 | | 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 | | 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 | | `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 `<img>` (`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) | | Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`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 |

View file

@ -11,20 +11,41 @@ class PromoCode(BaseModel):
code: str code: str
discount_percent: float = 0.0 discount_percent: float = 0.0
active: bool = True 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") @validator("code")
def uppercase_code(cls, v): 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") @validator("discount_percent")
def validate_discount_percent(cls, v): def validate_discount_percent(cls, v):
assert 0 <= v <= 100, "Discount must be between 0 and 100." assert 0 <= v <= 100, "Discount must be between 0 and 100."
return v 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 conditional: bool = False
min_tickets: int = 1 min_tickets: int = 1
email_notifications: bool = False email_notifications: bool = False
@ -53,6 +74,13 @@ class EventExtra(BaseModel):
return seen return seen
class EventExtra(EventExtraBase):
promo_codes: list[PromoCode] = Field(default_factory=list)
PublicEventExtra = EventExtraBase
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
wallet: str | None = None # filled from caller's wallet if absent wallet: str | None = None # filled from caller's wallet if absent
name: str # title (required) name: str # title (required)
@ -120,7 +148,9 @@ class PublicEvent(BaseModel):
banner: str | None banner: str | None
location: str | None = None location: str | None = None
categories: list[str] = Field(default_factory=list) 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 status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
@validator("categories", pre=True) @validator("categories", pre=True)
@ -146,6 +176,29 @@ def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> lis
return methods 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): class EventsSettings(BaseModel):
"""Extension-level settings for the events extension.""" """Extension-level settings for the events extension."""

108
promo.py Normal file
View file

@ -0,0 +1,108 @@
"""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

View file

@ -28,6 +28,7 @@ from .crud import (
) )
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
from .nostr_hooks import publish_or_delete_nostr_event from .nostr_hooks import publish_or_delete_nostr_event
from .promo import promo_usage
from .qr import ( from .qr import (
format_event_when, format_event_when,
image_png_bytes, image_png_bytes,
@ -82,6 +83,22 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
return 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: def send_ticket_notification_in_background(ticket: Ticket) -> None:
create_task(_send_ticket_notification(ticket)) create_task(_send_ticket_notification(ticket))

View file

@ -94,6 +94,7 @@ window.PageEventsDisplay = {
this.formDialog.data.email = '' this.formDialog.data.email = ''
this.formDialog.data.refund = '' this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = '' this.formDialog.data.nostr_identifier = ''
this.formDialog.data.promo_code = ''
this.formDialog.data.payment_method = this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning' this.paymentMethods[0] || 'lightning'
}, },

View file

@ -67,13 +67,15 @@ window.PageEvents = {
name: 'event_start_date', name: 'event_start_date',
align: 'left', align: 'left',
label: 'Start date', label: 'Start date',
field: 'event_start_date' field: 'event_start_date',
format: val => this.formatEventDate(val)
}, },
{ {
name: 'event_end_date', name: 'event_end_date',
align: 'left', align: 'left',
label: 'End date', label: 'End date',
field: 'event_end_date' field: 'event_end_date',
format: val => this.formatEventDate(val)
}, },
{ {
name: 'closing_date', name: 'closing_date',
@ -131,6 +133,19 @@ window.PageEvents = {
{name: 'event', align: 'left', label: 'Event', field: 'event'}, {name: 'event', align: 'left', label: 'Event', field: 'event'},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'}, {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', name: 'registered',
align: 'left', align: 'left',
@ -149,12 +164,58 @@ window.PageEvents = {
rowsPerPage: 10 rowsPerPage: 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: { formDialog: {
show: false, show: false,
data: { data: {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
extra: { extra: {
payment_methods: ['lightning'], payment_methods: ['lightning'],
promo_codes: [], promo_codes: [],
@ -165,6 +226,27 @@ window.PageEvents = {
} }
} }
}, },
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: { methods: {
isFiatCurrency(currency) { isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase()) return !['sat', 'sats'].includes((currency || '').toLowerCase())
@ -382,36 +464,71 @@ window.PageEvents = {
}, },
splitDateTime(value) { splitDateTime(value) {
// Inverse of foldDateTime: split a stored string back into the // 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: ''} if (!value) return {day: '', time: ''}
const [day, time = ''] = value.split('T') const [day, time = ''] = value.split('T')
// Time inputs only accept HH:MM, drop any seconds we stored. // Time inputs only accept HH:MM, drop any seconds we stored.
return {day, time: time.slice(0, 5)} 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() { sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = {...this.formDialog.data} const data = {...this.formDialog.data}
data.event_start_date = this.foldDateTime( data.event_start_date = this.withLocalTzOffset(
data.event_start_day, this.foldDateTime(data.event_start_day, data.event_start_time)
data.event_start_time
) )
data.event_end_date = this.foldDateTime( data.event_end_date = this.withLocalTzOffset(
data.event_end_day, this.foldDateTime(data.event_end_day, data.event_end_time)
data.event_end_time
) )
delete data.event_start_day delete data.event_start_day
delete data.event_start_time delete data.event_start_time
delete data.event_end_day delete data.event_end_day
delete data.event_end_time 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
if (data.extra?.promo_codes) { if (data.extra?.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.code?.trim() !== '') .filter(code => code.code?.trim() !== '')
.map(code => ({ .map(code => ({
...code, ...code,
code: code.code.trim().toUpperCase() code: code.code.trim().toUpperCase(),
// blank / 0 = unlimited; used_count is derived server-side
max_uses: code.max_uses ? Number(code.max_uses) : null
})) }))
} }
const methods = data.extra?.payment_methods || [] const methods = data.extra?.payment_methods || []
@ -425,10 +542,12 @@ window.PageEvents = {
// allow_fiat stays the fiat-currency carrier the backend and the // allow_fiat stays the fiat-currency carrier the backend and the
// NIP-52 tags read; keep it in lockstep with the checkbox list. // NIP-52 tags read; keep it in lockstep with the checkbox list.
data.allow_fiat = methods.includes('fiat') data.allow_fiat = methods.includes('fiat')
if (!this.isFiatCurrency(data.currency)) { if (this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) { // A fiat-priced event settles in its price currency; mirror it so
data.fiat_currency = 'GBP' // the payload (and the tickets_fiat_currency tag) stay coherent.
} data.fiat_currency = data.currency
} else if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
} }
if (data.id) { if (data.id) {
@ -453,6 +572,8 @@ window.PageEvents = {
? data.extra.payment_methods ? data.extra.payment_methods
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])] : ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
}, },
location: data.location || '',
categories: [...(data.categories || [])],
event_start_day: start.day, event_start_day: start.day,
event_start_time: start.time, event_start_time: start.time,
event_end_day: end.day, event_end_day: end.day,
@ -463,6 +584,8 @@ window.PageEvents = {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
event_start_day: '', event_start_day: '',
event_start_time: '', event_start_time: '',
event_end_day: '', event_end_day: '',
@ -487,6 +610,8 @@ window.PageEvents = {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
extra: { extra: {
payment_methods: ['lightning'], payment_methods: ['lightning'],
email_notifications: false, email_notifications: false,

View file

@ -479,8 +479,10 @@
filled filled
dense dense
v-model.trim="formDialog.data.name" v-model.trim="formDialog.data.name"
type="name" type="text"
label="Title of event " label="Title of event *"
lazy-rules
:rules="[val => !!val || 'Title is required']"
></q-input> ></q-input>
</div> </div>
<div class="col q-pl-sm"> <div class="col q-pl-sm">
@ -504,6 +506,30 @@
label="Info about the event" label="Info about the event"
hint="Markdown supported" hint="Markdown supported"
></q-input> ></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.location"
type="text"
label="Location"
hint="Venue or address, e.g. Salle des fêtes, Foix. Published as the NIP-52 location tag."
>
<template v-slot:prepend>
<q-icon name="place"></q-icon>
</template>
</q-input>
<q-select
filled
dense
multiple
use-chips
emit-value
map-options
v-model="formDialog.data.categories"
:options="categoryOptions"
label="Categories"
hint="Published as NIP-52 hashtags so clients can filter the feed."
></q-select>
<q-input <q-input
filled filled
dense dense
@ -512,25 +538,16 @@
label="Image URL" label="Image URL"
hint="Optional banner image to display on the event page" hint="Optional banner image to display on the event page"
></q-input> ></q-input>
<div class="row q-mt-lg">
<div class="col-4">Ticket closing date</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col-4">Event begins</div> <div class="col-4">Event begins *</div>
<div class="col-5"> <div class="col-5">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_start_day" v-model.trim="formDialog.data.event_start_day"
type="date" type="date"
lazy-rules
:rules="[val => !!val || 'Start date is required']"
></q-input> ></q-input>
</div> </div>
<div class="col-3"> <div class="col-3">
@ -552,6 +569,10 @@
dense dense
v-model.trim="formDialog.data.event_end_day" v-model.trim="formDialog.data.event_end_day"
type="date" type="date"
hint="Defaults to the start date"
lazy-rules
reactive-rules
:rules="[validateEndDate]"
></q-input> ></q-input>
</div> </div>
<div class="col-3"> <div class="col-3">
@ -564,6 +585,18 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row q-col-gutter-sm">
<div class="col-4">Ticket sales close</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
hint="Optional. Defaults to the event end date."
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
<q-select <q-select
@ -571,7 +604,7 @@
dense dense
v-model="formDialog.data.currency" v-model="formDialog.data.currency"
type="text" type="text"
label="Unit" label="Price currency"
:options="currencies" :options="currencies"
></q-select> ></q-select>
</div> </div>
@ -581,7 +614,9 @@
dense dense
v-model.number="formDialog.data.amount_tickets" v-model.number="formDialog.data.amount_tickets"
type="number" type="number"
label="Amount of tickets " min="0"
label="Amount of tickets"
hint="0 = unlimited"
></q-input> ></q-input>
</div> </div>
<div class="col"> <div class="col">
@ -600,34 +635,57 @@
</div> </div>
</div> </div>
<div class="q-mt-sm"> <div class="q-mt-sm">
<div class="text-caption text-grey-7">Payment methods *</div> <div class="text-subtitle2">Payment methods *</div>
<q-option-group
v-model="formDialog.data.extra.payment_methods"
type="checkbox"
inline
:options="paymentMethodOptions"
></q-option-group>
<div class="text-caption text-grey-7"> <div class="text-caption text-grey-7">
Card / fiat checkout goes through the fiat provider configured on Pick the rails buyers can pay with. Untick Lightning for a
this LNbits instance. Untick Lightning for a card-only event. card-only sale.
</div>
<div class="row q-col-gutter-md q-mt-xs">
<div
v-for="opt in paymentMethodOptions"
:key="opt.value"
class="col-12 col-sm-6"
>
<!-- The span carries the tooltip: a disabled checkbox
swallows pointer events, the wrapper still hovers. -->
<span class="inline-block full-width">
<q-checkbox
v-model="formDialog.data.extra.payment_methods"
:val="opt.value"
:label="opt.label"
:disable="opt.value === 'fiat' && !hasFiatProvider"
></q-checkbox>
<div class="text-caption text-grey-7 q-pl-lg">
<span v-text="opt.hint"></span>
<span
v-if="opt.value === 'fiat' && hasFiatProvider"
v-text="' (' + fiatProviderNames + ')'"
></span>
</div>
<q-tooltip
v-if="opt.value === 'fiat' && !hasFiatProvider"
max-width="280px"
>
Your LNbits user has no fiat provider configured. Ask the
instance admin to enable Stripe, PayPal or Square (Admin →
Fiat providers) to accept card payments.
</q-tooltip>
</span>
</div>
</div> </div>
</div> </div>
<q-select <q-select
v-if=" v-if="acceptsFiat && isSatPrice"
acceptsFiat &&
['sat', 'sats'].includes(
(formDialog.data.currency || '').toLowerCase()
)
"
filled filled
dense dense
v-model="formDialog.data.fiat_currency" v-model="formDialog.data.fiat_currency"
label="Fiat checkout currency" label="Fiat currency"
:options=" hint="Currency card buyers are charged in."
currencies.filter( :options="fiatCurrencyOptions"
c => !['sat', 'sats'].includes((c || '').toLowerCase()) lazy-rules
) :rules="[
" val => !!val || 'Pick a fiat currency for buyers paying by card'
]"
></q-select> ></q-select>
<q-expansion-item <q-expansion-item
group="advanced" group="advanced"
@ -671,12 +729,22 @@
class="row q-col-gutter-sm q-mt-md" class="row q-col-gutter-sm q-mt-md"
> >
<q-input <q-input
class="col-8" class="col-6"
filled filled
dense dense
v-model.trim="formDialog.data.extra.promo_codes[index].code" v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text" type="text"
label="Promo Code" label="Promo Code"
:hint="
formDialog.data.extra.promo_codes[index].used_count
? 'Used ' +
formDialog.data.extra.promo_codes[index].used_count +
(formDialog.data.extra.promo_codes[index].max_uses
? ' / ' +
formDialog.data.extra.promo_codes[index].max_uses
: '')
: ''
"
> >
<template v-slot:before> <template v-slot:before>
<q-checkbox <q-checkbox
@ -697,7 +765,7 @@
</template> </template>
</q-input> </q-input>
<q-input <q-input
class="col-4" class="col-3"
filled filled
dense dense
v-model.number=" v-model.number="
@ -707,6 +775,19 @@
label="Discount (%)" label="Discount (%)"
min="0" min="0"
max="100" max="100"
></q-input>
<q-input
class="col-3"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].max_uses
"
type="number"
label="Max uses"
placeholder="∞"
min="1"
hint="Blank = unlimited"
> >
<template v-slot:after> <template v-slot:after>
<q-btn <q-btn
@ -725,7 +806,8 @@
formDialog.data.extra.promo_codes.push({ formDialog.data.extra.promo_codes.push({
code: '', code: '',
discount_percent: 0, discount_percent: 0,
active: true active: true,
max_uses: null
}) })
" "
>Add Promo Code</q-btn >Add Promo Code</q-btn
@ -779,14 +861,9 @@
unelevated unelevated
color="primary" color="primary"
:disable=" :disable="
formDialog.data.wallet == null || !formDialog.data.wallet ||
formDialog.data.name == null || !formDialog.data.name ||
formDialog.data.info == null || !formDialog.data.event_start_day
formDialog.data.closing_date == null ||
formDialog.data.event_start_day == null ||
formDialog.data.event_end_day == null ||
formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null
" "
type="submit" type="submit"
>Create Event</q-btn >Create Event</q-btn

175
tests/test_promo.py Normal file
View file

@ -0,0 +1,175 @@
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from ..models import Event, EventExtra, PromoCode, PublicEvent, Ticket
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 _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, {})
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, {})
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, {}).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)
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}).total == 500
assert basket_totals(event, ["INF"], 10, {"INF": 999}).total == 9000
def test_full_discount_prices_to_zero():
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
assert basket_totals(event, ["FREE"], 2, {}).total == 0

178
tests/test_promo_api.py Normal file
View file

@ -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 == []

View file

@ -59,10 +59,12 @@ from .crud import (
update_ticket, update_ticket,
) )
from .models import ( from .models import (
BasketTotals,
CreateEvent, CreateEvent,
CreateTicket, CreateTicket,
Event, Event,
EventsSettings, EventsSettings,
PromoValidateRequest,
PublicEvent, PublicEvent,
PublicTicket, PublicTicket,
Ticket, Ticket,
@ -71,6 +73,13 @@ from .models import (
effective_payment_methods, effective_payment_methods,
) )
from .nostr_hooks import publish_or_delete_nostr_event 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 ( from .qr import (
image_png_bytes, image_png_bytes,
load_qr_logo, load_qr_logo,
@ -79,6 +88,8 @@ from .qr import (
ticket_card_filename, ticket_card_filename,
) )
from .services import ( from .services import (
event_promo_usage,
hydrate_promo_usage,
refund_tickets, refund_tickets,
resend_ticket_email_notification, resend_ticket_email_notification,
send_ticket_notification_in_background, send_ticket_notification_in_background,
@ -89,6 +100,7 @@ from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events") events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets") tickets_api_router = APIRouter(prefix="/api/v1/tickets")
qr_api_router = APIRouter(prefix="/api/v1") qr_api_router = APIRouter(prefix="/api/v1")
promo_api_router = APIRouter(prefix="/api/v1/promo")
def _is_fiat_currency(currency: str | None) -> bool: def _is_fiat_currency(currency: str | None) -> bool:
@ -108,12 +120,17 @@ async def api_events(
if all_wallets: if all_wallets:
user = await get_user(wallet.wallet.user) user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else [] 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]: 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() return await get_public_events()
@ -131,6 +148,7 @@ async def api_events_all(
events = await get_all_events() events = await get_all_events()
enriched: list[dict] = [] enriched: list[dict] = []
for event in events: for event in events:
await hydrate_promo_usage(event)
wallet = await get_wallet(event.wallet) wallet = await get_wallet(event.wallet)
row = event.dict() row = event.dict()
row["wallet_user_id"] = wallet.user if wallet else None row["wallet_user_id"] = wallet.user if wallet else None
@ -353,6 +371,13 @@ async def api_event_update(
if not data.closing_date: if not data.closing_date:
data.closing_date = data.event_end_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. # Explicit field list — never copy `status` from the request body.
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an # Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
# owner editing a fiat-enabled event keeps the fiat config. # owner editing a fiat-enabled event keeps the fiat config.
@ -660,7 +685,7 @@ async def api_ticket_create(
name = data.name name = data.name
email = data.email email = data.email
user_id = data.user_id 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 refund_address = data.refund_address
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
payment_method = (data.payment_method or "lightning").lower() payment_method = (data.payment_method or "lightning").lower()
@ -677,22 +702,39 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.", detail="Invalid Nostr identifier.",
) from exc ) from exc
unit_price = event.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
frontend_root = _resolve_frontend_root(data, request) 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: if promo_code:
# check if promo_code exists in event.extra.promo_codes promo = find_promo(event, promo_code)
if promo_code not in [pc.code for pc in event.extra.promo_codes]: if not promo:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
) )
# get the promocode if not promo.active:
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) 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 extra["promo_code"] = promo.code
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100) price = basket_totals(event, [promo.code], quantity, usage).total
# Scale by quantity AFTER the promo applies. One invoice, N tickets. else:
price = unit_price * quantity price = round_amount(event.price_per_ticket * quantity, event.currency)
# Free tickets (final charge 0 — a free event or a 100%-off promo). # Free tickets (final charge 0 — a free event or a 100%-off promo).
# Short-circuit before any invoice / fiat-provider logic: no Lightning # Short-circuit before any invoice / fiat-provider logic: no Lightning
@ -784,6 +826,7 @@ async def api_ticket_create(
"event_id": event.id, "event_id": event.id,
"quantity": str(quantity), "quantity": str(quantity),
"ticket_ids": ",".join(ticket_ids), "ticket_ids": ",".join(ticket_ids),
**({"promo_code": promo_code} if promo_code else {}),
}, },
} }
@ -1042,6 +1085,7 @@ async def api_event_ticket_stats(
"registered_at": ( "registered_at": (
t.reg_timestamp.isoformat() if t.reg_timestamp else None t.reg_timestamp.isoformat() if t.reg_timestamp else None
), ),
"applied_promo_code": t.extra.applied_promo_code,
} }
for t in paid_tickets for t in paid_tickets
], ],
@ -1100,3 +1144,21 @@ async def api_ticket_card(ticket_id: str):
"Cache-Control": "no-cache, no-store, must-revalidate", "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 {}
return basket_totals(event, data.codes, data.quantity, usage)