diff --git a/src/modules/events/lib/ticketWaves.spec.ts b/src/modules/events/lib/ticketWaves.spec.ts new file mode 100644 index 0000000..48501ac --- /dev/null +++ b/src/modules/events/lib/ticketWaves.spec.ts @@ -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 & { 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); + }); +}); diff --git a/src/modules/events/lib/ticketWaves.ts b/src/modules/events/lib/ticketWaves.ts new file mode 100644 index 0000000..2b90644 --- /dev/null +++ b/src/modules/events/lib/ticketWaves.ts @@ -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 & { + 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( + event: T, +): SyncedEvent { + 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; +} diff --git a/src/modules/events/types/ticket.ts b/src/modules/events/types/ticket.ts index 67ee90f..1e2e903 100644 --- a/src/modules/events/types/ticket.ts +++ b/src/modules/events/types/ticket.ts @@ -7,41 +7,45 @@ */ export interface PromoCode { - code: string - discount_percent: number - active: boolean + code: string; + discount_percent: number; + active: boolean; /** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12, * 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, * never sent back. */ - used_count?: number + used_count?: number; } /** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream * v2 shape; `quantity` stands in for v2's `items` since this fork has * no ticket types). */ export interface PromoValidateRequest { - codes: string[] - quantity: number + codes: string[]; + 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 { - code: string - discount_percent: number | null + code: string; + discount_percent: number | null; /** Always null on this fork (percent-only); kept for the v2 shape. */ - discount_fixed: number | null - amount_saved: number + discount_fixed: number | null; + amount_saved: number; } /** Response of the promo validate endpoint. An empty `discounts_applied` * means none of the submitted codes is valid for this event/quantity. */ export interface BasketTotals { - subtotal: number - discount: number - total: number - discounts_applied: BasketDiscount[] - currency: string + subtotal: number; + discount: number; + total: number; + discounts_applied: BasketDiscount[]; + currency: string; } /** @@ -50,73 +54,101 @@ export interface BasketTotals { * and the per-event notification toggles + custom subject/body added * 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 { /** Present on organizer-authenticated records only: public event * responses (`/events/public`, `/events/{id}`) no longer carry the * code list (events ext >= v1.6.1-aio.12). */ - promo_codes?: PromoCode[] - conditional: boolean - min_tickets: number - email_notifications: boolean - nostr_notifications: boolean - notification_subject: string - notification_body: string + promo_codes?: PromoCode[]; + conditional: boolean; + min_tickets: number; + email_notifications: boolean; + nostr_notifications: boolean; + notification_subject: string; + notification_body: string; /** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent * means the legacy rule: Lightning always, fiat iff `allow_fiat`. * 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 { - applied_promo_code?: string | null - sats_paid?: number | null - refund_address?: string | null - nostr_identifier?: string | null - ticket_base_url?: string | null - email_notification_sent: boolean - nostr_notification_sent: boolean - refunded: boolean + applied_promo_code?: string | null; + sats_paid?: number | null; + refund_address?: string | null; + nostr_identifier?: string | null; + ticket_base_url?: string | null; + email_notification_sent: boolean; + nostr_notification_sent: boolean; + refunded: boolean; } export interface EventTicket { - id: string - wallet: string + id: string; + wallet: string; /** Reference to the event (LNbits event ID) */ - eventId: string + eventId: string; /** Ticket holder name */ - name: string | null + name: string | null; /** Ticket holder email */ - email: string | null + email: string | null; /** Nostr pubkey or LNbits user ID */ - userId: string | null + userId: string | null; /** Whether ticket has been scanned/registered at the door */ - registered: boolean + registered: boolean; /** Whether payment has been confirmed */ - paid: boolean + paid: boolean; /** Ticket creation timestamp */ - time: string + time: string; /** Registration/scan timestamp */ - regTimestamp: string + regTimestamp: string; /** Optional metadata — promo code applied, sats paid, notification * 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 { - eventId: string - userId: string - accessToken: string + eventId: string; + userId: string; + accessToken: string; /** Lightning (default) or fiat. Only meaningful if the event has * `allow_fiat=true` on the backend; otherwise the backend coerces * to lightning. */ - paymentMethod?: PaymentMethod + paymentMethod?: PaymentMethod; /** Specific fiat provider id (e.g. "stripe"). Backend picks the * 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. */ export interface TicketPurchaseInvoice { - paymentHash: string - paymentRequest?: string - fiatPaymentRequest?: string - fiatProvider?: string - isFiat: boolean + paymentHash: string; + paymentRequest?: string; + fiatPaymentRequest?: string; + fiatProvider?: string; + isFiat: boolean; /** Free tickets: already issued + paid, no invoice to settle. */ - paid?: boolean + paid?: boolean; /** Row ids returned inline for the free path (no poll needed). */ - ticketIds?: string[] + ticketIds?: string[]; } export interface TicketPaymentStatus { - paid: boolean + paid: boolean; /** First ticket id created on this invoice. Back-compat with * single-ticket purchases — equals the payment_hash. */ - ticketId?: string + ticketId?: string; /** Every row created on this invoice — one for single-ticket * purchases, N for multi-ticket. Each row is independently * 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. */ export interface TicketedEvent { - id: string - wallet: string - name: string - info: string - closing_date: string | null - event_start_date: string - event_end_date: string | null - currency: string + id: string; + wallet: string; + name: string; + info: string; + closing_date: string | null; + event_start_date: string; + event_end_date: string | null; + currency: string; /** 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: string - amount_tickets: number - price_per_ticket: number - time: string - sold: number - banner: string | null - location: string | null - categories: string[] - extra: EventExtra - status: string + fiat_currency: string; + amount_tickets: number; + price_per_ticket: number; + time: string; + sold: number; + banner: string | null; + location: string | null; + categories: string[]; + extra: EventExtra; + status: string; } export interface CreateEventRequest { - wallet?: string - name: string - info?: string - closing_date?: string - event_start_date: string - event_end_date?: string - currency?: string - allow_fiat?: boolean - fiat_currency?: string - amount_tickets?: number - price_per_ticket?: number - banner?: string | null - location?: string | null - categories?: string[] + wallet?: string; + name: string; + info?: string; + closing_date?: string; + event_start_date: string; + event_end_date?: string; + currency?: string; + allow_fiat?: boolean; + fiat_currency?: string; + amount_tickets?: number; + price_per_ticket?: number; + banner?: string | null; + location?: string | null; + categories?: string[]; /** Optional — notification toggles + custom subject/body, promo * codes, conditional-event config. Backend defaults to a fresh * EventExtra if omitted. */ - extra?: Partial + extra?: Partial; } /** @@ -214,19 +246,23 @@ export interface CreateEventRequest { * under the webapp instead of the LNbits host. */ export interface CreateTicketRequest { - name?: string - email?: string - user_id?: string + name?: string; + email?: string; + user_id?: string; /** App root (origin + base path, no trailing slash). Origin must be * on the LNbits instance's CORS allowlist or the backend 400s. */ - frontend_url?: string - promo_code?: string - refund_address?: string - nostr_identifier?: string - payment_method?: PaymentMethod - fiat_provider?: string + frontend_url?: string; + promo_code?: string; + refund_address?: string; + nostr_identifier?: string; + payment_method?: PaymentMethod; + fiat_provider?: string; /** 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. */ export interface PublicTicket { - eventId: string - name: string | null - registered: boolean - paid: boolean - time: string - regTimestamp: string + eventId: string; + name: string | null; + registered: boolean; + paid: boolean; + time: string; + regTimestamp: string; } export interface NotificationDeliveryResult { - attempted: boolean - sent: boolean - error: string | null + attempted: boolean; + sent: boolean; + error: string | null; } /** @@ -254,7 +290,7 @@ export interface NotificationDeliveryResult { * (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8). */ export interface TicketResendResult { - ticket: EventTicket - email: NotificationDeliveryResult - nostr: NotificationDeliveryResult + ticket: EventTicket; + email: NotificationDeliveryResult; + nostr: NotificationDeliveryResult; }