Ticket waves: manage them, buy from them, stop lying about totals #176
3 changed files with 608 additions and 116 deletions
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.
commit
8490cdf84c
228
src/modules/events/lib/ticketWaves.spec.ts
Normal file
228
src/modules/events/lib/ticketWaves.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
228
src/modules/events/lib/ticketWaves.ts
Normal file
228
src/modules/events/lib/ticketWaves.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue