feat(events): guest checkout with email delivery, Stripe return flow, public ticket page #144
12 changed files with 450 additions and 56 deletions
feat(events): guest identity, frontend_url and public ticket read in the ticket API
Prepare the webapp for account-less ticket purchases against events ext v1.6.1-aio.8: - requestTicket takes an options object: user_id (+ optional email) for logged-in buyers, name + email for guests, Authorization only when a token exists, and frontend_url so the backend builds the Stripe return URLs and the emailed ticket link under this app's root. - getTicket (anonymous PublicTicket) and getPublicEvent for the return dialog and the public ticket page. - resendTicketEmail maps the structured TicketResendResult (legacy bare ticket tolerated). - NIP-52 tickets_payment_methods tag parsed into ticketInfo.paymentMethods; effectivePaymentMethods() mirrors the backend rule. - renderTicketQr: level-H ticket:// QR with the brand logo centred, shared by every ticket surface; useTicketPurchase accepts a guest contact. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
commit
f85848a50b
|
|
@ -6,6 +6,22 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { PaymentService } from '@/core/services/PaymentService'
|
import type { PaymentService } from '@/core/services/PaymentService'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import { useOwnedTickets } from './useOwnedTickets'
|
import { useOwnedTickets } from './useOwnedTickets'
|
||||||
|
import { frontendRoot } from '../lib/frontendRoot'
|
||||||
|
import { renderTicketQr } from '../lib/ticketQr'
|
||||||
|
|
||||||
|
/** Guest identity for a purchase without an LNbits account. */
|
||||||
|
export interface PurchaseContact {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseTicketOptions {
|
||||||
|
quantity?: number
|
||||||
|
/** Guest buyer (no account). Required when not authenticated. */
|
||||||
|
contact?: PurchaseContact
|
||||||
|
/** Delivery email for a logged-in buyer (guests carry it in `contact`). */
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function useTicketPurchase() {
|
export function useTicketPurchase() {
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
|
|
@ -64,11 +80,7 @@ export function useTicketPurchase() {
|
||||||
|
|
||||||
async function generateTicketQRCode(ticketId: string) {
|
async function generateTicketQRCode(ticketId: string) {
|
||||||
try {
|
try {
|
||||||
const ticketUrl = `ticket://${ticketId}`
|
const dataUrl = await renderTicketQr(ticketId, { width: 160 })
|
||||||
const dataUrl = await paymentService.generateCustomQRCode(ticketUrl, {
|
|
||||||
width: 128,
|
|
||||||
margin: 2
|
|
||||||
})
|
|
||||||
ticketQRCode.value = dataUrl
|
ticketQRCode.value = dataUrl
|
||||||
return dataUrl
|
return dataUrl
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -128,9 +140,13 @@ export function useTicketPurchase() {
|
||||||
|
|
||||||
async function purchaseTicketForEvent(
|
async function purchaseTicketForEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
options: { quantity?: number } = {},
|
options: PurchaseTicketOptions = {},
|
||||||
) {
|
) {
|
||||||
if (!canPurchase.value || !currentUser.value) {
|
// Guests are identified by name + email; logged-in buyers by user id.
|
||||||
|
// Either is enough for a paid ticket — free tickets stay login-gated
|
||||||
|
// in the dialog (events#29: no anonymous free issuance).
|
||||||
|
const isGuest = !canPurchase.value || !currentUser.value
|
||||||
|
if (isGuest && !options.contact) {
|
||||||
throw new Error('User must be authenticated to purchase tickets')
|
throw new Error('User must be authenticated to purchase tickets')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,12 +166,14 @@ export function useTicketPurchase() {
|
||||||
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
||||||
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
||||||
|
|
||||||
const invoice = await ticketApi.requestTicket(
|
const invoice = await ticketApi.requestTicket(eventId, {
|
||||||
eventId,
|
userId: isGuest ? undefined : currentUser.value!.id,
|
||||||
currentUser.value!.id,
|
name: isGuest ? options.contact?.name : undefined,
|
||||||
accessToken,
|
email: isGuest ? options.contact?.email : options.email,
|
||||||
{ quantity: options.quantity },
|
accessToken: isGuest ? undefined : accessToken,
|
||||||
)
|
frontendUrl: frontendRoot(),
|
||||||
|
quantity: options.quantity,
|
||||||
|
})
|
||||||
|
|
||||||
// Free tickets (price 0 / 100%-off promo): the backend already
|
// Free tickets (price 0 / 100%-off promo): the backend already
|
||||||
// issued + marked them paid, so there's no invoice to settle. Skip
|
// issued + marked them paid, so there's no invoice to settle. Skip
|
||||||
|
|
|
||||||
16
src/modules/events/lib/frontendRoot.spec.ts
Normal file
16
src/modules/events/lib/frontendRoot.spec.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { frontendRoot } from './frontendRoot'
|
||||||
|
|
||||||
|
describe('frontendRoot', () => {
|
||||||
|
it('joins origin and base path without a trailing slash', () => {
|
||||||
|
expect(frontendRoot('https://demo.aiolabs.dev', '/events/')).toBe('https://demo.aiolabs.dev/events')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses a root base to the bare origin', () => {
|
||||||
|
expect(frontendRoot('http://localhost:5173', '/')).toBe('http://localhost:5173')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolerates a base without leading slash', () => {
|
||||||
|
expect(frontendRoot('https://app.ariege.io', 'oyez/')).toBe('https://app.ariege.io/oyez')
|
||||||
|
})
|
||||||
|
})
|
||||||
20
src/modules/events/lib/frontendRoot.ts
Normal file
20
src/modules/events/lib/frontendRoot.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* This app's public root — origin + Vite base path, no trailing slash.
|
||||||
|
*
|
||||||
|
* Sent to the events extension as `frontend_url` so the backend can
|
||||||
|
* compose the Stripe success / cancel URLs and the emailed ticket link
|
||||||
|
* under the webapp (e.g. `https://demo.aiolabs.dev/events`,
|
||||||
|
* `https://app.ariege.io/oyez`) instead of the LNbits host. The
|
||||||
|
* extension appends the same path shapes the LNbits UI uses
|
||||||
|
* (`/events/{event_id}`, `/events/ticket/{ticket_id}`), which the events
|
||||||
|
* router serves under that base.
|
||||||
|
*
|
||||||
|
* Parameters exist for tests; callers use the defaults.
|
||||||
|
*/
|
||||||
|
export function frontendRoot(
|
||||||
|
origin: string = window.location.origin,
|
||||||
|
base: string = import.meta.env.BASE_URL ?? '/',
|
||||||
|
): string {
|
||||||
|
const path = base.startsWith('/') ? base : `/${base}`
|
||||||
|
return `${origin}${path}`.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
21
src/modules/events/lib/paymentMethods.spec.ts
Normal file
21
src/modules/events/lib/paymentMethods.spec.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { effectivePaymentMethods } from './paymentMethods'
|
||||||
|
|
||||||
|
describe('effectivePaymentMethods', () => {
|
||||||
|
it('falls back to the legacy allowFiat rule when no list is published', () => {
|
||||||
|
expect(effectivePaymentMethods({ allowFiat: false })).toEqual(['lightning'])
|
||||||
|
expect(effectivePaymentMethods({ allowFiat: true })).toEqual(['lightning', 'fiat'])
|
||||||
|
expect(effectivePaymentMethods(undefined)).toEqual(['lightning'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours an explicit list, including fiat-only events', () => {
|
||||||
|
expect(effectivePaymentMethods({ allowFiat: true, paymentMethods: ['fiat'] })).toEqual(['fiat'])
|
||||||
|
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['lightning', 'fiat'] }))
|
||||||
|
.toEqual(['lightning', 'fiat'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops unknown rails and falls back when nothing known remains', () => {
|
||||||
|
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain'] })).toEqual(['lightning'])
|
||||||
|
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain', 'fiat'] })).toEqual(['fiat'])
|
||||||
|
})
|
||||||
|
})
|
||||||
22
src/modules/events/lib/paymentMethods.ts
Normal file
22
src/modules/events/lib/paymentMethods.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
export type PaymentRailId = 'lightning' | 'fiat'
|
||||||
|
|
||||||
|
const KNOWN_RAILS: PaymentRailId[] = ['lightning', 'fiat']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rails a buyer may use for an event.
|
||||||
|
*
|
||||||
|
* Mirrors `effective_payment_methods()` in the events extension: an
|
||||||
|
* explicit `paymentMethods` list wins; otherwise the legacy rule applies
|
||||||
|
* — Lightning always, fiat only when the organizer set `allowFiat`.
|
||||||
|
* Unknown rail ids are dropped so a future backend value can't render
|
||||||
|
* a button the dialog has no handler for.
|
||||||
|
*/
|
||||||
|
export function effectivePaymentMethods(
|
||||||
|
info: { allowFiat?: boolean; paymentMethods?: string[] } | null | undefined,
|
||||||
|
): PaymentRailId[] {
|
||||||
|
const explicit = (info?.paymentMethods ?? []).filter(
|
||||||
|
(m): m is PaymentRailId => (KNOWN_RAILS as string[]).includes(m),
|
||||||
|
)
|
||||||
|
if (explicit.length > 0) return explicit
|
||||||
|
return info?.allowFiat ? ['lightning', 'fiat'] : ['lightning']
|
||||||
|
}
|
||||||
61
src/modules/events/lib/ticketQr.ts
Normal file
61
src/modules/events/lib/ticketQr.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import brandLogo from '@brand/logo.png'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the scannable `ticket://<id>` QR every ticket surface uses
|
||||||
|
* (purchase success, Stripe return dialog, ticket page, My Tickets).
|
||||||
|
*
|
||||||
|
* Error-correction level H (30 % recoverable) leaves room for the brand
|
||||||
|
* logo the events extension also composites into its emailed PNG, so
|
||||||
|
* the in-app and emailed images look alike. The overlay is best-effort:
|
||||||
|
* if canvas or the logo asset is unavailable the plain QR is returned.
|
||||||
|
*/
|
||||||
|
export async function renderTicketQr(
|
||||||
|
ticketId: string,
|
||||||
|
options: { width?: number; logo?: boolean } = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const width = options.width ?? 200
|
||||||
|
const qrcode = await import('qrcode')
|
||||||
|
const plain = await qrcode.toDataURL(`ticket://${ticketId}`, {
|
||||||
|
width,
|
||||||
|
margin: 2,
|
||||||
|
errorCorrectionLevel: 'H',
|
||||||
|
color: { dark: '#000000', light: '#FFFFFF' },
|
||||||
|
})
|
||||||
|
if (options.logo === false) return plain
|
||||||
|
try {
|
||||||
|
return await overlayLogo(plain, width)
|
||||||
|
} catch {
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => resolve(img)
|
||||||
|
img.onerror = () => reject(new Error(`Could not load ${src}`))
|
||||||
|
img.src = src
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function overlayLogo(qrDataUrl: string, width: number): Promise<string> {
|
||||||
|
if (typeof document === 'undefined') return qrDataUrl
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = width
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return qrDataUrl
|
||||||
|
|
||||||
|
const [qr, logo] = await Promise.all([loadImage(qrDataUrl), loadImage(brandLogo)])
|
||||||
|
ctx.drawImage(qr, 0, 0, width, width)
|
||||||
|
|
||||||
|
// Logo ≤ 20 % of the QR width on a white pad ≈ 26 % — well inside the
|
||||||
|
// 30 % damage budget of level H, matching the backend's PNG endpoint.
|
||||||
|
const logoSize = Math.round(width * 0.2)
|
||||||
|
const padSize = Math.round(width * 0.26)
|
||||||
|
const center = width / 2
|
||||||
|
ctx.fillStyle = '#FFFFFF'
|
||||||
|
ctx.fillRect(center - padSize / 2, center - padSize / 2, padSize, padSize)
|
||||||
|
ctx.drawImage(logo, center - logoSize / 2, center - logoSize / 2, logoSize, logoSize)
|
||||||
|
return canvas.toDataURL('image/png')
|
||||||
|
}
|
||||||
92
src/modules/events/services/TicketApiService.spec.ts
Normal file
92
src/modules/events/services/TicketApiService.spec.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { TicketApiService } from './TicketApiService'
|
||||||
|
|
||||||
|
const BASE = 'https://lnbits.test'
|
||||||
|
|
||||||
|
function okJson(body: unknown) {
|
||||||
|
return { ok: true, json: async () => body } as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TicketApiService.requestTicket', () => {
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
let api: TicketApiService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset()
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
|
||||||
|
})
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
function lastCall() {
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||||
|
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends name + email and no user_id / Authorization for guests', async () => {
|
||||||
|
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', fiat_payment_request: 'https://checkout', is_fiat: true, ticket_ids: ['a', 'b'] }))
|
||||||
|
const res = await api.requestTicket('evt', {
|
||||||
|
name: 'Guest', email: 'g@example.com', frontendUrl: 'https://demo.aiolabs.dev/events',
|
||||||
|
paymentMethod: 'fiat', quantity: 2,
|
||||||
|
})
|
||||||
|
const { url, body, headers } = lastCall()
|
||||||
|
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`)
|
||||||
|
expect(body).toEqual({
|
||||||
|
name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events',
|
||||||
|
payment_method: 'fiat', quantity: 2,
|
||||||
|
})
|
||||||
|
expect(body.user_id).toBeUndefined()
|
||||||
|
expect(headers.Authorization).toBeUndefined()
|
||||||
|
expect(headers['X-API-KEY']).toBe('inst-key')
|
||||||
|
expect(res).toMatchObject({ isFiat: true, fiatPaymentRequest: 'https://checkout', ticketIds: ['a', 'b'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends user_id + email with a Bearer token for logged-in buyers', async () => {
|
||||||
|
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
|
||||||
|
await api.requestTicket('evt', {
|
||||||
|
userId: 'u1', email: 'me@example.com', accessToken: 'tok', frontendUrl: 'http://localhost:5173',
|
||||||
|
})
|
||||||
|
const { body, headers } = lastCall()
|
||||||
|
expect(body).toEqual({ user_id: 'u1', email: 'me@example.com', frontend_url: 'http://localhost:5173' })
|
||||||
|
expect(headers.Authorization).toBe('Bearer tok')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces the backend detail string as the error message', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: false, json: async () => ({ detail: 'Payment method not enabled for this event.' }) } as Response)
|
||||||
|
await expect(api.requestTicket('evt', { name: 'G', email: 'g@x.io', paymentMethod: 'fiat' }))
|
||||||
|
.rejects.toThrow('Payment method not enabled for this event.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TicketApiService.getTicket / resendTicketEmail', () => {
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
let api: TicketApiService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset()
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' })
|
||||||
|
})
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
it('maps the anonymous PublicTicket shape', async () => {
|
||||||
|
fetchMock.mockResolvedValue(okJson({ event: 'evt', name: 'G', registered: false, paid: true, time: 't', reg_timestamp: 'r' }))
|
||||||
|
const t = await api.getTicket('tid')
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/events/api/v1/tickets/tid`)
|
||||||
|
expect(t).toEqual({ eventId: 'evt', name: 'G', registered: false, paid: true, time: 't', regTimestamp: 'r' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps the structured resend result and tolerates the legacy bare-ticket shape', async () => {
|
||||||
|
const ticket = { id: 'tid', wallet: 'w', event: 'evt', name: 'G', email: 'g@x.io', user_id: null, registered: false, paid: true, time: 't', reg_timestamp: 'r', extra: {} }
|
||||||
|
fetchMock.mockResolvedValueOnce(okJson({ ticket, email: { attempted: true, sent: false, error: 'smtp down' }, nostr: { attempted: false, sent: false, error: null } }))
|
||||||
|
const r = await api.resendTicketEmail('tid', 'admin')
|
||||||
|
expect(r.ticket.id).toBe('tid')
|
||||||
|
expect(r.email).toEqual({ attempted: true, sent: false, error: 'smtp down' })
|
||||||
|
expect(r.nostr.attempted).toBe(false)
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce(okJson(ticket))
|
||||||
|
const legacy = await api.resendTicketEmail('tid', 'admin')
|
||||||
|
expect(legacy.ticket.id).toBe('tid')
|
||||||
|
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -3,8 +3,10 @@ import type {
|
||||||
EventTicketExtra,
|
EventTicketExtra,
|
||||||
CreateTicketRequest,
|
CreateTicketRequest,
|
||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
|
PublicTicket,
|
||||||
TicketPurchaseInvoice,
|
TicketPurchaseInvoice,
|
||||||
TicketPaymentStatus,
|
TicketPaymentStatus,
|
||||||
|
TicketResendResult,
|
||||||
TicketedEvent,
|
TicketedEvent,
|
||||||
CreateEventRequest,
|
CreateEventRequest,
|
||||||
} from '../types/ticket'
|
} from '../types/ticket'
|
||||||
|
|
@ -14,6 +16,27 @@ export interface TicketApiConfig {
|
||||||
apiKey: string
|
apiKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Options for `TicketApiService.requestTicket`. */
|
||||||
|
export interface RequestTicketOptions {
|
||||||
|
/** LNbits user id — logged-in buyers. */
|
||||||
|
userId?: string
|
||||||
|
/** Guest buyer name (required together with `email` when no `userId`). */
|
||||||
|
name?: string
|
||||||
|
/** Delivery email — required for guests, optional for logged-in buyers. */
|
||||||
|
email?: string
|
||||||
|
/** Bearer token; forwarded only when present. */
|
||||||
|
accessToken?: string
|
||||||
|
/** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */
|
||||||
|
frontendUrl?: string
|
||||||
|
paymentMethod?: PaymentMethod
|
||||||
|
fiatProvider?: string
|
||||||
|
promoCode?: string
|
||||||
|
refundAddress?: string
|
||||||
|
nostrIdentifier?: string
|
||||||
|
/** Number of tickets to buy on this invoice. Backend caps at 10. */
|
||||||
|
quantity?: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database-backed ticketing API service.
|
* Database-backed ticketing API service.
|
||||||
* Talks to the LNbits events extension for ticket inventory,
|
* Talks to the LNbits events extension for ticket inventory,
|
||||||
|
|
@ -57,29 +80,27 @@ export class TicketApiService {
|
||||||
* = follow-the-URL string from the configured fiat provider). The
|
* = follow-the-URL string from the configured fiat provider). The
|
||||||
* `isFiat` flag is the discriminator.
|
* `isFiat` flag is the discriminator.
|
||||||
*
|
*
|
||||||
* `paymentMethod` defaults to "lightning"; pass "fiat" to opt into
|
* Identity: pass `userId` for a logged-in buyer (plus `email` so the
|
||||||
* the fiat path (requires the event to have `allow_fiat=true`).
|
* ticket can be delivered by mail), or `name` + `email` for a guest.
|
||||||
* `fiatProvider` is optional — backend picks the user's configured
|
* The endpoint itself is anonymous; `accessToken` is only forwarded
|
||||||
* default when omitted.
|
* when present.
|
||||||
*
|
*
|
||||||
* Additional ticket metadata (promo code, refund address, nostr
|
* `paymentMethod` defaults to "lightning"; pass "fiat" to opt into
|
||||||
* identifier for DM delivery) can be supplied via `options`.
|
* the fiat path (the event must have that rail enabled — the backend
|
||||||
|
* 400s otherwise). `fiatProvider` is optional — backend picks the
|
||||||
|
* organizer's configured default when omitted. `frontendUrl` is this
|
||||||
|
* app's root (see `lib/frontendRoot.ts`) and drives the Stripe
|
||||||
|
* return URLs + emailed ticket link.
|
||||||
*/
|
*/
|
||||||
async requestTicket(
|
async requestTicket(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
userId: string,
|
options: RequestTicketOptions = {},
|
||||||
accessToken: string,
|
|
||||||
options: {
|
|
||||||
paymentMethod?: PaymentMethod
|
|
||||||
fiatProvider?: string
|
|
||||||
promoCode?: string
|
|
||||||
refundAddress?: string
|
|
||||||
nostrIdentifier?: string
|
|
||||||
/** Number of tickets to buy on this invoice. Backend caps at 10. */
|
|
||||||
quantity?: number
|
|
||||||
} = {},
|
|
||||||
): Promise<TicketPurchaseInvoice> {
|
): Promise<TicketPurchaseInvoice> {
|
||||||
const body: CreateTicketRequest = { user_id: userId }
|
const body: CreateTicketRequest = {}
|
||||||
|
if (options.userId) body.user_id = options.userId
|
||||||
|
if (options.name) body.name = options.name
|
||||||
|
if (options.email) body.email = options.email
|
||||||
|
if (options.frontendUrl) body.frontend_url = options.frontendUrl
|
||||||
if (options.paymentMethod) body.payment_method = options.paymentMethod
|
if (options.paymentMethod) body.payment_method = options.paymentMethod
|
||||||
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
|
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
|
||||||
if (options.promoCode) body.promo_code = options.promoCode
|
if (options.promoCode) body.promo_code = options.promoCode
|
||||||
|
|
@ -87,14 +108,14 @@ export class TicketApiService {
|
||||||
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
|
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
|
||||||
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
|
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}`
|
||||||
|
|
||||||
const data = await this.request(
|
const data = await this.request(
|
||||||
`/events/api/v1/tickets/${eventId}`,
|
`/events/api/v1/tickets/${eventId}`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -112,6 +133,33 @@ export class TicketApiService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonymous read of one ticket — paid / registered state only, no
|
||||||
|
* PII. Used by the Stripe return dialog to poll settlement and by
|
||||||
|
* the public ticket page the emailed link opens.
|
||||||
|
*/
|
||||||
|
async getTicket(ticketId: string): Promise<PublicTicket> {
|
||||||
|
const t = await this.request(`/events/api/v1/tickets/${ticketId}`, { method: 'GET' })
|
||||||
|
return {
|
||||||
|
eventId: t.event,
|
||||||
|
name: t.name ?? null,
|
||||||
|
registered: Boolean(t.registered),
|
||||||
|
paid: Boolean(t.paid),
|
||||||
|
time: t.time,
|
||||||
|
regTimestamp: t.reg_timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonymous public event detail (`PublicEvent`). Note the backend
|
||||||
|
* answers 410 for sold-out / closed events — callers that only need
|
||||||
|
* a display name should try the Nostr store first and treat a
|
||||||
|
* failure here as non-fatal.
|
||||||
|
*/
|
||||||
|
async getPublicEvent(eventId: string): Promise<TicketedEvent> {
|
||||||
|
return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether a ticket payment has been confirmed.
|
* Check whether a ticket payment has been confirmed.
|
||||||
*/
|
*/
|
||||||
|
|
@ -226,22 +274,30 @@ export class TicketApiService {
|
||||||
/**
|
/**
|
||||||
* Resend the ticket confirmation email for a paid ticket. Requires
|
* Resend the ticket confirmation email for a paid ticket. Requires
|
||||||
* the event's wallet admin key (organizer-only). Returns the updated
|
* the event's wallet admin key (organizer-only). Returns the updated
|
||||||
* Ticket with the `email_notification_sent` flag refreshed.
|
* Ticket plus per-channel delivery results (events ext >= v1.6.1-aio.8;
|
||||||
|
* older extensions returned the bare Ticket, which is tolerated).
|
||||||
*
|
*
|
||||||
* Endpoint added upstream in v1.6.1 (PR #51).
|
* Endpoint added upstream in v1.6.1 (PR #51); structured result in v1.6.8.
|
||||||
*/
|
*/
|
||||||
async resendTicketEmail(
|
async resendTicketEmail(
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
adminKey: string,
|
adminKey: string,
|
||||||
): Promise<EventTicket> {
|
): Promise<TicketResendResult> {
|
||||||
const t = await this.request(
|
const r = await this.request(
|
||||||
`/events/api/v1/tickets/${ticketId}/resend-email`,
|
`/events/api/v1/tickets/${ticketId}/resend-email`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-API-KEY': adminKey },
|
headers: { 'X-API-KEY': adminKey },
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
const t = r.ticket ?? r
|
||||||
|
const delivery = (d: any) => ({
|
||||||
|
attempted: Boolean(d?.attempted),
|
||||||
|
sent: Boolean(d?.sent),
|
||||||
|
error: d?.error ?? null,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
|
ticket: {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
wallet: t.wallet,
|
wallet: t.wallet,
|
||||||
eventId: t.event,
|
eventId: t.event,
|
||||||
|
|
@ -253,6 +309,9 @@ export class TicketApiService {
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
extra: t.extra as EventTicketExtra | undefined,
|
extra: t.extra as EventTicketExtra | undefined,
|
||||||
|
},
|
||||||
|
email: delivery(r.email),
|
||||||
|
nostr: delivery(r.nostr),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ export interface EventTicketInfo {
|
||||||
allowFiat: boolean
|
allowFiat: boolean
|
||||||
/** Fiat settle currency when allowFiat is true. */
|
/** Fiat settle currency when allowFiat is true. */
|
||||||
fiatCurrency?: string
|
fiatCurrency?: string
|
||||||
|
/** Rails the organizer enabled (see TicketTags.paymentMethods). */
|
||||||
|
paymentMethods?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined {
|
function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined {
|
||||||
|
|
@ -98,6 +100,7 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
|
||||||
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined,
|
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined,
|
||||||
allowFiat: ticket.allowFiat,
|
allowFiat: ticket.allowFiat,
|
||||||
fiatCurrency: ticket.fiatCurrency,
|
fiatCurrency: ticket.fiatCurrency,
|
||||||
|
paymentMethods: ticket.paymentMethods,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
35
src/modules/events/types/nip52.spec.ts
Normal file
35
src/modules/events/types/nip52.spec.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseCalendarTimeEvent, NIP52_KINDS } from './nip52'
|
||||||
|
import type { NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
|
function makeEvent(extraTags: string[][]): NostrEvent {
|
||||||
|
return {
|
||||||
|
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
|
||||||
|
id: 'id',
|
||||||
|
pubkey: 'pk',
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
content: '',
|
||||||
|
sig: '',
|
||||||
|
tags: [
|
||||||
|
['d', 'evt1'],
|
||||||
|
['title', 'Test'],
|
||||||
|
['start', '1700000000'],
|
||||||
|
['tickets_currency', 'EUR'],
|
||||||
|
['tickets_price', '20'],
|
||||||
|
...extraTags,
|
||||||
|
],
|
||||||
|
} as NostrEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseTicketTags — tickets_payment_methods', () => {
|
||||||
|
it('parses the comma list into paymentMethods', () => {
|
||||||
|
const parsed = parseCalendarTimeEvent(makeEvent([['tickets_payment_methods', 'lightning, fiat']]))
|
||||||
|
expect(parsed?.ticket?.paymentMethods).toEqual(['lightning', 'fiat'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves paymentMethods undefined when the tag is absent (legacy events)', () => {
|
||||||
|
const parsed = parseCalendarTimeEvent(makeEvent([['tickets_allow_fiat', 'true']]))
|
||||||
|
expect(parsed?.ticket?.allowFiat).toBe(true)
|
||||||
|
expect(parsed?.ticket?.paymentMethods).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -36,6 +36,10 @@ export interface TicketTags {
|
||||||
allowFiat: boolean
|
allowFiat: boolean
|
||||||
/** Fiat settle currency when allowFiat is true. */
|
/** Fiat settle currency when allowFiat is true. */
|
||||||
fiatCurrency?: string
|
fiatCurrency?: string
|
||||||
|
/** Rails the organizer enabled, from `tickets_payment_methods`
|
||||||
|
* ("lightning,fiat"). Undefined on events published before the
|
||||||
|
* tag existed — callers fall back to the allowFiat rule. */
|
||||||
|
paymentMethods?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -137,6 +141,7 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined {
|
||||||
const soldStr = getTagValue(tags, 'tickets_sold')
|
const soldStr = getTagValue(tags, 'tickets_sold')
|
||||||
const priceStr = getTagValue(tags, 'tickets_price')
|
const priceStr = getTagValue(tags, 'tickets_price')
|
||||||
const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat')
|
const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat')
|
||||||
|
const paymentMethodsStr = getTagValue(tags, 'tickets_payment_methods')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: availableStr != null ? Number(availableStr) : undefined,
|
available: availableStr != null ? Number(availableStr) : undefined,
|
||||||
|
|
@ -145,6 +150,9 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined {
|
||||||
currency,
|
currency,
|
||||||
allowFiat: allowFiatStr === 'true',
|
allowFiat: allowFiatStr === 'true',
|
||||||
fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'),
|
fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'),
|
||||||
|
paymentMethods: paymentMethodsStr != null
|
||||||
|
? paymentMethodsStr.split(',').map((m) => m.trim()).filter(Boolean)
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ export interface EventExtra {
|
||||||
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
|
||||||
|
* means the legacy rule: Lightning always, fiat iff `allow_fiat`.
|
||||||
|
* Same shape as upstream v2's `extra.payment_methods`. */
|
||||||
|
payment_methods?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventTicketExtra {
|
export interface EventTicketExtra {
|
||||||
|
|
@ -165,17 +169,22 @@ export interface CreateEventRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Body for `POST /tickets/{event_id}`. Either `user_id` OR the
|
* Body for `POST /tickets/{event_id}`. Identity is `user_id` (logged-in
|
||||||
* `name`+`email` pair is required (backend root_validator enforces
|
* buyer, optionally with `email` for ticket delivery) OR the
|
||||||
* mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM
|
* `name`+`email` pair (guest buyer). `nostr_identifier` opts the buyer
|
||||||
* delivery when the event has nostr_notifications enabled. The
|
* into Nostr DM delivery when the event has nostr_notifications
|
||||||
* `payment_method` + `fiat_provider` pair selects between Lightning
|
* enabled. The `payment_method` + `fiat_provider` pair selects between
|
||||||
* and fiat checkout.
|
* Lightning and fiat checkout. `frontend_url` is this app's root so the
|
||||||
|
* backend can build the Stripe return URLs and the emailed ticket link
|
||||||
|
* 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
|
||||||
|
* on the LNbits instance's CORS allowlist or the backend 400s. */
|
||||||
|
frontend_url?: string
|
||||||
promo_code?: string
|
promo_code?: string
|
||||||
refund_address?: string
|
refund_address?: string
|
||||||
nostr_identifier?: string
|
nostr_identifier?: string
|
||||||
|
|
@ -184,3 +193,33 @@ export interface CreateTicketRequest {
|
||||||
/** Number of tickets on this invoice (backend bounds 1..10). */
|
/** Number of tickets on this invoice (backend bounds 1..10). */
|
||||||
quantity?: number
|
quantity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonymous view of a ticket from `GET /tickets/{ticket_id}`. The
|
||||||
|
* backend deliberately omits id / email / user_id; the id is whatever
|
||||||
|
* the caller asked for.
|
||||||
|
*/
|
||||||
|
export interface PublicTicket {
|
||||||
|
eventId: string
|
||||||
|
name: string | null
|
||||||
|
registered: boolean
|
||||||
|
paid: boolean
|
||||||
|
time: string
|
||||||
|
regTimestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationDeliveryResult {
|
||||||
|
attempted: boolean
|
||||||
|
sent: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured response of `POST /tickets/{ticket_id}/resend-email`
|
||||||
|
* (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8).
|
||||||
|
*/
|
||||||
|
export interface TicketResendResult {
|
||||||
|
ticket: EventTicket
|
||||||
|
email: NotificationDeliveryResult
|
||||||
|
nostr: NotificationDeliveryResult
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue