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` - **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

@ -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

@ -526,7 +526,9 @@ window.PageEvents = {
.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 || []

View file

@ -729,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
@ -755,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="
@ -765,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
@ -783,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

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)