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:
parent
4c3b1bca31
commit
8602bd71e3
7 changed files with 618 additions and 19 deletions
63
models.py
63
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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue