Compare commits
7 commits
c9fbd8335c
...
93cc95fe18
| Author | SHA1 | Date | |
|---|---|---|---|
| 93cc95fe18 | |||
| 07519bec1b | |||
| 8602bd71e3 | |||
| 4c3b1bca31 | |||
| c230e61031 | |||
| 87c74ce13f | |||
| f835766935 |
13 changed files with 905 additions and 95 deletions
|
|
@ -62,6 +62,13 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
||||||
- **Stripe session.** The buyer's email is passed as `customer_email`
|
- **Stripe session.** The buyer's email is passed as `customer_email`
|
||||||
(prefilled and locked on the hosted page); the line item is named after the
|
(prefilled and locked on the hosted page); the line item is named after the
|
||||||
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
|
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
|
||||||
|
- **Promo codes.** `extra.promo_codes` (`code`, `discount_percent`, `active`,
|
||||||
|
`max_uses`; `used_count` is derived from paid tickets) are organizer-only: they are
|
||||||
|
never part of public responses. Buyers preview a code with
|
||||||
|
`POST /events/api/v1/promo/validate/{event_id}` (`{codes, quantity}` → v2-shaped
|
||||||
|
`BasketTotals` + `currency`); purchase enforces `active` and `max_uses` (each ticket
|
||||||
|
of a multi-ticket purchase consumes one use) and rejects bad codes with a distinct
|
||||||
|
`detail`. Updates that omit `extra.promo_codes` keep the stored list.
|
||||||
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
|
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
|
||||||
attached — a self-describing PNG (site, event, when, where, QR with the
|
attached — a self-describing PNG (site, event, when, where, QR with the
|
||||||
instance logo, name on ticket, ticket id) also served at
|
instance logo, name on ticket, ticket id) also served at
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,19 @@ from loguru import logger
|
||||||
from .crud import db
|
from .crud import db
|
||||||
from .tasks import wait_for_paid_invoices
|
from .tasks import wait_for_paid_invoices
|
||||||
from .views import events_generic_router
|
from .views import events_generic_router
|
||||||
from .views_api import events_api_router, qr_api_router, tickets_api_router
|
from .views_api import (
|
||||||
|
events_api_router,
|
||||||
|
promo_api_router,
|
||||||
|
qr_api_router,
|
||||||
|
tickets_api_router,
|
||||||
|
)
|
||||||
|
|
||||||
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
||||||
events_ext.include_router(events_generic_router)
|
events_ext.include_router(events_generic_router)
|
||||||
events_ext.include_router(events_api_router)
|
events_ext.include_router(events_api_router)
|
||||||
events_ext.include_router(tickets_api_router)
|
events_ext.include_router(tickets_api_router)
|
||||||
events_ext.include_router(qr_api_router)
|
events_ext.include_router(qr_api_router)
|
||||||
|
events_ext.include_router(promo_api_router)
|
||||||
|
|
||||||
events_static_files = [
|
events_static_files = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"id": "events",
|
"id": "events",
|
||||||
"version": "1.6.1-aio.10",
|
"version": "1.6.1-aio.14",
|
||||||
"name": "Events",
|
"name": "Events",
|
||||||
"repo": "https://git.atitlan.io/aiolabs/events",
|
"repo": "https://git.atitlan.io/aiolabs/events",
|
||||||
"short_description": "Sell and register event tickets",
|
"short_description": "Sell and register event tickets",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Running log of fork features that are shaped so they could be offered to
|
||||||
in an upstream-compatible form; strike it when the PR merges upstream.
|
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
|
|
||||||
| Feature | Where | Upstream target | Readiness |
|
| Feature | Where | Upstream target | Readiness |
|
||||||
| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
||||||
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
||||||
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
||||||
|
|
@ -17,3 +17,4 @@ in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
|
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
|
||||||
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
||||||
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
||||||
|
| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 |
|
||||||
|
|
|
||||||
63
models.py
63
models.py
|
|
@ -11,20 +11,41 @@ class PromoCode(BaseModel):
|
||||||
code: str
|
code: str
|
||||||
discount_percent: float = 0.0
|
discount_percent: float = 0.0
|
||||||
active: bool = True
|
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")
|
@validator("code")
|
||||||
def uppercase_code(cls, v):
|
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")
|
@validator("discount_percent")
|
||||||
def validate_discount_percent(cls, v):
|
def validate_discount_percent(cls, v):
|
||||||
assert 0 <= v <= 100, "Discount must be between 0 and 100."
|
assert 0 <= v <= 100, "Discount must be between 0 and 100."
|
||||||
return v
|
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
|
conditional: bool = False
|
||||||
min_tickets: int = 1
|
min_tickets: int = 1
|
||||||
email_notifications: bool = False
|
email_notifications: bool = False
|
||||||
|
|
@ -53,6 +74,13 @@ class EventExtra(BaseModel):
|
||||||
return seen
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
class EventExtra(EventExtraBase):
|
||||||
|
promo_codes: list[PromoCode] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
PublicEventExtra = EventExtraBase
|
||||||
|
|
||||||
|
|
||||||
class CreateEvent(BaseModel):
|
class CreateEvent(BaseModel):
|
||||||
wallet: str | None = None # filled from caller's wallet if absent
|
wallet: str | None = None # filled from caller's wallet if absent
|
||||||
name: str # title (required)
|
name: str # title (required)
|
||||||
|
|
@ -120,7 +148,9 @@ class PublicEvent(BaseModel):
|
||||||
banner: str | None
|
banner: str | None
|
||||||
location: str | None = None
|
location: str | None = None
|
||||||
categories: list[str] = Field(default_factory=list)
|
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
|
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
|
||||||
|
|
||||||
@validator("categories", pre=True)
|
@validator("categories", pre=True)
|
||||||
|
|
@ -146,6 +176,29 @@ def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> lis
|
||||||
return methods
|
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):
|
class EventsSettings(BaseModel):
|
||||||
"""Extension-level settings for the events extension."""
|
"""Extension-level settings for the events extension."""
|
||||||
|
|
||||||
|
|
|
||||||
108
promo.py
Normal file
108
promo.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""Promo-code arithmetic shared by the validate endpoint and the purchase path.
|
||||||
|
|
||||||
|
Pure functions (no DB, no settings) so the number a buyer sees in the
|
||||||
|
"Apply" preview is exactly the number the invoice / Stripe session charges.
|
||||||
|
Field names and the `BasketTotals` shape follow upstream lnbits/events v2
|
||||||
|
(PR #64) so the eventual rebase (#33) merges cleanly; deviations are noted
|
||||||
|
inline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket
|
||||||
|
|
||||||
|
SAT_UNITS = ("sat", "sats")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_code(raw: str | None) -> str | None:
|
||||||
|
"""Buyer input → stored form (stripped, upper-cased); empty → None."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
code = raw.strip().upper()
|
||||||
|
return code or None
|
||||||
|
|
||||||
|
|
||||||
|
def find_promo(event: Event, code: str) -> PromoCode | None:
|
||||||
|
return next((pc for pc in event.extra.promo_codes if pc.code == code), None)
|
||||||
|
|
||||||
|
|
||||||
|
def promo_usage(tickets: list[Ticket]) -> dict[str, int]:
|
||||||
|
"""Redemptions per code = PAID tickets carrying it in
|
||||||
|
`extra.applied_promo_code`. Every row counts, so a multi-ticket
|
||||||
|
purchase consumes `quantity` uses (upstream v2 counts one per basket).
|
||||||
|
|
||||||
|
Paid only: pending rows live up to 24 h (`purge_unpaid_tickets`), so
|
||||||
|
counting them would let an abandoned Stripe session lock out the last
|
||||||
|
uses of a limited code for a day. The cost is a bounded overshoot when
|
||||||
|
several buyers pass the check before any of them pays — accepted.
|
||||||
|
"""
|
||||||
|
counter: Counter[str] = Counter()
|
||||||
|
for ticket in tickets:
|
||||||
|
code = ticket.extra.applied_promo_code
|
||||||
|
if ticket.paid and code:
|
||||||
|
counter[code] += 1
|
||||||
|
return dict(counter)
|
||||||
|
|
||||||
|
|
||||||
|
def remaining_uses(promo: PromoCode, used: int) -> int | None:
|
||||||
|
"""None = unlimited (`max_uses` unset / 0)."""
|
||||||
|
if not promo.max_uses:
|
||||||
|
return None
|
||||||
|
return max(promo.max_uses - used, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def round_amount(amount: float, currency: str | None) -> float:
|
||||||
|
"""Sats are integers; fiat is 2 dp. Applied once, at the end, so
|
||||||
|
subtotal - total == discount holds for what is actually charged."""
|
||||||
|
if (currency or "sat").lower() in SAT_UNITS:
|
||||||
|
return float(int(amount))
|
||||||
|
return round(amount, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def basket_totals(
|
||||||
|
event: Event,
|
||||||
|
codes: list[str],
|
||||||
|
quantity: int,
|
||||||
|
usage: dict[str, int],
|
||||||
|
) -> BasketTotals:
|
||||||
|
"""Price `quantity` tickets with the first applicable code in `codes`.
|
||||||
|
|
||||||
|
A code is applicable when it exists, is active, has enough uses left
|
||||||
|
for the whole quantity, and actually saves something. Anything else is
|
||||||
|
simply absent from `discounts_applied` (upstream v2 semantics — the
|
||||||
|
purchase endpoint is where hard errors are raised). Only one code is
|
||||||
|
applied; v2's `combinable` stacking is out of scope here.
|
||||||
|
"""
|
||||||
|
currency = event.currency or "sat"
|
||||||
|
subtotal = round_amount(event.price_per_ticket * quantity, currency)
|
||||||
|
totals = BasketTotals(
|
||||||
|
subtotal=subtotal, discount=0, total=subtotal, currency=currency
|
||||||
|
)
|
||||||
|
for raw in codes:
|
||||||
|
code = normalize_code(raw)
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
promo = find_promo(event, code)
|
||||||
|
if not promo or not promo.active:
|
||||||
|
continue
|
||||||
|
remaining = remaining_uses(promo, usage.get(promo.code, 0))
|
||||||
|
if remaining is not None and remaining < quantity:
|
||||||
|
continue
|
||||||
|
total = round_amount(subtotal * (1 - promo.discount_percent / 100), currency)
|
||||||
|
saved = round_amount(subtotal - total, currency)
|
||||||
|
if saved <= 0:
|
||||||
|
continue
|
||||||
|
totals.total = total
|
||||||
|
totals.discount = saved
|
||||||
|
totals.discounts_applied = [
|
||||||
|
BasketDiscount(
|
||||||
|
code=promo.code,
|
||||||
|
discount_percent=promo.discount_percent,
|
||||||
|
discount_fixed=None,
|
||||||
|
amount_saved=saved,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
break
|
||||||
|
return totals
|
||||||
17
services.py
17
services.py
|
|
@ -28,6 +28,7 @@ from .crud import (
|
||||||
)
|
)
|
||||||
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
|
from .promo import promo_usage
|
||||||
from .qr import (
|
from .qr import (
|
||||||
format_event_when,
|
format_event_when,
|
||||||
image_png_bytes,
|
image_png_bytes,
|
||||||
|
|
@ -82,6 +83,22 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
async def event_promo_usage(event_id: str) -> dict[str, int]:
|
||||||
|
"""Paid redemptions per promo code for one event (see promo.promo_usage)."""
|
||||||
|
return promo_usage(await get_event_tickets(event_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def hydrate_promo_usage(event: Event) -> Event:
|
||||||
|
"""Fill `used_count` on each of the event's promo codes. No query when
|
||||||
|
the event has no codes, so listing stays cheap."""
|
||||||
|
if not event.extra.promo_codes:
|
||||||
|
return event
|
||||||
|
usage = await event_promo_usage(event.id)
|
||||||
|
for promo in event.extra.promo_codes:
|
||||||
|
promo.used_count = usage.get(promo.code, 0)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
def send_ticket_notification_in_background(ticket: Ticket) -> None:
|
def send_ticket_notification_in_background(ticket: Ticket) -> None:
|
||||||
create_task(_send_ticket_notification(ticket))
|
create_task(_send_ticket_notification(ticket))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ window.PageEventsDisplay = {
|
||||||
this.formDialog.data.email = ''
|
this.formDialog.data.email = ''
|
||||||
this.formDialog.data.refund = ''
|
this.formDialog.data.refund = ''
|
||||||
this.formDialog.data.nostr_identifier = ''
|
this.formDialog.data.nostr_identifier = ''
|
||||||
|
this.formDialog.data.promo_code = ''
|
||||||
this.formDialog.data.payment_method =
|
this.formDialog.data.payment_method =
|
||||||
this.paymentMethods[0] || 'lightning'
|
this.paymentMethods[0] || 'lightning'
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,15 @@ window.PageEvents = {
|
||||||
name: 'event_start_date',
|
name: 'event_start_date',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
label: 'Start date',
|
label: 'Start date',
|
||||||
field: 'event_start_date'
|
field: 'event_start_date',
|
||||||
|
format: val => this.formatEventDate(val)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'event_end_date',
|
name: 'event_end_date',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
label: 'End date',
|
label: 'End date',
|
||||||
field: 'event_end_date'
|
field: 'event_end_date',
|
||||||
|
format: val => this.formatEventDate(val)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'closing_date',
|
name: 'closing_date',
|
||||||
|
|
@ -131,6 +133,19 @@ window.PageEvents = {
|
||||||
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||||
|
{
|
||||||
|
name: 'email_sent',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Email sent',
|
||||||
|
field: row =>
|
||||||
|
!row.email
|
||||||
|
? 'no email'
|
||||||
|
: row.extra?.email_notification_sent
|
||||||
|
? '\u2713 sent'
|
||||||
|
: row.paid
|
||||||
|
? 'not sent'
|
||||||
|
: 'unpaid'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'registered',
|
name: 'registered',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
|
|
@ -149,12 +164,58 @@ window.PageEvents = {
|
||||||
rowsPerPage: 10
|
rowsPerPage: 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Rails an organizer can enable per event. Mirrors the webapp's
|
||||||
|
// CreateEventDialog; `fiat` is rendered disabled when the LNbits user
|
||||||
|
// has no fiat provider (see `hasFiatProvider`).
|
||||||
|
paymentMethodOptions: [
|
||||||
|
{
|
||||||
|
value: 'lightning',
|
||||||
|
label: 'Lightning',
|
||||||
|
hint: 'Pay with any Lightning wallet'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'fiat',
|
||||||
|
label: 'Card',
|
||||||
|
hint: 'Card or bank through your configured fiat provider'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Same list the webapp offers (src/modules/events/types/category.ts);
|
||||||
|
// published as NIP-52 `t` tags so both clients filter on one vocabulary.
|
||||||
|
categoryOptions: [
|
||||||
|
'concert',
|
||||||
|
'workshop',
|
||||||
|
'market',
|
||||||
|
'festival',
|
||||||
|
'exhibition',
|
||||||
|
'sport',
|
||||||
|
'theater',
|
||||||
|
'cinema',
|
||||||
|
'party',
|
||||||
|
'talk',
|
||||||
|
'conference',
|
||||||
|
'meetup',
|
||||||
|
'food',
|
||||||
|
'outdoor',
|
||||||
|
'kids',
|
||||||
|
'wellness',
|
||||||
|
'technology',
|
||||||
|
'art',
|
||||||
|
'music',
|
||||||
|
'dance',
|
||||||
|
'literature',
|
||||||
|
'comedy',
|
||||||
|
'charity',
|
||||||
|
'tradition',
|
||||||
|
'other'
|
||||||
|
].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})),
|
||||||
formDialog: {
|
formDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {
|
data: {
|
||||||
currency: 'sats',
|
currency: 'sats',
|
||||||
allow_fiat: false,
|
allow_fiat: false,
|
||||||
fiat_currency: 'GBP',
|
fiat_currency: 'GBP',
|
||||||
|
location: '',
|
||||||
|
categories: [],
|
||||||
extra: {
|
extra: {
|
||||||
payment_methods: ['lightning'],
|
payment_methods: ['lightning'],
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
|
|
@ -165,6 +226,27 @@ window.PageEvents = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
hasFiatProvider() {
|
||||||
|
return (this.g.user?.fiat_providers || []).length > 0
|
||||||
|
},
|
||||||
|
fiatProviderNames() {
|
||||||
|
return (this.g.user?.fiat_providers || [])
|
||||||
|
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
|
||||||
|
.join(', ')
|
||||||
|
},
|
||||||
|
acceptsFiat() {
|
||||||
|
return (this.formDialog.data.extra?.payment_methods || []).includes(
|
||||||
|
'fiat'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isSatPrice() {
|
||||||
|
return !this.isFiatCurrency(this.formDialog.data.currency)
|
||||||
|
},
|
||||||
|
fiatCurrencyOptions() {
|
||||||
|
return this.currencies.filter(c => this.isFiatCurrency(c))
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
isFiatCurrency(currency) {
|
isFiatCurrency(currency) {
|
||||||
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
||||||
|
|
@ -382,36 +464,71 @@ window.PageEvents = {
|
||||||
},
|
},
|
||||||
splitDateTime(value) {
|
splitDateTime(value) {
|
||||||
// Inverse of foldDateTime: split a stored string back into the
|
// Inverse of foldDateTime: split a stored string back into the
|
||||||
// day/time pieces the form inputs bind to.
|
// day/time pieces the form inputs bind to. Slicing to HH:MM also
|
||||||
|
// drops the seconds + offset suffix withLocalTzOffset stamps on
|
||||||
|
// submit, so the organizer sees the wall-clock they entered.
|
||||||
if (!value) return {day: '', time: ''}
|
if (!value) return {day: '', time: ''}
|
||||||
const [day, time = ''] = value.split('T')
|
const [day, time = ''] = value.split('T')
|
||||||
// Time inputs only accept HH:MM, drop any seconds we stored.
|
// Time inputs only accept HH:MM, drop any seconds we stored.
|
||||||
return {day, time: time.slice(0, 5)}
|
return {day, time: time.slice(0, 5)}
|
||||||
},
|
},
|
||||||
|
withLocalTzOffset(value) {
|
||||||
|
// Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value.
|
||||||
|
// The publisher's `_to_unix` treats a naive datetime as UTC, so an
|
||||||
|
// event entered as 18:00 in CEST would otherwise go out on Nostr
|
||||||
|
// as 18:00 UTC. Same transform the webapp applies; date-only
|
||||||
|
// values pass through unchanged (they map to NIP-52 kind 31922).
|
||||||
|
if (!value || !value.includes('T')) return value
|
||||||
|
const offMin = -new Date(value).getTimezoneOffset()
|
||||||
|
const sign = offMin >= 0 ? '+' : '-'
|
||||||
|
const abs = Math.abs(offMin)
|
||||||
|
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
|
||||||
|
const mm = String(abs % 60).padStart(2, '0')
|
||||||
|
return `${value}:00${sign}${hh}:${mm}`
|
||||||
|
},
|
||||||
|
formatEventDate(value) {
|
||||||
|
// Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM".
|
||||||
|
if (!value) return ''
|
||||||
|
const {day, time} = this.splitDateTime(value)
|
||||||
|
return time ? `${day} ${time}` : day
|
||||||
|
},
|
||||||
|
validateEndDate() {
|
||||||
|
// Cross-field rule for the end-day input: end >= start, compared
|
||||||
|
// on the folded date+time so an equal-day earlier time is caught.
|
||||||
|
const d = this.formDialog.data
|
||||||
|
const start = this.foldDateTime(d.event_start_day, d.event_start_time)
|
||||||
|
const end = this.foldDateTime(d.event_end_day, d.event_end_time)
|
||||||
|
if (!start || !end) return true
|
||||||
|
return end >= start || 'End must be on or after start'
|
||||||
|
},
|
||||||
sendEventData() {
|
sendEventData() {
|
||||||
const wallet = _.findWhere(this.g.user.wallets, {
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
id: this.formDialog.data.wallet
|
id: this.formDialog.data.wallet
|
||||||
})
|
})
|
||||||
const data = {...this.formDialog.data}
|
const data = {...this.formDialog.data}
|
||||||
data.event_start_date = this.foldDateTime(
|
data.event_start_date = this.withLocalTzOffset(
|
||||||
data.event_start_day,
|
this.foldDateTime(data.event_start_day, data.event_start_time)
|
||||||
data.event_start_time
|
|
||||||
)
|
)
|
||||||
data.event_end_date = this.foldDateTime(
|
data.event_end_date = this.withLocalTzOffset(
|
||||||
data.event_end_day,
|
this.foldDateTime(data.event_end_day, data.event_end_time)
|
||||||
data.event_end_time
|
|
||||||
)
|
)
|
||||||
delete data.event_start_day
|
delete data.event_start_day
|
||||||
delete data.event_start_time
|
delete data.event_start_time
|
||||||
delete data.event_end_day
|
delete data.event_end_day
|
||||||
delete data.event_end_time
|
delete data.event_end_time
|
||||||
|
// Optional NIP-52 fields: blank location is "unset", not "".
|
||||||
|
data.location = (data.location || '').trim() || null
|
||||||
|
data.categories = data.categories || []
|
||||||
|
data.closing_date = data.closing_date || null
|
||||||
|
|
||||||
if (data.extra?.promo_codes) {
|
if (data.extra?.promo_codes) {
|
||||||
data.extra.promo_codes = data.extra.promo_codes
|
data.extra.promo_codes = data.extra.promo_codes
|
||||||
.filter(code => code.code?.trim() !== '')
|
.filter(code => code.code?.trim() !== '')
|
||||||
.map(code => ({
|
.map(code => ({
|
||||||
...code,
|
...code,
|
||||||
code: code.code.trim().toUpperCase()
|
code: code.code.trim().toUpperCase(),
|
||||||
|
// blank / 0 = unlimited; used_count is derived server-side
|
||||||
|
max_uses: code.max_uses ? Number(code.max_uses) : null
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
const methods = data.extra?.payment_methods || []
|
const methods = data.extra?.payment_methods || []
|
||||||
|
|
@ -425,11 +542,13 @@ window.PageEvents = {
|
||||||
// allow_fiat stays the fiat-currency carrier the backend and the
|
// allow_fiat stays the fiat-currency carrier the backend and the
|
||||||
// NIP-52 tags read; keep it in lockstep with the checkbox list.
|
// NIP-52 tags read; keep it in lockstep with the checkbox list.
|
||||||
data.allow_fiat = methods.includes('fiat')
|
data.allow_fiat = methods.includes('fiat')
|
||||||
if (!this.isFiatCurrency(data.currency)) {
|
if (this.isFiatCurrency(data.currency)) {
|
||||||
if (!data.allow_fiat) {
|
// A fiat-priced event settles in its price currency; mirror it so
|
||||||
|
// the payload (and the tickets_fiat_currency tag) stay coherent.
|
||||||
|
data.fiat_currency = data.currency
|
||||||
|
} else if (!data.allow_fiat) {
|
||||||
data.fiat_currency = 'GBP'
|
data.fiat_currency = 'GBP'
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
this.updateEvent(wallet, data)
|
this.updateEvent(wallet, data)
|
||||||
|
|
@ -453,6 +572,8 @@ window.PageEvents = {
|
||||||
? data.extra.payment_methods
|
? data.extra.payment_methods
|
||||||
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
|
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
|
||||||
},
|
},
|
||||||
|
location: data.location || '',
|
||||||
|
categories: [...(data.categories || [])],
|
||||||
event_start_day: start.day,
|
event_start_day: start.day,
|
||||||
event_start_time: start.time,
|
event_start_time: start.time,
|
||||||
event_end_day: end.day,
|
event_end_day: end.day,
|
||||||
|
|
@ -463,6 +584,8 @@ window.PageEvents = {
|
||||||
currency: 'sats',
|
currency: 'sats',
|
||||||
allow_fiat: false,
|
allow_fiat: false,
|
||||||
fiat_currency: 'GBP',
|
fiat_currency: 'GBP',
|
||||||
|
location: '',
|
||||||
|
categories: [],
|
||||||
event_start_day: '',
|
event_start_day: '',
|
||||||
event_start_time: '',
|
event_start_time: '',
|
||||||
event_end_day: '',
|
event_end_day: '',
|
||||||
|
|
@ -487,6 +610,8 @@ window.PageEvents = {
|
||||||
currency: 'sats',
|
currency: 'sats',
|
||||||
allow_fiat: false,
|
allow_fiat: false,
|
||||||
fiat_currency: 'GBP',
|
fiat_currency: 'GBP',
|
||||||
|
location: '',
|
||||||
|
categories: [],
|
||||||
extra: {
|
extra: {
|
||||||
payment_methods: ['lightning'],
|
payment_methods: ['lightning'],
|
||||||
email_notifications: false,
|
email_notifications: false,
|
||||||
|
|
|
||||||
|
|
@ -479,8 +479,10 @@
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.name"
|
v-model.trim="formDialog.data.name"
|
||||||
type="name"
|
type="text"
|
||||||
label="Title of event "
|
label="Title of event *"
|
||||||
|
lazy-rules
|
||||||
|
:rules="[val => !!val || 'Title is required']"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col q-pl-sm">
|
<div class="col q-pl-sm">
|
||||||
|
|
@ -504,6 +506,30 @@
|
||||||
label="Info about the event"
|
label="Info about the event"
|
||||||
hint="Markdown supported"
|
hint="Markdown supported"
|
||||||
></q-input>
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.location"
|
||||||
|
type="text"
|
||||||
|
label="Location"
|
||||||
|
hint="Venue or address, e.g. Salle des fêtes, Foix. Published as the NIP-52 location tag."
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="place"></q-icon>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="formDialog.data.categories"
|
||||||
|
:options="categoryOptions"
|
||||||
|
label="Categories"
|
||||||
|
hint="Published as NIP-52 hashtags so clients can filter the feed."
|
||||||
|
></q-select>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
@ -512,25 +538,16 @@
|
||||||
label="Image URL"
|
label="Image URL"
|
||||||
hint="Optional banner image to display on the event page"
|
hint="Optional banner image to display on the event page"
|
||||||
></q-input>
|
></q-input>
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<div class="col-4">Ticket closing date</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.closing_date"
|
|
||||||
type="date"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col-4">Event begins</div>
|
<div class="col-4">Event begins *</div>
|
||||||
<div class="col-5">
|
<div class="col-5">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.event_start_day"
|
v-model.trim="formDialog.data.event_start_day"
|
||||||
type="date"
|
type="date"
|
||||||
|
lazy-rules
|
||||||
|
:rules="[val => !!val || 'Start date is required']"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
|
|
@ -552,6 +569,10 @@
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.event_end_day"
|
v-model.trim="formDialog.data.event_end_day"
|
||||||
type="date"
|
type="date"
|
||||||
|
hint="Defaults to the start date"
|
||||||
|
lazy-rules
|
||||||
|
reactive-rules
|
||||||
|
:rules="[validateEndDate]"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
|
|
@ -564,6 +585,18 @@
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col-4">Ticket sales close</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.closing_date"
|
||||||
|
type="date"
|
||||||
|
hint="Optional. Defaults to the event end date."
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<q-select
|
<q-select
|
||||||
|
|
@ -571,7 +604,7 @@
|
||||||
dense
|
dense
|
||||||
v-model="formDialog.data.currency"
|
v-model="formDialog.data.currency"
|
||||||
type="text"
|
type="text"
|
||||||
label="Unit"
|
label="Price currency"
|
||||||
:options="currencies"
|
:options="currencies"
|
||||||
></q-select>
|
></q-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -581,7 +614,9 @@
|
||||||
dense
|
dense
|
||||||
v-model.number="formDialog.data.amount_tickets"
|
v-model.number="formDialog.data.amount_tickets"
|
||||||
type="number"
|
type="number"
|
||||||
label="Amount of tickets "
|
min="0"
|
||||||
|
label="Amount of tickets"
|
||||||
|
hint="0 = unlimited"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
@ -600,34 +635,57 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="q-mt-sm">
|
<div class="q-mt-sm">
|
||||||
<div class="text-caption text-grey-7">Payment methods *</div>
|
<div class="text-subtitle2">Payment methods *</div>
|
||||||
<q-option-group
|
|
||||||
v-model="formDialog.data.extra.payment_methods"
|
|
||||||
type="checkbox"
|
|
||||||
inline
|
|
||||||
:options="paymentMethodOptions"
|
|
||||||
></q-option-group>
|
|
||||||
<div class="text-caption text-grey-7">
|
<div class="text-caption text-grey-7">
|
||||||
Card / fiat checkout goes through the fiat provider configured on
|
Pick the rails buyers can pay with. Untick Lightning for a
|
||||||
this LNbits instance. Untick Lightning for a card-only event.
|
card-only sale.
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-md q-mt-xs">
|
||||||
|
<div
|
||||||
|
v-for="opt in paymentMethodOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
class="col-12 col-sm-6"
|
||||||
|
>
|
||||||
|
<!-- The span carries the tooltip: a disabled checkbox
|
||||||
|
swallows pointer events, the wrapper still hovers. -->
|
||||||
|
<span class="inline-block full-width">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="formDialog.data.extra.payment_methods"
|
||||||
|
:val="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:disable="opt.value === 'fiat' && !hasFiatProvider"
|
||||||
|
></q-checkbox>
|
||||||
|
<div class="text-caption text-grey-7 q-pl-lg">
|
||||||
|
<span v-text="opt.hint"></span>
|
||||||
|
<span
|
||||||
|
v-if="opt.value === 'fiat' && hasFiatProvider"
|
||||||
|
v-text="' (' + fiatProviderNames + ')'"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<q-tooltip
|
||||||
|
v-if="opt.value === 'fiat' && !hasFiatProvider"
|
||||||
|
max-width="280px"
|
||||||
|
>
|
||||||
|
Your LNbits user has no fiat provider configured. Ask the
|
||||||
|
instance admin to enable Stripe, PayPal or Square (Admin →
|
||||||
|
Fiat providers) to accept card payments.
|
||||||
|
</q-tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-select
|
<q-select
|
||||||
v-if="
|
v-if="acceptsFiat && isSatPrice"
|
||||||
acceptsFiat &&
|
|
||||||
['sat', 'sats'].includes(
|
|
||||||
(formDialog.data.currency || '').toLowerCase()
|
|
||||||
)
|
|
||||||
"
|
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="formDialog.data.fiat_currency"
|
v-model="formDialog.data.fiat_currency"
|
||||||
label="Fiat checkout currency"
|
label="Fiat currency"
|
||||||
:options="
|
hint="Currency card buyers are charged in."
|
||||||
currencies.filter(
|
:options="fiatCurrencyOptions"
|
||||||
c => !['sat', 'sats'].includes((c || '').toLowerCase())
|
lazy-rules
|
||||||
)
|
:rules="[
|
||||||
"
|
val => !!val || 'Pick a fiat currency for buyers paying by card'
|
||||||
|
]"
|
||||||
></q-select>
|
></q-select>
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="advanced"
|
group="advanced"
|
||||||
|
|
@ -671,12 +729,22 @@
|
||||||
class="row q-col-gutter-sm q-mt-md"
|
class="row q-col-gutter-sm q-mt-md"
|
||||||
>
|
>
|
||||||
<q-input
|
<q-input
|
||||||
class="col-8"
|
class="col-6"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.extra.promo_codes[index].code"
|
v-model.trim="formDialog.data.extra.promo_codes[index].code"
|
||||||
type="text"
|
type="text"
|
||||||
label="Promo Code"
|
label="Promo Code"
|
||||||
|
:hint="
|
||||||
|
formDialog.data.extra.promo_codes[index].used_count
|
||||||
|
? 'Used ' +
|
||||||
|
formDialog.data.extra.promo_codes[index].used_count +
|
||||||
|
(formDialog.data.extra.promo_codes[index].max_uses
|
||||||
|
? ' / ' +
|
||||||
|
formDialog.data.extra.promo_codes[index].max_uses
|
||||||
|
: '')
|
||||||
|
: ''
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<template v-slot:before>
|
<template v-slot:before>
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
|
|
@ -697,7 +765,7 @@
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
class="col-4"
|
class="col-3"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.number="
|
v-model.number="
|
||||||
|
|
@ -707,6 +775,19 @@
|
||||||
label="Discount (%)"
|
label="Discount (%)"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
class="col-3"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="
|
||||||
|
formDialog.data.extra.promo_codes[index].max_uses
|
||||||
|
"
|
||||||
|
type="number"
|
||||||
|
label="Max uses"
|
||||||
|
placeholder="∞"
|
||||||
|
min="1"
|
||||||
|
hint="Blank = unlimited"
|
||||||
>
|
>
|
||||||
<template v-slot:after>
|
<template v-slot:after>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -725,7 +806,8 @@
|
||||||
formDialog.data.extra.promo_codes.push({
|
formDialog.data.extra.promo_codes.push({
|
||||||
code: '',
|
code: '',
|
||||||
discount_percent: 0,
|
discount_percent: 0,
|
||||||
active: true
|
active: true,
|
||||||
|
max_uses: null
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
>Add Promo Code</q-btn
|
>Add Promo Code</q-btn
|
||||||
|
|
@ -779,14 +861,9 @@
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
:disable="
|
:disable="
|
||||||
formDialog.data.wallet == null ||
|
!formDialog.data.wallet ||
|
||||||
formDialog.data.name == null ||
|
!formDialog.data.name ||
|
||||||
formDialog.data.info == null ||
|
!formDialog.data.event_start_day
|
||||||
formDialog.data.closing_date == null ||
|
|
||||||
formDialog.data.event_start_day == null ||
|
|
||||||
formDialog.data.event_end_day == null ||
|
|
||||||
formDialog.data.amount_tickets == null ||
|
|
||||||
formDialog.data.price_per_ticket == null
|
|
||||||
"
|
"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Create Event</q-btn
|
>Create Event</q-btn
|
||||||
|
|
|
||||||
175
tests/test_promo.py
Normal file
175
tests/test_promo.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from ..models import Event, EventExtra, PromoCode, PublicEvent, Ticket
|
||||||
|
from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses
|
||||||
|
|
||||||
|
|
||||||
|
def _event(currency="sat", price=1000.0, codes=None) -> Event:
|
||||||
|
return Event(
|
||||||
|
id="evt",
|
||||||
|
wallet="w",
|
||||||
|
name="Test",
|
||||||
|
info="",
|
||||||
|
closing_date="2030-01-01",
|
||||||
|
event_start_date="2030-01-01",
|
||||||
|
event_end_date="2030-01-02",
|
||||||
|
currency=currency,
|
||||||
|
price_per_ticket=price,
|
||||||
|
amount_tickets=10,
|
||||||
|
time=datetime.now(timezone.utc),
|
||||||
|
extra=EventExtra(promo_codes=codes or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket(code, paid=True) -> Ticket:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return Ticket(
|
||||||
|
id=f"t-{code}-{paid}",
|
||||||
|
wallet="w",
|
||||||
|
event="evt",
|
||||||
|
registered=False,
|
||||||
|
paid=paid,
|
||||||
|
time=now,
|
||||||
|
reg_timestamp=now,
|
||||||
|
extra={"applied_promo_code": code},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- model -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_is_stripped_and_uppercased():
|
||||||
|
assert PromoCode(code=" half ", discount_percent=50).code == "HALF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_code_is_rejected():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
PromoCode(code=" ", discount_percent=10)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,expected", [(None, None), ("", None), (0, None), ("0", None), (3, 3), ("7", 7)]
|
||||||
|
)
|
||||||
|
def test_max_uses_normalisation(raw, expected):
|
||||||
|
assert PromoCode(code="X", max_uses=raw).max_uses == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_uses_below_one_rejected():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
PromoCode(code="X", max_uses=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_discount_bounds():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
PromoCode(code="X", discount_percent=101)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_event_projection_drops_promo_codes():
|
||||||
|
event = _event(codes=[PromoCode(code="SECRET", discount_percent=100)])
|
||||||
|
public = PublicEvent.parse_obj(event.dict()).dict()
|
||||||
|
assert "promo_codes" not in public["extra"]
|
||||||
|
assert public["extra"]["payment_methods"] == []
|
||||||
|
# the full model keeps them
|
||||||
|
assert Event.parse_obj(event.dict()).extra.promo_codes[0].code == "SECRET"
|
||||||
|
|
||||||
|
|
||||||
|
# --- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_code():
|
||||||
|
assert normalize_code(" save20 ") == "SAVE20"
|
||||||
|
assert normalize_code("") is None
|
||||||
|
assert normalize_code(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_promo_usage_counts_paid_rows_only_per_ticket():
|
||||||
|
usage = promo_usage(
|
||||||
|
[_ticket("HALF"), _ticket("HALF"), _ticket("HALF", paid=False), _ticket(None)]
|
||||||
|
)
|
||||||
|
assert usage == {"HALF": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remaining_uses():
|
||||||
|
assert remaining_uses(PromoCode(code="X"), 5) is None
|
||||||
|
assert remaining_uses(PromoCode(code="X", max_uses=3), 1) == 2
|
||||||
|
assert remaining_uses(PromoCode(code="X", max_uses=3), 9) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- basket_totals -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sat_totals_round_to_whole_sats():
|
||||||
|
event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)])
|
||||||
|
totals = basket_totals(event, ["off15"], 1, {})
|
||||||
|
assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50)
|
||||||
|
assert totals.currency == "sat"
|
||||||
|
assert totals.discounts_applied[0].dict() == {
|
||||||
|
"code": "OFF15",
|
||||||
|
"discount_percent": 15,
|
||||||
|
"discount_fixed": None,
|
||||||
|
"amount_saved": 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiat_totals_round_to_cents_and_scale_by_quantity():
|
||||||
|
event = _event(
|
||||||
|
currency="EUR",
|
||||||
|
price=19.99,
|
||||||
|
codes=[PromoCode(code="THIRD", discount_percent=33)],
|
||||||
|
)
|
||||||
|
totals = basket_totals(event, ["THIRD"], 3, {})
|
||||||
|
assert totals.subtotal == 59.97
|
||||||
|
assert totals.total == 40.18
|
||||||
|
assert totals.discount == 19.79
|
||||||
|
assert totals.discount + totals.total == totals.subtotal
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_applicable_code_wins():
|
||||||
|
event = _event(
|
||||||
|
codes=[
|
||||||
|
PromoCode(code="A", discount_percent=10),
|
||||||
|
PromoCode(code="B", discount_percent=50),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
basket_totals(event, ["NOPE", "B", "A"], 1, {}).discounts_applied[0].code == "B"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactive_unknown_zero_and_exhausted_codes_are_absent():
|
||||||
|
event = _event(
|
||||||
|
codes=[
|
||||||
|
PromoCode(code="OLD", discount_percent=20, active=False),
|
||||||
|
PromoCode(code="ZERO", discount_percent=0),
|
||||||
|
PromoCode(code="TWO", discount_percent=50, max_uses=2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for codes, usage, qty in (
|
||||||
|
(["OLD"], {}, 1),
|
||||||
|
(["ZERO"], {}, 1),
|
||||||
|
(["NOPE"], {}, 1),
|
||||||
|
(["TWO"], {"TWO": 2}, 1),
|
||||||
|
(["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity
|
||||||
|
):
|
||||||
|
totals = basket_totals(event, codes, qty, usage)
|
||||||
|
assert totals.discounts_applied == []
|
||||||
|
assert totals.total == totals.subtotal and totals.discount == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlimited_and_partially_used_codes_apply():
|
||||||
|
event = _event(
|
||||||
|
codes=[
|
||||||
|
PromoCode(code="TWO", discount_percent=50, max_uses=2),
|
||||||
|
PromoCode(code="INF", discount_percent=10),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}).total == 500
|
||||||
|
assert basket_totals(event, ["INF"], 10, {"INF": 999}).total == 9000
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_discount_prices_to_zero():
|
||||||
|
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
|
||||||
|
assert basket_totals(event, ["FREE"], 2, {}).total == 0
|
||||||
178
tests/test_promo_api.py
Normal file
178
tests/test_promo_api.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .. import views_api
|
||||||
|
from ..models import CreateTicket, Event, EventExtra, PromoCode, PromoValidateRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _event(codes) -> Event:
|
||||||
|
return Event(
|
||||||
|
id="evt",
|
||||||
|
wallet="w",
|
||||||
|
name="Test",
|
||||||
|
info="",
|
||||||
|
closing_date="2030-01-01",
|
||||||
|
event_start_date="2030-01-01",
|
||||||
|
event_end_date="2030-01-02",
|
||||||
|
currency="sat",
|
||||||
|
price_per_ticket=1000,
|
||||||
|
amount_tickets=10,
|
||||||
|
time=datetime.now(timezone.utc),
|
||||||
|
extra=EventExtra(promo_codes=codes),
|
||||||
|
status="approved",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event(monkeypatch):
|
||||||
|
ev = _event(
|
||||||
|
[
|
||||||
|
PromoCode(code="HALF", discount_percent=50, max_uses=2),
|
||||||
|
PromoCode(code="OLD", discount_percent=20, active=False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=ev))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 1})
|
||||||
|
)
|
||||||
|
return ev
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_returns_v2_shaped_totals(event):
|
||||||
|
totals = await views_api.api_validate_promo_codes(
|
||||||
|
"evt", PromoValidateRequest(codes=["half"], quantity=1)
|
||||||
|
)
|
||||||
|
assert totals.dict() == {
|
||||||
|
"subtotal": 1000,
|
||||||
|
"discount": 500,
|
||||||
|
"total": 500,
|
||||||
|
"currency": "sat",
|
||||||
|
"discounts_applied": [
|
||||||
|
{
|
||||||
|
"code": "HALF",
|
||||||
|
"discount_percent": 50,
|
||||||
|
"discount_fixed": None,
|
||||||
|
"amount_saved": 500,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_is_advisory_for_bad_codes(event):
|
||||||
|
for codes, qty in ((["OLD"], 1), (["NOPE"], 1), (["HALF"], 2)):
|
||||||
|
totals = await views_api.api_validate_promo_codes(
|
||||||
|
"evt", PromoValidateRequest(codes=codes, quantity=qty)
|
||||||
|
)
|
||||||
|
assert totals.discounts_applied == [] and totals.total == totals.subtotal
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_unknown_event_is_404(monkeypatch):
|
||||||
|
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=None))
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await views_api.api_validate_promo_codes(
|
||||||
|
"nope", PromoValidateRequest(codes=["X"])
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"code,quantity,detail",
|
||||||
|
[
|
||||||
|
("NOPE", 1, "Invalid promo code."),
|
||||||
|
("old", 1, "Promo code is not active."),
|
||||||
|
("HALF", 2, "Only 1 use(s) left on this promo code."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_purchase_rejects_bad_codes_before_any_invoice(
|
||||||
|
event, monkeypatch, code, quantity, detail
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
views_api,
|
||||||
|
"create_payment_request",
|
||||||
|
AsyncMock(side_effect=AssertionError("must not be called")),
|
||||||
|
)
|
||||||
|
data = CreateTicket(user_id="u1", promo_code=code, quantity=quantity)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await views_api.api_ticket_create(
|
||||||
|
"evt", data, SimpleNamespace(base_url="http://lnbits.local/")
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert exc.value.detail == detail
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_reports_fully_redeemed(event, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 2})
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
views_api,
|
||||||
|
"create_payment_request",
|
||||||
|
AsyncMock(side_effect=AssertionError("must not be called")),
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await views_api.api_ticket_create(
|
||||||
|
"evt",
|
||||||
|
CreateTicket(user_id="u1", promo_code="HALF"),
|
||||||
|
SimpleNamespace(base_url="http://lnbits.local/"),
|
||||||
|
)
|
||||||
|
assert exc.value.detail == "Promo code has been fully redeemed."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def update_env(monkeypatch):
|
||||||
|
stored = _event([PromoCode(code="KEEP", discount_percent=10)])
|
||||||
|
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=stored))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
views_api,
|
||||||
|
"get_settings",
|
||||||
|
AsyncMock(return_value=SimpleNamespace(auto_approve=True)),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(views_api, "update_event", AsyncMock(side_effect=lambda e: e))
|
||||||
|
monkeypatch.setattr(views_api, "publish_or_delete_nostr_event", AsyncMock())
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def _update_payload(extra: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"wallet": "w",
|
||||||
|
"name": "Test",
|
||||||
|
"info": "",
|
||||||
|
"closing_date": "2030-01-01",
|
||||||
|
"event_start_date": "2030-01-01",
|
||||||
|
"event_end_date": "2030-01-02",
|
||||||
|
"amount_tickets": 10,
|
||||||
|
"price_per_ticket": 1000,
|
||||||
|
"extra": extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wallet():
|
||||||
|
return SimpleNamespace(wallet=SimpleNamespace(id="w", user="u1"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_without_promo_key_keeps_stored_codes(update_env):
|
||||||
|
from ..models import CreateEvent
|
||||||
|
|
||||||
|
data = CreateEvent.parse_obj(_update_payload({"email_notifications": True}))
|
||||||
|
event = await views_api.api_event_update("evt", data, _wallet())
|
||||||
|
assert [pc.code for pc in event.extra.promo_codes] == ["KEEP"]
|
||||||
|
assert event.extra.email_notifications is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_with_empty_promo_list_clears(update_env):
|
||||||
|
from ..models import CreateEvent
|
||||||
|
|
||||||
|
data = CreateEvent.parse_obj(_update_payload({"promo_codes": []}))
|
||||||
|
event = await views_api.api_event_update("evt", data, _wallet())
|
||||||
|
assert event.extra.promo_codes == []
|
||||||
86
views_api.py
86
views_api.py
|
|
@ -59,10 +59,12 @@ from .crud import (
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
|
BasketTotals,
|
||||||
CreateEvent,
|
CreateEvent,
|
||||||
CreateTicket,
|
CreateTicket,
|
||||||
Event,
|
Event,
|
||||||
EventsSettings,
|
EventsSettings,
|
||||||
|
PromoValidateRequest,
|
||||||
PublicEvent,
|
PublicEvent,
|
||||||
PublicTicket,
|
PublicTicket,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
|
@ -71,6 +73,13 @@ from .models import (
|
||||||
effective_payment_methods,
|
effective_payment_methods,
|
||||||
)
|
)
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
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 (
|
from .qr import (
|
||||||
image_png_bytes,
|
image_png_bytes,
|
||||||
load_qr_logo,
|
load_qr_logo,
|
||||||
|
|
@ -79,6 +88,8 @@ from .qr import (
|
||||||
ticket_card_filename,
|
ticket_card_filename,
|
||||||
)
|
)
|
||||||
from .services import (
|
from .services import (
|
||||||
|
event_promo_usage,
|
||||||
|
hydrate_promo_usage,
|
||||||
refund_tickets,
|
refund_tickets,
|
||||||
resend_ticket_email_notification,
|
resend_ticket_email_notification,
|
||||||
send_ticket_notification_in_background,
|
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")
|
events_api_router = APIRouter(prefix="/api/v1/events")
|
||||||
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||||
qr_api_router = APIRouter(prefix="/api/v1")
|
qr_api_router = APIRouter(prefix="/api/v1")
|
||||||
|
promo_api_router = APIRouter(prefix="/api/v1/promo")
|
||||||
|
|
||||||
|
|
||||||
def _is_fiat_currency(currency: str | None) -> bool:
|
def _is_fiat_currency(currency: str | None) -> bool:
|
||||||
|
|
@ -108,12 +120,17 @@ async def api_events(
|
||||||
if all_wallets:
|
if all_wallets:
|
||||||
user = await get_user(wallet.wallet.user)
|
user = await get_user(wallet.wallet.user)
|
||||||
wallet_ids = user.wallet_ids if user else []
|
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]:
|
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()
|
return await get_public_events()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -131,6 +148,7 @@ async def api_events_all(
|
||||||
events = await get_all_events()
|
events = await get_all_events()
|
||||||
enriched: list[dict] = []
|
enriched: list[dict] = []
|
||||||
for event in events:
|
for event in events:
|
||||||
|
await hydrate_promo_usage(event)
|
||||||
wallet = await get_wallet(event.wallet)
|
wallet = await get_wallet(event.wallet)
|
||||||
row = event.dict()
|
row = event.dict()
|
||||||
row["wallet_user_id"] = wallet.user if wallet else None
|
row["wallet_user_id"] = wallet.user if wallet else None
|
||||||
|
|
@ -353,6 +371,13 @@ async def api_event_update(
|
||||||
if not data.closing_date:
|
if not data.closing_date:
|
||||||
data.closing_date = data.event_end_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.
|
# Explicit field list — never copy `status` from the request body.
|
||||||
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
|
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
|
||||||
# owner editing a fiat-enabled event keeps the fiat config.
|
# owner editing a fiat-enabled event keeps the fiat config.
|
||||||
|
|
@ -660,7 +685,7 @@ async def api_ticket_create(
|
||||||
name = data.name
|
name = data.name
|
||||||
email = data.email
|
email = data.email
|
||||||
user_id = data.user_id
|
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
|
refund_address = data.refund_address
|
||||||
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
|
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
|
||||||
payment_method = (data.payment_method or "lightning").lower()
|
payment_method = (data.payment_method or "lightning").lower()
|
||||||
|
|
@ -677,22 +702,39 @@ async def api_ticket_create(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Invalid Nostr identifier.",
|
detail="Invalid Nostr identifier.",
|
||||||
) from exc
|
) from exc
|
||||||
unit_price = event.price_per_ticket
|
|
||||||
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
||||||
frontend_root = _resolve_frontend_root(data, request)
|
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:
|
if promo_code:
|
||||||
# check if promo_code exists in event.extra.promo_codes
|
promo = find_promo(event, promo_code)
|
||||||
if promo_code not in [pc.code for pc in event.extra.promo_codes]:
|
if not promo:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
|
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
|
||||||
)
|
)
|
||||||
# get the promocode
|
if not promo.active:
|
||||||
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
|
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
|
extra["promo_code"] = promo.code
|
||||||
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100)
|
price = basket_totals(event, [promo.code], quantity, usage).total
|
||||||
# Scale by quantity AFTER the promo applies. One invoice, N tickets.
|
else:
|
||||||
price = unit_price * quantity
|
price = round_amount(event.price_per_ticket * quantity, event.currency)
|
||||||
|
|
||||||
# Free tickets (final charge 0 — a free event or a 100%-off promo).
|
# Free tickets (final charge 0 — a free event or a 100%-off promo).
|
||||||
# Short-circuit before any invoice / fiat-provider logic: no Lightning
|
# Short-circuit before any invoice / fiat-provider logic: no Lightning
|
||||||
|
|
@ -784,6 +826,7 @@ async def api_ticket_create(
|
||||||
"event_id": event.id,
|
"event_id": event.id,
|
||||||
"quantity": str(quantity),
|
"quantity": str(quantity),
|
||||||
"ticket_ids": ",".join(ticket_ids),
|
"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": (
|
"registered_at": (
|
||||||
t.reg_timestamp.isoformat() if t.reg_timestamp else None
|
t.reg_timestamp.isoformat() if t.reg_timestamp else None
|
||||||
),
|
),
|
||||||
|
"applied_promo_code": t.extra.applied_promo_code,
|
||||||
}
|
}
|
||||||
for t in paid_tickets
|
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",
|
"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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue