From 73aee75b5b097fb3d036f57a30f312c6efe1a655 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 22 May 2026 12:23:59 +0200 Subject: [PATCH] feat(activities): align types + API service with events v1.6.1 The backend rebase brought in PR #50 (fiat checkout + email/Nostr ticket notifications), v1.6.0 (custom notification subject/body), and PR #51 (resend-email endpoint). The webapp types lagged. Aligns the type surface in src/modules/activities/types/ticket.ts: - EventExtra (with notification toggles + custom subject/body), promo codes, conditional event config. - ActivityTicketExtra mirroring backend's TicketExtra (nostr_identifier, email/nostr notification_sent flags, refund state). - TicketedEvent + CreateEventRequest gain allow_fiat, fiat_currency, extra. - TicketPurchaseInvoice extended for fiat: paymentRequest now optional, fiatPaymentRequest + fiatProvider + isFiat added. **Closes a latent blocker**: a backend response with is_fiat=true would have lost the fiat URL during deserialization (silent crash on QR generation). - New CreateTicketRequest type for the POST /tickets/{id} body, with the v1.6.1 payment_method + fiat_provider + nostr_identifier fields. TicketApiService: - requestTicket() accepts the new optional fields (paymentMethod, fiatProvider, promoCode, refundAddress, nostrIdentifier) and deserializes the full TicketPurchaseInvoice shape including fiat. - fetchUserTickets() / validateTicket() / new resendTicketEmail() thread the extra metadata through. useTicketPurchase composable rejects fiat responses with a clear error (the QR-and-bolt11 path doesn't know fiat); the eventual UI selector will live in PurchaseTicketDialog. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/useTicketPurchase.ts | 21 ++++- .../activities/services/TicketApiService.ts | 75 ++++++++++++++- src/modules/activities/types/ticket.ts | 92 ++++++++++++++++++- 3 files changed, 178 insertions(+), 10 deletions(-) diff --git a/src/modules/activities/composables/useTicketPurchase.ts b/src/modules/activities/composables/useTicketPurchase.ts index fcb4dd4..0145fd9 100644 --- a/src/modules/activities/composables/useTicketPurchase.ts +++ b/src/modules/activities/composables/useTicketPurchase.ts @@ -98,16 +98,31 @@ export function useTicketPurchase() { currentUser.value!.id, accessToken ) + + // Backend now returns either a Lightning invoice or a fiat + // checkout URL (post-events-v1.4.0). This composable only knows + // how to drive the Lightning path; fiat would need a separate + // redirect-to-provider flow that lives in PurchaseTicketDialog + // (it has the user-visible payment-method selector). Reject the + // fiat response here so callers get a clear error instead of a + // silent broken QR. + if (invoice.isFiat || !invoice.paymentRequest) { + throw new Error( + 'This event uses fiat checkout. Use the purchase dialog ' + + 'to follow the provider link.', + ) + } + const bolt11: string = invoice.paymentRequest paymentHash.value = invoice.paymentHash - paymentRequest.value = invoice.paymentRequest + paymentRequest.value = bolt11 // Generate QR code for payment - await generateQRCode(invoice.paymentRequest) + await generateQRCode(bolt11) // Try to pay with wallet if available if (hasWalletWithBalance.value) { try { - await payWithWallet(invoice.paymentRequest) + await payWithWallet(bolt11) await startPaymentStatusCheck(eventId, invoice.paymentHash) } catch (walletError) { console.log('Wallet payment failed, falling back to manual payment:', walletError) diff --git a/src/modules/activities/services/TicketApiService.ts b/src/modules/activities/services/TicketApiService.ts index 64c5fdb..b3174a1 100644 --- a/src/modules/activities/services/TicketApiService.ts +++ b/src/modules/activities/services/TicketApiService.ts @@ -1,5 +1,8 @@ import type { ActivityTicket, + ActivityTicketExtra, + CreateTicketRequest, + PaymentMethod, TicketPurchaseInvoice, TicketPaymentStatus, TicketedEvent, @@ -49,14 +52,38 @@ export class TicketApiService { } /** - * Request a ticket purchase (creates a Lightning invoice). - * Uses POST /tickets/{event_id} with user_id in body (upstream API). + * Request a ticket purchase. Returns either a Lightning invoice + * (`paymentRequest` = bolt11) or a fiat invoice (`fiatPaymentRequest` + * = follow-the-URL string from the configured fiat provider). The + * `isFiat` flag is the discriminator. + * + * `paymentMethod` defaults to "lightning"; pass "fiat" to opt into + * the fiat path (requires the event to have `allow_fiat=true`). + * `fiatProvider` is optional — backend picks the user's configured + * default when omitted. + * + * Additional ticket metadata (promo code, refund address, nostr + * identifier for DM delivery) can be supplied via `options`. */ async requestTicket( eventId: string, userId: string, - accessToken: string + accessToken: string, + options: { + paymentMethod?: PaymentMethod + fiatProvider?: string + promoCode?: string + refundAddress?: string + nostrIdentifier?: string + } = {}, ): Promise { + const body: CreateTicketRequest = { user_id: userId } + if (options.paymentMethod) body.payment_method = options.paymentMethod + if (options.fiatProvider) body.fiat_provider = options.fiatProvider + if (options.promoCode) body.promo_code = options.promoCode + if (options.refundAddress) body.refund_address = options.refundAddress + if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier + const data = await this.request( `/events/api/v1/tickets/${eventId}`, { @@ -65,13 +92,16 @@ export class TicketApiService { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, }, - body: JSON.stringify({ user_id: userId }), + body: JSON.stringify(body), } ) return { paymentHash: data.payment_hash, - paymentRequest: data.payment_request, + paymentRequest: data.payment_request ?? undefined, + fiatPaymentRequest: data.fiat_payment_request ?? undefined, + fiatProvider: data.fiat_provider ?? undefined, + isFiat: Boolean(data.is_fiat), } } @@ -121,6 +151,7 @@ export class TicketApiService { paid: t.paid, time: t.time, regTimestamp: t.reg_timestamp, + extra: t.extra as ActivityTicketExtra | undefined, })) } @@ -144,6 +175,7 @@ export class TicketApiService { paid: t.paid, time: t.time, regTimestamp: t.reg_timestamp, + extra: t.extra as ActivityTicketExtra | undefined, })) } @@ -183,6 +215,39 @@ export class TicketApiService { }) } + /** + * Resend the ticket confirmation email for a paid ticket. Requires + * the event's wallet admin key (organizer-only). Returns the updated + * Ticket with the `email_notification_sent` flag refreshed. + * + * Endpoint added upstream in v1.6.1 (PR #51). + */ + async resendTicketEmail( + ticketId: string, + adminKey: string, + ): Promise { + const t = await this.request( + `/events/api/v1/tickets/${ticketId}/resend-email`, + { + method: 'POST', + headers: { 'X-API-KEY': adminKey }, + } + ) + return { + id: t.id, + wallet: t.wallet, + activityId: t.event, + name: t.name, + email: t.email, + userId: t.user_id, + registered: t.registered, + paid: t.paid, + time: t.time, + regTimestamp: t.reg_timestamp, + extra: t.extra as ActivityTicketExtra | undefined, + } + } + /** * Probe whether the current user has LNbits admin privileges. The * `/all` endpoint is `check_admin`-gated, so a 200 means "admin", diff --git a/src/modules/activities/types/ticket.ts b/src/modules/activities/types/ticket.ts index 5384ddf..bd1985c 100644 --- a/src/modules/activities/types/ticket.ts +++ b/src/modules/activities/types/ticket.ts @@ -1,7 +1,44 @@ /** - * Database-backed ticket types (via LNbits events extension) + * Database-backed ticket types (via LNbits events extension). + * + * Wire-format types — names match the snake_case fields the events + * extension serves over HTTP. Camel-cased aliases (e.g. ActivityTicket + * below) are the webapp-internal view models after adapter conversion. */ +export interface PromoCode { + code: string + discount_percent: number + active: boolean +} + +/** + * EventExtra mirrors the EventExtra Pydantic model in + * `events/models.py`. Carries promo codes, conditional-event config, + * and the per-event notification toggles + custom subject/body added + * in upstream v1.4.0 (PR #50) and v1.6.0. + */ +export interface EventExtra { + promo_codes: PromoCode[] + conditional: boolean + min_tickets: number + email_notifications: boolean + nostr_notifications: boolean + notification_subject: string + notification_body: string +} + +export interface ActivityTicketExtra { + 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 ActivityTicket { id: string wallet: string @@ -21,19 +58,40 @@ export interface ActivityTicket { time: string /** Registration/scan timestamp */ regTimestamp: string + /** Optional metadata — promo code applied, sats paid, notification + * delivery flags, refund state. May be absent on older tickets. */ + extra?: ActivityTicketExtra } export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled' +export type PaymentMethod = 'lightning' | 'fiat' + export interface TicketPurchaseRequest { activityId: 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 + /** Specific fiat provider id (e.g. "stripe"). Backend picks the + * user's default if omitted. */ + fiatProvider?: string } +/** + * Server response from `POST /tickets/{event_id}`. Either Lightning + * (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL + * the buyer follows to complete payment with `fiatProvider`). + * `isFiat` is the discriminator. + */ export interface TicketPurchaseInvoice { paymentHash: string - paymentRequest: string + paymentRequest?: string + fiatPaymentRequest?: string + fiatProvider?: string + isFiat: boolean } export interface TicketPaymentStatus { @@ -58,6 +116,10 @@ export interface TicketedEvent { event_start_date: string event_end_date: string | null currency: string + /** Whether the event accepts fiat payments. Upstream v1.4.0+. */ + allow_fiat: boolean + /** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */ + fiat_currency: string amount_tickets: number price_per_ticket: number time: string @@ -65,6 +127,7 @@ export interface TicketedEvent { banner: string | null location: string | null categories: string[] + extra: EventExtra status: string } @@ -76,9 +139,34 @@ export interface CreateEventRequest { 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 +} + +/** + * Body for `POST /tickets/{event_id}`. Either `user_id` OR the + * `name`+`email` pair is required (backend root_validator enforces + * mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM + * delivery when the event has nostr_notifications enabled. The + * `payment_method` + `fiat_provider` pair selects between Lightning + * and fiat checkout. + */ +export interface CreateTicketRequest { + name?: string + email?: string + user_id?: string + promo_code?: string + refund_address?: string + nostr_identifier?: string + payment_method?: PaymentMethod + fiat_provider?: string }