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
323 lines
11 KiB
Python
323 lines
11 KiB
Python
import json
|
|
from datetime import datetime
|
|
from urllib.parse import urlsplit
|
|
|
|
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
|
|
|
PAYMENT_METHODS = ("lightning", "fiat")
|
|
|
|
|
|
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
|
|
|
|
# stored form: stripped + upper-case, never empty
|
|
@validator("code")
|
|
def uppercase_code(cls, v):
|
|
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."""
|
|
|
|
conditional: bool = False
|
|
min_tickets: int = 1
|
|
email_notifications: bool = False
|
|
nostr_notifications: bool = False
|
|
notification_subject: str = ""
|
|
notification_body: str = ""
|
|
# Rails the organizer accepts for this event. Empty = legacy rule
|
|
# ("lightning" always, "fiat" when allow_fiat) — see
|
|
# `effective_payment_methods`. Same field name/shape as upstream v2 so the
|
|
# eventual rebase (#33) merges cleanly.
|
|
payment_methods: list[str] = Field(default_factory=list)
|
|
|
|
@validator("payment_methods", pre=True)
|
|
def normalize_payment_methods(cls, v):
|
|
if not v:
|
|
return []
|
|
if isinstance(v, str):
|
|
v = v.split(",")
|
|
seen: list[str] = []
|
|
for method in v:
|
|
method = str(method).strip().lower()
|
|
if method not in PAYMENT_METHODS:
|
|
raise ValueError(f"Unsupported payment method: {method}")
|
|
if method not in seen:
|
|
seen.append(method)
|
|
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)
|
|
info: str = "" # description (optional)
|
|
closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date
|
|
# ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30").
|
|
# Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time).
|
|
event_start_date: str
|
|
event_end_date: str | None = None # same format as event_start_date
|
|
currency: str = "sat"
|
|
allow_fiat: bool = False
|
|
fiat_currency: str = "GBP"
|
|
amount_tickets: int = 0 # 0 = unlimited / not ticketed
|
|
price_per_ticket: float = 0 # 0 = free
|
|
banner: str | None = None
|
|
location: str | None = None # venue/address (NIP-52 'location' tag)
|
|
categories: list[str] = Field(default_factory=list) # NIP-52 't' tags
|
|
extra: EventExtra = Field(default_factory=EventExtra)
|
|
status: str = "approved" # proposed, approved, rejected
|
|
|
|
|
|
class Event(BaseModel):
|
|
id: str
|
|
wallet: str
|
|
name: str
|
|
info: str = ""
|
|
closing_date: str | None = None
|
|
canceled: bool = False
|
|
event_start_date: str
|
|
event_end_date: str | None = None
|
|
currency: str = "sat"
|
|
allow_fiat: bool = False
|
|
fiat_currency: str = "GBP"
|
|
amount_tickets: int = 0
|
|
price_per_ticket: float = 0
|
|
time: datetime
|
|
sold: int = 0
|
|
banner: str | None = None
|
|
location: str | None = None
|
|
categories: list[str] = Field(default_factory=list)
|
|
extra: EventExtra = Field(default_factory=EventExtra)
|
|
status: str = "approved"
|
|
nostr_event_id: str | None = None
|
|
nostr_event_created_at: int | None = None
|
|
|
|
@validator("categories", pre=True)
|
|
def parse_categories(cls, v):
|
|
if isinstance(v, str):
|
|
return json.loads(v) if v else []
|
|
return v or []
|
|
|
|
|
|
class PublicEvent(BaseModel):
|
|
id: str
|
|
name: str
|
|
info: str
|
|
closing_date: str | None = None
|
|
canceled: bool
|
|
event_start_date: str
|
|
event_end_date: str | None = None
|
|
currency: str
|
|
allow_fiat: bool = False
|
|
fiat_currency: str = "GBP"
|
|
price_per_ticket: float
|
|
banner: str | None
|
|
location: str | None = None
|
|
categories: list[str] = Field(default_factory=list)
|
|
# 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)
|
|
def parse_categories(cls, v):
|
|
if isinstance(v, str):
|
|
return json.loads(v) if v else []
|
|
return v or []
|
|
|
|
|
|
def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]:
|
|
"""Rails a buyer may pick for `event`.
|
|
|
|
Explicit `extra.payment_methods` wins; an empty list falls back to the
|
|
pre-#payment-methods rule so events created before the field existed
|
|
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
|
|
"""
|
|
explicit = list(getattr(event.extra, "payment_methods", []) or [])
|
|
if explicit:
|
|
return explicit
|
|
methods = ["lightning"]
|
|
if event.allow_fiat:
|
|
methods.append("fiat")
|
|
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."""
|
|
|
|
auto_approve: bool = False # Skip approval workflow for non-admin users
|
|
|
|
|
|
class TicketExtra(BaseModel):
|
|
applied_promo_code: str | None = None
|
|
sats_paid: int | None = None
|
|
refund_address: str | None = None
|
|
nostr_identifier: str | None = None
|
|
ticket_base_url: str | None = None
|
|
email_notification_sent: bool = False
|
|
nostr_notification_sent: bool = False
|
|
refunded: bool = False
|
|
|
|
|
|
class CreateTicket(BaseModel):
|
|
name: str | None = None
|
|
email: EmailStr | None = None
|
|
user_id: str | None = None # LNbits user id (alternative to name+email)
|
|
promo_code: str | None = None
|
|
refund_address: str | None = None
|
|
nostr_identifier: str | None = None
|
|
payment_method: str | None = None
|
|
fiat_provider: str | None = None
|
|
# Number of tickets to buy on this single invoice. Bounded so a
|
|
# bad client can't run away with the organizer's capacity.
|
|
quantity: int = Field(default=1, ge=1, le=10)
|
|
# App root of the client that is buying (e.g. https://app.example/events).
|
|
# The extension builds the Stripe success/cancel URLs and the emailed
|
|
# ticket link under it, so the buyer lands back in the app they came
|
|
# from. Origin is allow-listed server-side (see `_resolve_frontend_root`);
|
|
# absent = today's behaviour (the LNbits host).
|
|
frontend_url: str | None = Field(default=None, max_length=512)
|
|
|
|
@validator("frontend_url")
|
|
def validate_frontend_url(cls, v):
|
|
if v is None:
|
|
return None
|
|
v = v.strip()
|
|
if not v:
|
|
return None
|
|
parts = urlsplit(v)
|
|
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
raise ValueError("frontend_url must be an absolute http(s) URL")
|
|
if parts.query or parts.fragment or ".." in parts.path:
|
|
raise ValueError("frontend_url must not contain a query, fragment or '..'")
|
|
return v.rstrip("/")
|
|
|
|
@root_validator
|
|
def validate_identifiers(cls, values):
|
|
"""A ticket needs an identity: an LNbits `user_id`, or `name` +
|
|
`email` for guests. A logged-in buyer may add `email` (and `name`)
|
|
on top of `user_id` so the ticket can be emailed to them."""
|
|
name = values.get("name")
|
|
email = values.get("email")
|
|
user_id = values.get("user_id")
|
|
if not user_id and not (name and email):
|
|
raise ValueError("Either user_id or both name and email must be provided")
|
|
return values
|
|
|
|
|
|
class Ticket(BaseModel):
|
|
id: str
|
|
wallet: str
|
|
event: str
|
|
name: str | None = None
|
|
email: str | None = None
|
|
user_id: str | None = None
|
|
registered: bool
|
|
paid: bool
|
|
time: datetime
|
|
reg_timestamp: datetime
|
|
extra: TicketExtra = Field(default_factory=TicketExtra)
|
|
# Shared LNbits invoice payment_hash. Equals `id` for single-ticket
|
|
# purchases (legacy + post-migration default). Multi-ticket
|
|
# purchases create N rows sharing one payment_hash so each attendee
|
|
# gets a distinct scannable id while the buyer pays once.
|
|
payment_hash: str | None = None
|
|
|
|
|
|
class NotificationDeliveryResult(BaseModel):
|
|
attempted: bool = False
|
|
sent: bool = False
|
|
error: str | None = None
|
|
|
|
|
|
class TicketResendResult(BaseModel):
|
|
ticket: Ticket
|
|
email: NotificationDeliveryResult = Field(
|
|
default_factory=NotificationDeliveryResult
|
|
)
|
|
nostr: NotificationDeliveryResult = Field(
|
|
default_factory=NotificationDeliveryResult
|
|
)
|
|
|
|
|
|
class PublicTicket(BaseModel):
|
|
event: str
|
|
name: str | None = None
|
|
registered: bool
|
|
paid: bool
|
|
time: datetime
|
|
reg_timestamp: datetime
|
|
|
|
|
|
class TicketPaymentRequest(BaseModel):
|
|
payment_hash: str
|
|
payment_request: str | None = None
|
|
fiat_payment_request: str | None = None
|
|
fiat_provider: str | None = None
|
|
is_fiat: bool = False
|
|
# True when the tickets are already issued + paid with no invoice to
|
|
# settle — free events (price 0) or a 100%-off promo. The client skips
|
|
# the QR / payment-poll step and goes straight to the ticket QRs.
|
|
paid: bool = False
|
|
# Row ids created on this invoice — one for single-ticket
|
|
# purchases, N for multi-ticket (each independently scannable at
|
|
# the door). Buyers fetch these after payment to render N QRs in
|
|
# My Tickets.
|
|
ticket_ids: list[str] = Field(default_factory=list)
|