feat(events): ticket-wave types and arithmetic

Foundation for wave support. Types only plus a pure lib — no UI yet.

Since events ext v1.6.8 price, currency and stock belong to a ticket
WAVE, and the event-level fields are derived: `amount_tickets` is the sum
across waves, `price_per_ticket` and `currency` are the FIRST wave's.
Reading them to price or count anything is now a bug, which is what the
webapp does today.

`lib/ticketWaves.ts` mirrors the extension's `ensure_ticket_waves`,
`get_active_ticket_waves`, `advertised_ticket_wave` and
`sync_event_ticket_waves`, plus the `_resolve_ticket_wave` selection rule
and the primary-wave write-through the LNbits admin dialog performs.

Every function was cross-checked against the running Python with shared
fixtures rather than written from the docs, which caught one divergence
worth recording: the backend keeps the time component on a synthesized
primary wave's `closing_date`, and `sync_event_ticket_waves` takes a
STRING max over those. Truncating to a date — the obvious-looking
thing, and what this first did — makes the client derive a different
event closing_date than the server. Truncation belongs at comparison
time, in `waveDay`, mirroring the backend's `_parse_date`.

`ticket_wave_id` added to CreateTicketRequest and PromoValidateRequest;
`ticket_waves` to EventExtra. The latter is public from events ext
v1.6.8-aio.2 (aiolabs/events#66) — without that a buyer cannot learn a
wave id at all.

19 tests. vue-tsc and prettier clean; 59 events tests pass.
This commit is contained in:
Padreug 2026-09-29 08:19:33 +02:00
commit 8490cdf84c
3 changed files with 608 additions and 116 deletions

View file

@ -0,0 +1,228 @@
import { describe, it, expect } from "vitest";
import {
activeTicketWaves,
advertisedTicketWave,
applyFormToPrimaryWave,
ensureTicketWaves,
isWaveActive,
resolveTicketWave,
syncEventFromWaves,
waveDay,
} from "./ticketWaves";
import type { TicketWave } from "../types/ticket";
const TODAY = "2026-06-15";
function wave(overrides: Partial<TicketWave> & { id: string }): TicketWave {
return {
title: overrides.id,
opening_date: "2026-06-01",
closing_date: "2026-06-30",
currency: "sat",
amount_tickets: 10,
price_per_ticket: 100,
...overrides,
};
}
const EARLY = wave({
id: "early",
price_per_ticket: 10,
amount_tickets: 5,
closing_date: "2026-06-10",
});
const REGULAR = wave({
id: "regular",
price_per_ticket: 25,
amount_tickets: 40,
});
const VIP = wave({
id: "vip",
price_per_ticket: 50,
opening_date: "2026-07-01",
closing_date: "2026-07-30",
});
const eventWith = (waves: TicketWave[]) => ({ extra: { ticket_waves: waves } });
describe("waveDay", () => {
it("takes the date component, tolerating the datetimes our closing dates carry", () => {
expect(waveDay("2026-06-15")).toBe("2026-06-15");
expect(waveDay("2026-06-15T18:30:00")).toBe("2026-06-15");
expect(waveDay(null)).toBe("");
});
});
describe("ensureTicketWaves", () => {
it("returns stored waves untouched", () => {
expect(
ensureTicketWaves(eventWith([EARLY, REGULAR])).map((w) => w.id),
).toEqual(["early", "regular"]);
});
it("synthesizes a primary wave for events from before waves existed", () => {
const [primary] = ensureTicketWaves({
closing_date: "2026-06-30T20:00:00",
currency: "EUR",
amount_tickets: 40,
price_per_ticket: 12.5,
});
expect(primary.id).toBe("primary");
expect(primary.amount_tickets).toBe(40);
expect(primary.price_per_ticket).toBe(12.5);
// Verified against the backend: it keeps the time component here, and
// `syncEventFromWaves` maxes these as strings, so truncating would make
// the client derive a different event closing_date than the server.
expect(primary.closing_date).toBe("2026-06-30T20:00:00");
expect(primary.opening_date).toBe("2026-06-30T20:00:00");
});
it("opens on the creation day when the record carries one", () => {
const [primary] = ensureTicketWaves({
time: "2026-05-01T09:00:00",
closing_date: "2026-06-30T20:00:00",
amount_tickets: 5,
});
expect(primary.opening_date).toBe("2026-05-01");
});
});
describe("isWaveActive", () => {
it("is inclusive of both boundary days", () => {
expect(
isWaveActive(
wave({ id: "a", opening_date: TODAY, closing_date: TODAY }),
TODAY,
),
).toBe(true);
});
it("is false before it opens, after it closes, and when sold out", () => {
expect(isWaveActive(VIP, TODAY)).toBe(false);
expect(isWaveActive(EARLY, TODAY)).toBe(false);
expect(isWaveActive(wave({ id: "gone", amount_tickets: 0 }), TODAY)).toBe(
false,
);
});
});
describe("activeTicketWaves", () => {
it("returns every open wave, closed and unopened ones excluded", () => {
const cheaper = wave({ id: "cheap", price_per_ticket: 15 });
expect(
activeTicketWaves(eventWith([EARLY, REGULAR, VIP, cheaper]), TODAY).map(
(w) => w.id,
),
).toEqual(["regular", "cheap"]);
});
});
describe("advertisedTicketWave", () => {
it("ignores a closed wave even when it is the primary and the cheapest", () => {
const advertised = advertisedTicketWave(
eventWith([EARLY, REGULAR, VIP]),
TODAY,
);
expect(advertised?.id).toBe("regular");
});
it("shows the cheapest open wave when several overlap", () => {
const cheaper = wave({
id: "cheap",
price_per_ticket: 15,
amount_tickets: 3,
});
expect(advertisedTicketWave(eventWith([REGULAR, cheaper]), TODAY)?.id).toBe(
"cheap",
);
});
it("is null when nothing is on sale", () => {
expect(advertisedTicketWave(eventWith([EARLY]), TODAY)).toBeNull();
expect(advertisedTicketWave(eventWith([VIP]), TODAY)).toBeNull();
});
});
describe("resolveTicketWave", () => {
it("implies a single open wave", () => {
const result = resolveTicketWave(
eventWith([EARLY, REGULAR]),
undefined,
TODAY,
);
expect(result).toEqual({ ok: true, wave: REGULAR });
});
it("refuses to guess between several open waves", () => {
const cheaper = wave({ id: "cheap", price_per_ticket: 15 });
expect(
resolveTicketWave(eventWith([REGULAR, cheaper]), undefined, TODAY),
).toEqual({ ok: false, reason: "must-choose" });
});
it("accepts an explicit open wave and rejects a closed one", () => {
expect(
resolveTicketWave(eventWith([EARLY, REGULAR]), "regular", TODAY),
).toEqual({ ok: true, wave: REGULAR });
expect(
resolveTicketWave(eventWith([EARLY, REGULAR]), "early", TODAY),
).toEqual({ ok: false, reason: "not-open" });
});
it("reports when nothing is open at all", () => {
expect(resolveTicketWave(eventWith([EARLY]), undefined, TODAY)).toEqual({
ok: false,
reason: "none-open",
});
});
});
describe("syncEventFromWaves", () => {
it("derives the event-level fields the way the backend does", () => {
const synced = syncEventFromWaves(eventWith([EARLY, REGULAR, VIP]));
expect(synced.amount_tickets).toBe(55); // sum of every wave
expect(synced.price_per_ticket).toBe(10); // the FIRST wave, not the cheapest open one
expect(synced.closing_date).toBe("2026-07-30"); // the latest wave's
});
});
describe("applyFormToPrimaryWave", () => {
it("writes the form values into the primary wave", () => {
const [primary, ...rest] = applyFormToPrimaryWave([EARLY, REGULAR], {
amount_tickets: 99,
price_per_ticket: 42,
currency: "EUR",
});
expect(primary.amount_tickets).toBe(99);
expect(primary.price_per_ticket).toBe(42);
expect(primary.currency).toBe("EUR");
expect(rest.map((w) => w.id)).toEqual(["regular"]);
});
it("is what stops an organizer edit being silently discarded", () => {
// Without the write-through the wave keeps its old number and the
// backend recomputes amount_tickets straight back to it.
const untouched = syncEventFromWaves(eventWith([EARLY, REGULAR]));
expect(untouched.amount_tickets).toBe(45);
const applied = applyFormToPrimaryWave([EARLY, REGULAR], {
amount_tickets: 99,
});
expect(syncEventFromWaves(eventWith(applied)).amount_tickets).toBe(139);
});
it("does not mutate the caller’s waves", () => {
const waves = [EARLY, REGULAR];
applyFormToPrimaryWave(waves, { amount_tickets: 1 });
expect(waves[0].amount_tickets).toBe(5);
});
it("creates a primary wave when the event has none", () => {
const [primary] = applyFormToPrimaryWave([], {
amount_tickets: 7,
price_per_ticket: 3,
});
expect(primary.id).toBe("primary");
expect(primary.amount_tickets).toBe(7);
});
});

View file

@ -0,0 +1,228 @@
import type { TicketWave } from "../types/ticket";
/**
* Ticket-wave arithmetic, mirroring the events extension so the webapp
* cannot disagree with the backend about which wave is open, what it
* costs, or what the event-level numbers should be.
*
* Backend counterparts live in the extension's `models.py`:
* `ensure_ticket_waves`, `get_active_ticket_waves`,
* `advertised_ticket_wave`, `sync_event_ticket_waves`.
*
* The one rule behind all of it: since v1.6.8 price, currency and stock
* belong to a WAVE. The event-level fields are derived — `amount_tickets`
* is the sum across waves, `price_per_ticket` and `currency` are the
* FIRST wave's — so reading them to price or count anything is a bug.
*/
/** Shape this module needs from an event; both the REST and form models fit. */
export interface WaveBearingEvent {
/** Creation timestamp; present on organizer records, absent on public ones. */
time?: string | number | null;
closing_date?: string | null;
event_start_date?: string;
event_end_date?: string | null;
currency?: string;
allow_fiat?: boolean;
fiat_currency?: string;
amount_tickets?: number;
price_per_ticket?: number;
extra?: { ticket_waves?: TicketWave[] } | null;
}
/**
* Date component of an ISO value.
*
* Mirrors the backend's `_parse_date`, which takes the first ten
* characters rather than parsing: a fork event's `closing_date` may carry
* a time (it defaults from `event_end_date`), and a bare date-only parse
* throws on those.
*/
export function waveDay(value: string | null | undefined): string {
return (value ?? "").slice(0, 10);
}
/** Today in the same YYYY-MM-DD form waves are compared in. */
export function todayKey(now: Date = new Date()): string {
return now.toISOString().slice(0, 10);
}
/**
* The event's waves, synthesizing a primary one when it has none.
*
* Events created before waves existed carry no list, and the backend
* gives them an implied single wave seeded from the event-level fields
* rather than treating them as unsellable.
*/
export function ensureTicketWaves(event: WaveBearingEvent): TicketWave[] {
const stored = event.extra?.ticket_waves ?? [];
if (stored.length > 0) return stored;
// Mirrors the backend byte for byte, INCLUDING leaving any time
// component on `closing_date`. Stripping it here looks tidier and is
// wrong: `syncEventFromWaves` takes a string max over wave closing
// dates, so a truncated value makes the client derive a different event
// closing date than the server does. Truncation belongs in `waveDay`, at
// comparison time, which is where the backend's `_parse_date` does it.
const closing =
event.closing_date || event.event_end_date || event.event_start_date || "";
// Opening falls back to the event's creation day when there is one
// (organizer records), else to the closing value — `getattr(event,
// "time", None)` in the backend, which public events don't carry.
const opening = event.time != null ? waveDay(String(event.time)) : closing;
return [
{
id: "primary",
title: "Primary wave",
opening_date: opening,
closing_date: closing,
currency: event.currency ?? "sat",
allow_fiat: event.allow_fiat ?? false,
fiat_currency: event.fiat_currency ?? "GBP",
amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0,
},
];
}
/** A wave is open when today falls inside its window and it has stock left. */
export function isWaveActive(
wave: TicketWave,
today: string = todayKey(),
): boolean {
return (
waveDay(wave.opening_date) <= today &&
today <= waveDay(wave.closing_date) &&
wave.amount_tickets > 0
);
}
/** Every wave a buyer could currently buy from. */
export function activeTicketWaves(
event: WaveBearingEvent,
today: string = todayKey(),
): TicketWave[] {
return ensureTicketWaves(event).filter((wave) => isWaveActive(wave, today));
}
/**
* The wave a listing should describe, or null when nothing is on sale.
*
* Several waves can be open at once and a card cannot ask the reader which
* tier they want, so it shows the CHEAPEST — the price a buyer is actually
* able to obtain. Same rule the backend publishes to Nostr.
*/
export function advertisedTicketWave(
event: WaveBearingEvent,
today: string = todayKey(),
): TicketWave | null {
const active = activeTicketWaves(event, today);
if (active.length === 0) return null;
return active.reduce((cheapest, wave) =>
wave.price_per_ticket < cheapest.price_per_ticket ? wave : cheapest,
);
}
/**
* The wave a purchase should use, given the buyer's choice.
*
* Mirrors `_resolve_ticket_wave`: an explicit id must name an OPEN wave, a
* single open wave is implied, and an ambiguous choice is an error rather
* than a silent pick. Returning the reason lets the dialog show a picker
* instead of firing a request the backend will refuse.
*/
export type WaveResolution =
| { ok: true; wave: TicketWave }
| { ok: false; reason: "none-open" | "must-choose" | "not-open" };
export function resolveTicketWave(
event: WaveBearingEvent,
ticketWaveId?: string | null,
today: string = todayKey(),
): WaveResolution {
const active = activeTicketWaves(event, today);
if (active.length === 0) return { ok: false, reason: "none-open" };
if (ticketWaveId) {
const wave = active.find((w) => w.id === ticketWaveId);
return wave ? { ok: true, wave } : { ok: false, reason: "not-open" };
}
if (active.length === 1) return { ok: true, wave: active[0] };
return { ok: false, reason: "must-choose" };
}
/**
* Event-level fields recomputed from the waves.
*
* Mirrors `sync_event_ticket_waves`, which runs on every backend read and
* write. The webapp needs it to show an organizer what their edit will
* actually produce, rather than echoing numbers the server is about to
* overwrite.
*/
export type SyncedEvent<T> = T & {
closing_date: string;
currency: string;
allow_fiat: boolean;
fiat_currency: string;
amount_tickets: number;
price_per_ticket: number;
extra: { ticket_waves: TicketWave[] };
};
export function syncEventFromWaves<T extends WaveBearingEvent>(
event: T,
): SyncedEvent<T> {
const waves = ensureTicketWaves(event);
const primary = waves[0];
return {
...event,
closing_date: waves
.map((wave) => wave.closing_date)
.reduce((latest, date) => (date > latest ? date : latest)),
currency: primary.currency,
allow_fiat: primary.allow_fiat ?? false,
fiat_currency: primary.fiat_currency ?? "GBP",
amount_tickets: waves.reduce((sum, wave) => sum + wave.amount_tickets, 0),
price_per_ticket: primary.price_per_ticket,
extra: { ...(event.extra ?? {}), ticket_waves: waves },
};
}
/**
* Push the top-level form values into the primary wave before saving.
*
* Without this an organizer's capacity or price edit is silently
* discarded: the backend derives those fields FROM the waves on every
* write, so submitting a changed `amount_tickets` alongside an unchanged
* wave list leaves the wave's number winning. The LNbits admin dialog does
* the same thing (`syncPrimaryWaveFromForm`).
*/
export function applyFormToPrimaryWave(
waves: TicketWave[],
values: {
closing_date?: string | null;
currency?: string;
allow_fiat?: boolean;
fiat_currency?: string;
amount_tickets?: number;
price_per_ticket?: number;
},
): TicketWave[] {
const next =
waves.length > 0 ? [...waves] : ensureTicketWaves({ extra: null });
const primary = { ...next[0] };
if (values.closing_date != null)
primary.closing_date = waveDay(values.closing_date);
if (values.currency != null) primary.currency = values.currency;
if (values.allow_fiat != null) primary.allow_fiat = values.allow_fiat;
if (values.fiat_currency != null)
primary.fiat_currency = values.fiat_currency;
if (values.amount_tickets != null)
primary.amount_tickets = values.amount_tickets;
if (values.price_per_ticket != null)
primary.price_per_ticket = values.price_per_ticket;
next[0] = primary;
return next;
}

View file

@ -7,41 +7,45 @@
*/ */
export interface PromoCode { export interface PromoCode {
code: string code: string;
discount_percent: number discount_percent: number;
active: boolean active: boolean;
/** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12, /** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12,
* same name as upstream v2). */ * same name as upstream v2). */
max_uses?: number | null max_uses?: number | null;
/** Paid tickets that used this code — derived by the backend on read, /** Paid tickets that used this code — derived by the backend on read,
* never sent back. */ * never sent back. */
used_count?: number used_count?: number;
} }
/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream /** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream
* v2 shape; `quantity` stands in for v2's `items` since this fork has * v2 shape; `quantity` stands in for v2's `items` since this fork has
* no ticket types). */ * no ticket types). */
export interface PromoValidateRequest { export interface PromoValidateRequest {
codes: string[] codes: string[];
quantity: number quantity: number;
/** Which wave to price against. Omit only when a single wave is open —
* the preview must name the same wave the purchase will, or the quote
* and the charge disagree. */
ticket_wave_id?: string;
} }
export interface BasketDiscount { export interface BasketDiscount {
code: string code: string;
discount_percent: number | null discount_percent: number | null;
/** Always null on this fork (percent-only); kept for the v2 shape. */ /** Always null on this fork (percent-only); kept for the v2 shape. */
discount_fixed: number | null discount_fixed: number | null;
amount_saved: number amount_saved: number;
} }
/** Response of the promo validate endpoint. An empty `discounts_applied` /** Response of the promo validate endpoint. An empty `discounts_applied`
* means none of the submitted codes is valid for this event/quantity. */ * means none of the submitted codes is valid for this event/quantity. */
export interface BasketTotals { export interface BasketTotals {
subtotal: number subtotal: number;
discount: number discount: number;
total: number total: number;
discounts_applied: BasketDiscount[] discounts_applied: BasketDiscount[];
currency: string currency: string;
} }
/** /**
@ -50,73 +54,101 @@ export interface BasketTotals {
* and the per-event notification toggles + custom subject/body added * and the per-event notification toggles + custom subject/body added
* in upstream v1.4.0 (PR #50) and v1.6.0. * in upstream v1.4.0 (PR #50) and v1.6.0.
*/ */
/**
* A time-boxed pricing tier (events ext >= v1.6.8).
*
* Price, currency, stock and the fiat flags belong to the wave, not the
* event: `sync_event_ticket_waves` makes the event-level fields derived —
* `amount_tickets` is the sum across waves, `price_per_ticket` and
* `currency` are the FIRST wave's. Never price or count from the event.
*/
export interface TicketWave {
id: string;
title: string;
/** YYYY-MM-DD. Day granularity — a wave opens and closes on whole days. */
opening_date: string;
closing_date: string;
currency: string;
use_ticket_image?: boolean;
ticket_image_id?: string | null;
allow_fiat?: boolean;
fiat_currency?: string;
/** Remaining stock in this wave, decremented per sale. */
amount_tickets: number;
price_per_ticket: number;
}
export interface EventExtra { export interface EventExtra {
/** Present on organizer-authenticated records only: public event /** Present on organizer-authenticated records only: public event
* responses (`/events/public`, `/events/{id}`) no longer carry the * responses (`/events/public`, `/events/{id}`) no longer carry the
* code list (events ext >= v1.6.1-aio.12). */ * code list (events ext >= v1.6.1-aio.12). */
promo_codes?: PromoCode[] promo_codes?: PromoCode[];
conditional: boolean conditional: boolean;
min_tickets: number min_tickets: number;
email_notifications: boolean email_notifications: boolean;
nostr_notifications: boolean nostr_notifications: boolean;
notification_subject: string notification_subject: string;
notification_body: string notification_body: string;
/** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent /** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent
* means the legacy rule: Lightning always, fiat iff `allow_fiat`. * means the legacy rule: Lightning always, fiat iff `allow_fiat`.
* Same shape as upstream v2's `extra.payment_methods`. */ * Same shape as upstream v2's `extra.payment_methods`. */
payment_methods?: string[] payment_methods?: string[];
/** Pricing tiers. Present on public responses too (events ext
* >= v1.6.8-aio.2) so a buyer can pick one. Absent on events from
* before waves existed — treat that as a single implied wave. */
ticket_waves?: TicketWave[];
} }
export interface EventTicketExtra { export interface EventTicketExtra {
applied_promo_code?: string | null applied_promo_code?: string | null;
sats_paid?: number | null sats_paid?: number | null;
refund_address?: string | null refund_address?: string | null;
nostr_identifier?: string | null nostr_identifier?: string | null;
ticket_base_url?: string | null ticket_base_url?: string | null;
email_notification_sent: boolean email_notification_sent: boolean;
nostr_notification_sent: boolean nostr_notification_sent: boolean;
refunded: boolean refunded: boolean;
} }
export interface EventTicket { export interface EventTicket {
id: string id: string;
wallet: string wallet: string;
/** Reference to the event (LNbits event ID) */ /** Reference to the event (LNbits event ID) */
eventId: string eventId: string;
/** Ticket holder name */ /** Ticket holder name */
name: string | null name: string | null;
/** Ticket holder email */ /** Ticket holder email */
email: string | null email: string | null;
/** Nostr pubkey or LNbits user ID */ /** Nostr pubkey or LNbits user ID */
userId: string | null userId: string | null;
/** Whether ticket has been scanned/registered at the door */ /** Whether ticket has been scanned/registered at the door */
registered: boolean registered: boolean;
/** Whether payment has been confirmed */ /** Whether payment has been confirmed */
paid: boolean paid: boolean;
/** Ticket creation timestamp */ /** Ticket creation timestamp */
time: string time: string;
/** Registration/scan timestamp */ /** Registration/scan timestamp */
regTimestamp: string regTimestamp: string;
/** Optional metadata — promo code applied, sats paid, notification /** Optional metadata — promo code applied, sats paid, notification
* delivery flags, refund state. May be absent on older tickets. */ * delivery flags, refund state. May be absent on older tickets. */
extra?: EventTicketExtra extra?: EventTicketExtra;
} }
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled' export type TicketStatus = "pending" | "paid" | "registered" | "cancelled";
export type PaymentMethod = 'lightning' | 'fiat' export type PaymentMethod = "lightning" | "fiat";
export interface TicketPurchaseRequest { export interface TicketPurchaseRequest {
eventId: string eventId: string;
userId: string userId: string;
accessToken: string accessToken: string;
/** Lightning (default) or fiat. Only meaningful if the event has /** Lightning (default) or fiat. Only meaningful if the event has
* `allow_fiat=true` on the backend; otherwise the backend coerces * `allow_fiat=true` on the backend; otherwise the backend coerces
* to lightning. */ * to lightning. */
paymentMethod?: PaymentMethod paymentMethod?: PaymentMethod;
/** Specific fiat provider id (e.g. "stripe"). Backend picks the /** Specific fiat provider id (e.g. "stripe"). Backend picks the
* user's default if omitted. */ * user's default if omitted. */
fiatProvider?: string fiatProvider?: string;
} }
/** /**
@ -128,26 +160,26 @@ export interface TicketPurchaseRequest {
* scannable rows so the client skips the payment step entirely. * scannable rows so the client skips the payment step entirely.
*/ */
export interface TicketPurchaseInvoice { export interface TicketPurchaseInvoice {
paymentHash: string paymentHash: string;
paymentRequest?: string paymentRequest?: string;
fiatPaymentRequest?: string fiatPaymentRequest?: string;
fiatProvider?: string fiatProvider?: string;
isFiat: boolean isFiat: boolean;
/** Free tickets: already issued + paid, no invoice to settle. */ /** Free tickets: already issued + paid, no invoice to settle. */
paid?: boolean paid?: boolean;
/** Row ids returned inline for the free path (no poll needed). */ /** Row ids returned inline for the free path (no poll needed). */
ticketIds?: string[] ticketIds?: string[];
} }
export interface TicketPaymentStatus { export interface TicketPaymentStatus {
paid: boolean paid: boolean;
/** First ticket id created on this invoice. Back-compat with /** First ticket id created on this invoice. Back-compat with
* single-ticket purchases — equals the payment_hash. */ * single-ticket purchases — equals the payment_hash. */
ticketId?: string ticketId?: string;
/** Every row created on this invoice — one for single-ticket /** Every row created on this invoice — one for single-ticket
* purchases, N for multi-ticket. Each row is independently * purchases, N for multi-ticket. Each row is independently
* scannable at the door. */ * scannable at the door. */
ticketIds?: string[] ticketIds?: string[];
} }
/** /**
@ -159,48 +191,48 @@ export interface TicketPaymentStatus {
* switches the publisher between NIP-52 kind 31922 and 31923. * switches the publisher between NIP-52 kind 31922 and 31923.
*/ */
export interface TicketedEvent { export interface TicketedEvent {
id: string id: string;
wallet: string wallet: string;
name: string name: string;
info: string info: string;
closing_date: string | null closing_date: string | null;
event_start_date: string event_start_date: string;
event_end_date: string | null event_end_date: string | null;
currency: string currency: string;
/** Whether the event accepts fiat payments. Upstream v1.4.0+. */ /** Whether the event accepts fiat payments. Upstream v1.4.0+. */
allow_fiat: boolean allow_fiat: boolean;
/** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */ /** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */
fiat_currency: string fiat_currency: string;
amount_tickets: number amount_tickets: number;
price_per_ticket: number price_per_ticket: number;
time: string time: string;
sold: number sold: number;
banner: string | null banner: string | null;
location: string | null location: string | null;
categories: string[] categories: string[];
extra: EventExtra extra: EventExtra;
status: string status: string;
} }
export interface CreateEventRequest { export interface CreateEventRequest {
wallet?: string wallet?: string;
name: string name: string;
info?: string info?: string;
closing_date?: string closing_date?: string;
event_start_date: string event_start_date: string;
event_end_date?: string event_end_date?: string;
currency?: string currency?: string;
allow_fiat?: boolean allow_fiat?: boolean;
fiat_currency?: string fiat_currency?: string;
amount_tickets?: number amount_tickets?: number;
price_per_ticket?: number price_per_ticket?: number;
banner?: string | null banner?: string | null;
location?: string | null location?: string | null;
categories?: string[] categories?: string[];
/** Optional — notification toggles + custom subject/body, promo /** Optional — notification toggles + custom subject/body, promo
* codes, conditional-event config. Backend defaults to a fresh * codes, conditional-event config. Backend defaults to a fresh
* EventExtra if omitted. */ * EventExtra if omitted. */
extra?: Partial<EventExtra> extra?: Partial<EventExtra>;
} }
/** /**
@ -214,19 +246,23 @@ export interface CreateEventRequest {
* under the webapp instead of the LNbits host. * under the webapp instead of the LNbits host.
*/ */
export interface CreateTicketRequest { export interface CreateTicketRequest {
name?: string name?: string;
email?: string email?: string;
user_id?: string user_id?: string;
/** App root (origin + base path, no trailing slash). Origin must be /** App root (origin + base path, no trailing slash). Origin must be
* on the LNbits instance's CORS allowlist or the backend 400s. */ * on the LNbits instance's CORS allowlist or the backend 400s. */
frontend_url?: string frontend_url?: string;
promo_code?: string promo_code?: string;
refund_address?: string refund_address?: string;
nostr_identifier?: string nostr_identifier?: string;
payment_method?: PaymentMethod payment_method?: PaymentMethod;
fiat_provider?: string fiat_provider?: string;
/** Number of tickets on this invoice (backend bounds 1..10). */ /** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number quantity?: number;
/** Which pricing tier the buyer chose. Required whenever more than one
* wave is open — the backend refuses to guess ("Please select a ticket
* wave") — and implied when exactly one is. */
ticket_wave_id?: string;
} }
/** /**
@ -235,18 +271,18 @@ export interface CreateTicketRequest {
* the caller asked for. * the caller asked for.
*/ */
export interface PublicTicket { export interface PublicTicket {
eventId: string eventId: string;
name: string | null name: string | null;
registered: boolean registered: boolean;
paid: boolean paid: boolean;
time: string time: string;
regTimestamp: string regTimestamp: string;
} }
export interface NotificationDeliveryResult { export interface NotificationDeliveryResult {
attempted: boolean attempted: boolean;
sent: boolean sent: boolean;
error: string | null error: string | null;
} }
/** /**
@ -254,7 +290,7 @@ export interface NotificationDeliveryResult {
* (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8). * (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8).
*/ */
export interface TicketResendResult { export interface TicketResendResult {
ticket: EventTicket ticket: EventTicket;
email: NotificationDeliveryResult email: NotificationDeliveryResult;
nostr: NotificationDeliveryResult nostr: NotificationDeliveryResult;
} }