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

View file

@ -59,10 +59,12 @@ from .crud import (
update_ticket,
)
from .models import (
BasketTotals,
CreateEvent,
CreateTicket,
Event,
EventsSettings,
PromoValidateRequest,
PublicEvent,
PublicTicket,
Ticket,
@ -71,6 +73,13 @@ from .models import (
effective_payment_methods,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .promo import (
basket_totals,
find_promo,
normalize_code,
remaining_uses,
round_amount,
)
from .qr import (
image_png_bytes,
load_qr_logo,
@ -79,6 +88,8 @@ from .qr import (
ticket_card_filename,
)
from .services import (
event_promo_usage,
hydrate_promo_usage,
refund_tickets,
resend_ticket_email_notification,
send_ticket_notification_in_background,
@ -89,6 +100,7 @@ from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
qr_api_router = APIRouter(prefix="/api/v1")
promo_api_router = APIRouter(prefix="/api/v1/promo")
def _is_fiat_currency(currency: str | None) -> bool:
@ -108,12 +120,17 @@ async def api_events(
if all_wallets:
user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else []
return await get_events(wallet_ids)
events = await get_events(wallet_ids)
for event in events:
await hydrate_promo_usage(event)
return events
@events_api_router.get("/public")
@events_api_router.get("/public", response_model=list[PublicEvent])
async def api_events_public() -> list[Event]:
"""Approved, non-canceled events for an anonymous public listing."""
"""Approved, non-canceled events for an anonymous public listing.
Projected through `PublicEvent`: no wallet id, no promo codes."""
return await get_public_events()
@ -131,6 +148,7 @@ async def api_events_all(
events = await get_all_events()
enriched: list[dict] = []
for event in events:
await hydrate_promo_usage(event)
wallet = await get_wallet(event.wallet)
row = event.dict()
row["wallet_user_id"] = wallet.user if wallet else None
@ -353,6 +371,13 @@ async def api_event_update(
if not data.closing_date:
data.closing_date = data.event_end_date
# Promo codes are organizer-only and absent from public responses, so a
# client that round-trips a public record (or simply doesn't manage
# codes) would otherwise wipe them on every edit. Carry the stored list
# over unless the request names the key; an explicit `[]` still clears.
if "promo_codes" not in data.extra.__fields_set__:
data.extra.promo_codes = event.extra.promo_codes
# Explicit field list — never copy `status` from the request body.
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
# owner editing a fiat-enabled event keeps the fiat config.
@ -660,7 +685,7 @@ async def api_ticket_create(
name = data.name
email = data.email
user_id = data.user_id
promo_code = data.promo_code.upper() if data.promo_code else None
promo_code = normalize_code(data.promo_code)
refund_address = data.refund_address
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
payment_method = (data.payment_method or "lightning").lower()
@ -677,22 +702,39 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.",
) from exc
unit_price = event.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
frontend_root = _resolve_frontend_root(data, request)
# One invoice, N tickets; the promo (if any) prices the whole quantity
# through the same `basket_totals` the validate endpoint uses, so the
# preview a buyer saw is what gets charged. Unlike validate, a bad code
# is a hard error here — silently charging full price would be worse.
if promo_code:
# check if promo_code exists in event.extra.promo_codes
if promo_code not in [pc.code for pc in event.extra.promo_codes]:
promo = find_promo(event, promo_code)
if not promo:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
)
# get the promocode
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
if not promo.active:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Promo code is not active.",
)
usage = await event_promo_usage(event.id)
uses_left = remaining_uses(promo, usage.get(promo.code, 0))
if uses_left is not None and uses_left < quantity:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
"Promo code has been fully redeemed."
if uses_left == 0
else f"Only {uses_left} use(s) left on this promo code."
),
)
extra["promo_code"] = promo.code
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100)
# Scale by quantity AFTER the promo applies. One invoice, N tickets.
price = unit_price * quantity
price = basket_totals(event, [promo.code], quantity, usage).total
else:
price = round_amount(event.price_per_ticket * quantity, event.currency)
# Free tickets (final charge 0 — a free event or a 100%-off promo).
# Short-circuit before any invoice / fiat-provider logic: no Lightning
@ -784,6 +826,7 @@ async def api_ticket_create(
"event_id": event.id,
"quantity": str(quantity),
"ticket_ids": ",".join(ticket_ids),
**({"promo_code": promo_code} if promo_code else {}),
},
}
@ -1042,6 +1085,7 @@ async def api_event_ticket_stats(
"registered_at": (
t.reg_timestamp.isoformat() if t.reg_timestamp else None
),
"applied_promo_code": t.extra.applied_promo_code,
}
for t in paid_tickets
],
@ -1100,3 +1144,21 @@ async def api_ticket_card(ticket_id: str):
"Cache-Control": "no-cache, no-store, must-revalidate",
},
)
@promo_api_router.post("/validate/{event_id}")
async def api_validate_promo_codes(
event_id: str, data: PromoValidateRequest
) -> BasketTotals:
"""Price a purchase with the given codes without committing to it —
what the buyer sees before paying. Anonymous and advisory: a code that
is unknown / inactive / exhausted is simply absent from
`discounts_applied`; the purchase endpoint is where hard errors live.
Same URL as upstream v2 (`quantity` instead of v2's `items`)."""
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
usage = await event_promo_usage(event_id) if event.extra.promo_codes else {}
return basket_totals(event, data.codes, data.quantity, usage)