From 8490cdf84c4b5c05428f98b296a9a1f1b646667f Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 08:19:33 +0200 Subject: [PATCH 1/4] feat(events): ticket-wave types and arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/modules/events/lib/ticketWaves.spec.ts | 228 ++++++++++++++++++ src/modules/events/lib/ticketWaves.ts | 228 ++++++++++++++++++ src/modules/events/types/ticket.ts | 268 ++++++++++++--------- 3 files changed, 608 insertions(+), 116 deletions(-) create mode 100644 src/modules/events/lib/ticketWaves.spec.ts create mode 100644 src/modules/events/lib/ticketWaves.ts 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; } -- 2.55.0 From afa34c90c2f772bc445a33ff6d923077ad46c48c Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 08:23:52 +0200 Subject: [PATCH 2/4] feat(events): buy from a chosen ticket wave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkout was priced and requested off the event, which since events ext v1.6.8 means the PRIMARY wave. Two things were wrong at once: once early bird closed the dialog displayed the closed tier's price while the backend charged the open one, and an event with two waves open could not be bought at all — the purchase endpoint refuses to guess ("Please select a ticket wave") and nothing sent `ticket_wave_id`. The dialog now resolves a wave the way the backend does: a single open wave is implied, several means the buyer picks, none means nothing is on sale. Price, currency and fiat availability all read from that wave — `allow_fiat` too, which is a per-wave opt-in and could otherwise offer a rail the purchase would refuse. The picker only renders when there is an actual choice, so the common single-wave case gains no extra click, and the CTA is disabled rather than firing a request the backend will reject. `ticket_wave_id` now rides on all three purchase paths (discounted-free, Lightning, fiat) and on the promo preview, so the quote and the charge are priced against the same tier. Plumbing worth noting: the tags a card renders from describe the active wave but carry no wave id, by the flat-tag decision on aiolabs/events#61. So the detail page fetches the LNbits record for the pickable tiers — possible only because `ticket_waves` is public from v1.6.8-aio.2 (aiolabs/events#66). Non-fatal on failure: the backend answers 410 for sold-out or closed events, where there is no tier to pick anyway. 3 service tests pin the wire format, including that no empty wave id is sent when the buyer had no choice. 62 pass; vue-tsc and prettier clean. --- .../components/PurchaseTicketDialog.vue | 876 ++++++++++++------ .../events/composables/usePromoCode.ts | 161 ++-- .../events/composables/useTicketPurchase.ts | 334 +++---- .../events/services/TicketApiService.spec.ts | 382 +++++--- .../events/services/TicketApiService.ts | 310 ++++--- src/modules/events/views/EventDetailPage.vue | 457 +++++---- 6 files changed, 1601 insertions(+), 919 deletions(-) diff --git a/src/modules/events/components/PurchaseTicketDialog.vue b/src/modules/events/components/PurchaseTicketDialog.vue index 6726b20..a1737fb 100644 --- a/src/modules/events/components/PurchaseTicketDialog.vue +++ b/src/modules/events/components/PurchaseTicketDialog.vue @@ -1,14 +1,20 @@