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

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