Inventory reaches clients only through the republished calendar event, and until now a publish that failed or was skipped left no durable trace — only a log line, if that. Twice the drift was caught by a human reading a wrong number on a public page (#35 on aio-demo, #51 on cfaun, where an event's relay copy sat 14 days behind the DB). Adds `events.nostr_publish_pending`, set before every attempt and cleared only on a confirmed success. Ordering it that way is what makes "the attempt was never made" — no signer resolved, no NostrClient, the process died mid-flight — as discoverable as "the attempt raised". Both shapes have now been observed in production; only the second one was ever visible. `set_ticket_paid` raises the flag inside its own update so the counters and "the relay doesn't know about them yet" commit atomically, and the sale path pays no extra write. `publish_or_delete_nostr_event` now returns a bool so callers can branch. The flag, not the return value, is the durable record — the existing call sites stay correct ignoring it. Publish failures move from WARNING to ERROR: the published ticket count has stopped tracking reality, which is not routine journal noise. Refs #35
327 lines
11 KiB
Python
327 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
|
|
# Set before every publish attempt, cleared on confirmed success.
|
|
# True means the relay's copy may be behind this row — see
|
|
# migrations_fork.m003 and the sweep in __init__.events_start.
|
|
nostr_publish_pending: bool = False
|
|
|
|
@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)
|