diff --git a/README.md b/README.md
index cd02134..4ac72c5 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,13 @@ Events includes a shareable ticket scanner, which can be used to register attend
- **Stripe session.** The buyer's email is passed as `customer_email`
(prefilled and locked on the hosted page); the line item is named after the
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
+- **Promo codes.** `extra.promo_codes` (`code`, `discount_percent`, `active`,
+ `max_uses`; `used_count` is derived from paid tickets) are organizer-only: they are
+ never part of public responses. Buyers preview a code with
+ `POST /events/api/v1/promo/validate/{event_id}` (`{codes, quantity}` → v2-shaped
+ `BasketTotals` + `currency`); purchase enforces `active` and `max_uses` (each ticket
+ of a multi-ticket purchase consumes one use) and rejects bad codes with a distinct
+ `detail`. Updates that omit `extra.promo_codes` keep the stored list.
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
attached — a self-describing PNG (site, event, when, where, QR with the
instance logo, name on ticket, ticket id) also served at
diff --git a/__init__.py b/__init__.py
index 394bc6d..a0b330d 100644
--- a/__init__.py
+++ b/__init__.py
@@ -6,13 +6,19 @@ from loguru import logger
from .crud import db
from .tasks import wait_for_paid_invoices
from .views import events_generic_router
-from .views_api import events_api_router, qr_api_router, tickets_api_router
+from .views_api import (
+ events_api_router,
+ promo_api_router,
+ qr_api_router,
+ tickets_api_router,
+)
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
events_ext.include_router(events_generic_router)
events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router)
events_ext.include_router(qr_api_router)
+events_ext.include_router(promo_api_router)
events_static_files = [
{
diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md
index 93e6c2f..56e2ee7 100644
--- a/docs/upstream-candidates.md
+++ b/docs/upstream-candidates.md
@@ -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 `
` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
+| Feature | Where | Upstream target | Readiness |
+| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
+| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
+| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
+| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
+| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
+| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
+| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
+| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
+| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
+| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
+| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `
` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
+| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 |
diff --git a/models.py b/models.py
index e36c60a..b349794 100644
--- a/models.py
+++ b/models.py
@@ -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."""
diff --git a/promo.py b/promo.py
new file mode 100644
index 0000000..4bfa8ff
--- /dev/null
+++ b/promo.py
@@ -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
diff --git a/services.py b/services.py
index 9e6d1ce..f69b76e 100644
--- a/services.py
+++ b/services.py
@@ -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))
diff --git a/static/js/display.js b/static/js/display.js
index ad7e358..f687512 100644
--- a/static/js/display.js
+++ b/static/js/display.js
@@ -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'
},
diff --git a/static/js/index.js b/static/js/index.js
index 3de0898..962203a 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -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 || []
diff --git a/static/js/index.vue b/static/js/index.vue
index 6e7ec28..d53f4c8 100644
--- a/static/js/index.vue
+++ b/static/js/index.vue
@@ -729,12 +729,22 @@
class="row q-col-gutter-sm q-mt-md"
>
+
Add Promo Code 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
diff --git a/tests/test_promo_api.py b/tests/test_promo_api.py
new file mode 100644
index 0000000..65d1c62
--- /dev/null
+++ b/tests/test_promo_api.py
@@ -0,0 +1,178 @@
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi import HTTPException
+
+from .. import views_api
+from ..models import CreateTicket, Event, EventExtra, PromoCode, PromoValidateRequest
+
+
+def _event(codes) -> Event:
+ return Event(
+ id="evt",
+ wallet="w",
+ name="Test",
+ info="",
+ closing_date="2030-01-01",
+ event_start_date="2030-01-01",
+ event_end_date="2030-01-02",
+ currency="sat",
+ price_per_ticket=1000,
+ amount_tickets=10,
+ time=datetime.now(timezone.utc),
+ extra=EventExtra(promo_codes=codes),
+ status="approved",
+ )
+
+
+@pytest.fixture
+def event(monkeypatch):
+ ev = _event(
+ [
+ PromoCode(code="HALF", discount_percent=50, max_uses=2),
+ PromoCode(code="OLD", discount_percent=20, active=False),
+ ]
+ )
+ monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=ev))
+ monkeypatch.setattr(
+ views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 1})
+ )
+ return ev
+
+
+@pytest.mark.asyncio
+async def test_validate_returns_v2_shaped_totals(event):
+ totals = await views_api.api_validate_promo_codes(
+ "evt", PromoValidateRequest(codes=["half"], quantity=1)
+ )
+ assert totals.dict() == {
+ "subtotal": 1000,
+ "discount": 500,
+ "total": 500,
+ "currency": "sat",
+ "discounts_applied": [
+ {
+ "code": "HALF",
+ "discount_percent": 50,
+ "discount_fixed": None,
+ "amount_saved": 500,
+ }
+ ],
+ }
+
+
+@pytest.mark.asyncio
+async def test_validate_is_advisory_for_bad_codes(event):
+ for codes, qty in ((["OLD"], 1), (["NOPE"], 1), (["HALF"], 2)):
+ totals = await views_api.api_validate_promo_codes(
+ "evt", PromoValidateRequest(codes=codes, quantity=qty)
+ )
+ assert totals.discounts_applied == [] and totals.total == totals.subtotal
+
+
+@pytest.mark.asyncio
+async def test_validate_unknown_event_is_404(monkeypatch):
+ monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=None))
+ with pytest.raises(HTTPException) as exc:
+ await views_api.api_validate_promo_codes(
+ "nope", PromoValidateRequest(codes=["X"])
+ )
+ assert exc.value.status_code == 404
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "code,quantity,detail",
+ [
+ ("NOPE", 1, "Invalid promo code."),
+ ("old", 1, "Promo code is not active."),
+ ("HALF", 2, "Only 1 use(s) left on this promo code."),
+ ],
+)
+async def test_purchase_rejects_bad_codes_before_any_invoice(
+ event, monkeypatch, code, quantity, detail
+):
+ monkeypatch.setattr(
+ views_api,
+ "create_payment_request",
+ AsyncMock(side_effect=AssertionError("must not be called")),
+ )
+ data = CreateTicket(user_id="u1", promo_code=code, quantity=quantity)
+ with pytest.raises(HTTPException) as exc:
+ await views_api.api_ticket_create(
+ "evt", data, SimpleNamespace(base_url="http://lnbits.local/")
+ )
+ assert exc.value.status_code == 400
+ assert exc.value.detail == detail
+
+
+@pytest.mark.asyncio
+async def test_purchase_reports_fully_redeemed(event, monkeypatch):
+ monkeypatch.setattr(
+ views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 2})
+ )
+ monkeypatch.setattr(
+ views_api,
+ "create_payment_request",
+ AsyncMock(side_effect=AssertionError("must not be called")),
+ )
+ with pytest.raises(HTTPException) as exc:
+ await views_api.api_ticket_create(
+ "evt",
+ CreateTicket(user_id="u1", promo_code="HALF"),
+ SimpleNamespace(base_url="http://lnbits.local/"),
+ )
+ assert exc.value.detail == "Promo code has been fully redeemed."
+
+
+@pytest.fixture
+def update_env(monkeypatch):
+ stored = _event([PromoCode(code="KEEP", discount_percent=10)])
+ monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=stored))
+ monkeypatch.setattr(
+ views_api,
+ "get_settings",
+ AsyncMock(return_value=SimpleNamespace(auto_approve=True)),
+ )
+ monkeypatch.setattr(views_api, "update_event", AsyncMock(side_effect=lambda e: e))
+ monkeypatch.setattr(views_api, "publish_or_delete_nostr_event", AsyncMock())
+ return stored
+
+
+def _update_payload(extra: dict) -> dict:
+ return {
+ "wallet": "w",
+ "name": "Test",
+ "info": "",
+ "closing_date": "2030-01-01",
+ "event_start_date": "2030-01-01",
+ "event_end_date": "2030-01-02",
+ "amount_tickets": 10,
+ "price_per_ticket": 1000,
+ "extra": extra,
+ }
+
+
+def _wallet():
+ return SimpleNamespace(wallet=SimpleNamespace(id="w", user="u1"))
+
+
+@pytest.mark.asyncio
+async def test_update_without_promo_key_keeps_stored_codes(update_env):
+ from ..models import CreateEvent
+
+ data = CreateEvent.parse_obj(_update_payload({"email_notifications": True}))
+ event = await views_api.api_event_update("evt", data, _wallet())
+ assert [pc.code for pc in event.extra.promo_codes] == ["KEEP"]
+ assert event.extra.email_notifications is True
+
+
+@pytest.mark.asyncio
+async def test_update_with_empty_promo_list_clears(update_env):
+ from ..models import CreateEvent
+
+ data = CreateEvent.parse_obj(_update_payload({"promo_codes": []}))
+ event = await views_api.api_event_update("evt", data, _wallet())
+ assert event.extra.promo_codes == []
diff --git a/views_api.py b/views_api.py
index ac0b0ba..039bc4f 100644
--- a/views_api.py
+++ b/views_api.py
@@ -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)