From 8602bd71e3b4a4ca03a48e645005c1735d6f81b2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 10 Sep 2026 12:19:44 +0200 Subject: [PATCH 1/3] feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promo handling was inherited from upstream unchanged and had four gaps the webapp was about to put in front of buyers: - `active` was decorative: purchase never read it, so a deactivated code kept discounting. Now rejected with "Promo code is not active." - No redemption cap (#32). `PromoCode.max_uses` (None/0 = unlimited) with `used_count` DERIVED from paid tickets carrying the code in `extra.applied_promo_code` — each ticket of a multi-ticket purchase consumes one use (upstream v2 counts one per basket; documented). Paid-only counting so an abandoned Stripe session can't lock out the last uses for the 24 h unpaid-row lifetime; bounded overshoot under concurrency accepted. - Every code was readable by anyone: `PublicEvent.extra` was the full `EventExtra` and `/events/public` returned the untrimmed `Event` (wallet id included). `EventExtraBase` / `PublicEventExtra` project them out; `/public` now goes through `PublicEvent`. Organizer and admin listings keep the full model, now hydrated with `used_count`. - No preview: `POST /events/api/v1/promo/validate/{event_id}` (same URL as upstream v2; `quantity` replaces v2's `items` since this fork has no ticket types) returns v2-shaped `BasketTotals` + `currency`. Advisory: bad codes are simply absent from `discounts_applied`; purchase still hard-fails them with distinct details. All pricing (validate, invoice, Stripe amount) goes through one pure `basket_totals` with a single rounding rule (whole sats / 2 dp fiat), so the preview equals the charge. Stripe metadata carries `promo_code`; the organizer stats rows carry `applied_promo_code`. `api_event_update` keeps stored codes when the request omits `extra.promo_codes` (explicit `[]` still clears): now that public records don't carry them, a client round-tripping one would otherwise wipe the organizer's codes on every edit. Closes #32 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas --- __init__.py | 8 +- models.py | 63 ++++++++++++-- promo.py | 108 ++++++++++++++++++++++++ services.py | 17 ++++ tests/test_promo.py | 175 +++++++++++++++++++++++++++++++++++++++ tests/test_promo_api.py | 178 ++++++++++++++++++++++++++++++++++++++++ views_api.py | 86 ++++++++++++++++--- 7 files changed, 617 insertions(+), 18 deletions(-) create mode 100644 promo.py create mode 100644 tests/test_promo.py create mode 100644 tests/test_promo_api.py 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/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/tests/test_promo.py b/tests/test_promo.py new file mode 100644 index 0000000..a4067c3 --- /dev/null +++ b/tests/test_promo.py @@ -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 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) -- 2.55.0 From 07519bec1bebc2f68b4bce4a8a0b7755bee605e1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 10 Sep 2026 12:19:44 +0200 Subject: [PATCH 2/3] feat(admin): max uses column + used count in the promo editor The Quasar editor gains a "Max uses" input per code (blank = unlimited, normalised to null on save) and shows "Used n / max" from the derived count. The public buy page's Clear button now also clears the promo field. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas --- static/js/display.js | 1 + static/js/index.js | 4 +++- static/js/index.vue | 30 +++++++++++++++++++++++++++--- 3 files changed, 31 insertions(+), 4 deletions(-) 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" >