feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public

Promo handling was inherited from upstream unchanged and had four gaps
the webapp was about to put in front of buyers:

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

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

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

Closes #32

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
This commit is contained in:
Padreug 2026-09-10 12:19:44 +02:00
commit 8602bd71e3
7 changed files with 618 additions and 19 deletions

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