Merge pull request 'feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public (v1.6.1-aio.12)' (#45) from feat/promo-codes into main
Some checks failed
lint.yml / Merge pull request 'feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public (v1.6.1-aio.12)' (#45) from feat/promo-codes into main (push) Failing after 0s

Reviewed-on: #45
This commit is contained in:
padreug 2026-09-13 16:54:51 +00:00
commit 50c40439b7
12 changed files with 670 additions and 36 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`
(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

View file

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

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
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 `<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) |
| 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 `<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
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 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
@ -53,6 +74,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)
@ -120,7 +148,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)
@ -146,6 +176,29 @@ 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."""

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 .nostr_hooks import publish_or_delete_nostr_event
from .promo import promo_usage
from .qr import (
format_event_when,
image_png_bytes,
@ -82,6 +83,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))

View file

@ -94,6 +94,7 @@ 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'
},

View file

@ -526,7 +526,9 @@ window.PageEvents = {
.filter(code => code.code?.trim() !== '')
.map(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 || []

View file

@ -729,12 +729,22 @@
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
class="col-6"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
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>
<q-checkbox
@ -755,7 +765,7 @@
</template>
</q-input>
<q-input
class="col-4"
class="col-3"
filled
dense
v-model.number="
@ -765,6 +775,19 @@
label="Discount (%)"
min="0"
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>
<q-btn
@ -783,7 +806,8 @@
formDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true
active: true,
max_uses: null
})
"
>Add Promo Code</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,
)
from .models import (
BasketTotals,
CreateEvent,
CreateTicket,
Event,
EventsSettings,
PromoValidateRequest,
PublicEvent,
PublicTicket,
Ticket,
@ -71,6 +73,13 @@ from .models import (
effective_payment_methods,
)
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,6 +88,8 @@ 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,
@ -89,6 +100,7 @@ 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")
def _is_fiat_currency(currency: str | None) -> bool:
@ -108,12 +120,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 +148,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
@ -353,6 +371,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.
@ -660,7 +685,7 @@ async def api_ticket_create(
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()
@ -677,22 +702,39 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.",
) from exc
unit_price = event.price_per_ticket
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
price = basket_totals(event, [promo.code], quantity, usage).total
else:
price = round_amount(event.price_per_ticket * quantity, event.currency)
# Free tickets (final charge 0 — a free event or a 100%-off promo).
# Short-circuit before any invoice / fiat-provider logic: no Lightning
@ -784,6 +826,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 {}),
},
}
@ -1042,6 +1085,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
],
@ -1100,3 +1144,21 @@ 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 {}
return basket_totals(event, data.codes, data.quantity, usage)