diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 03c7ca6..b998a3b 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -238,6 +238,15 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`); } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); + } catch (e) { /* column may already exist */ } // Invoices table await (db as any).run(sql` @@ -719,6 +728,15 @@ async function migrate() { try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`); } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); + } catch (e) { /* column may already exist */ } // Invoices table await (db as any).execute(sql` diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index a79e2df..ee75c02 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -121,6 +121,9 @@ export const sqlitePayments = sqliteTable('payments', { currency: text('currency').notNull().default('PYG'), status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'), reference: text('reference'), + lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call + lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires + lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion) userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid" payerName: text('payer_name'), // Name of payer if different from attendee paidAt: text('paid_at'), @@ -476,6 +479,9 @@ export const pgPayments = pgTable('payments', { currency: varchar('currency', { length: 10 }).notNull().default('PYG'), status: varchar('status', { length: 20 }).notNull().default('pending'), reference: varchar('reference', { length: 255 }), + lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call + lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires + lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion) userMarkedPaidAt: timestamp('user_marked_paid_at'), payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee paidAt: timestamp('paid_at'), diff --git a/backend/src/lib/lnbits.ts b/backend/src/lib/lnbits.ts index 66cc131..bd2bc87 100644 --- a/backend/src/lib/lnbits.ts +++ b/backend/src/lib/lnbits.ts @@ -36,6 +36,11 @@ export interface CreateInvoiceParams { extra?: Record; // Additional metadata } +// How long a booking's Lightning invoice is valid for, in seconds. Shared +// between initial booking creation and invoice regeneration so both produce +// invoices with the same lifetime. +export const LNBITS_INVOICE_EXPIRY_SECONDS = 900; // 15 minutes + /** * Check if LNbits is configured */ diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index 1493071..af34907 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -1,9 +1,15 @@ import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; -import { db, dbGet, dbAll, tickets, payments } from '../db/index.js'; -import { eq, and } from 'drizzle-orm'; -import { getNow } from '../lib/utils.js'; -import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js'; +import { db, dbGet, dbAll, tickets, payments, events } from '../db/index.js'; +import { eq, and, inArray } from 'drizzle-orm'; +import { getNow, toDbDate } from '../lib/utils.js'; +import { + verifyWebhookPayment, + getPaymentStatus, + createInvoice, + isLNbitsConfigured, + LNBITS_INVOICE_EXPIRY_SECONDS, +} from '../lib/lnbits.js'; import emailService from '../lib/email.js'; import { getPubSub } from '../lib/stores/pubsub.js'; import { getLock } from '../lib/stores/lock.js'; @@ -391,6 +397,145 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { }); }); +/** + * Get a Lightning invoice for a ticket to pay or re-pay. + * + * Reuses the stored invoice if it still has more than 5 minutes of validity + * left; otherwise generates a fresh one from LNbits. This is what lets a user + * come back to an unpaid Lightning booking later (e.g. from "Pay now" on the + * dashboard) instead of hitting a dead end. + */ +lnbitsRouter.post('/invoice/:ticketId', async (c) => { + const ticketId = c.req.param('ticketId'); + + const ticket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) + ); + if (!ticket) { + return c.json({ error: 'Ticket not found' }, 404); + } + + const payment = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId)) + ); + if (!payment) { + return c.json({ error: 'Payment not found' }, 404); + } + + if (payment.provider !== 'lightning') { + return c.json({ error: 'This booking is not a Lightning payment' }, 400); + } + + if (ticket.status === 'confirmed' || payment.status === 'paid') { + return c.json({ alreadyPaid: true }); + } + + if (ticket.status !== 'pending' || payment.status !== 'pending') { + return c.json({ + error: 'This booking is no longer active. Please make a new booking.', + }, 400); + } + + // Gather every ticket/payment in the booking group - a multi-ticket booking + // shares a single Lightning invoice for the combined total. + let groupTickets: any[] = [ticket]; + if (ticket.bookingId) { + groupTickets = await dbAll( + (db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + } + const groupPayments = await dbAll( + (db as any).select().from(payments).where(inArray((payments as any).ticketId, groupTickets.map((t: any) => t.id))) + ); + const invoiceHolder = groupPayments.find((p: any) => p.reference) || payment; + const totalAmount = groupPayments.reduce((sum: number, p: any) => sum + Number(p.amount), 0); + const currency = invoiceHolder.currency; + + const now = Date.now(); + const FIVE_MIN_MS = 5 * 60 * 1000; + const expiresAtMs = invoiceHolder.lnbitsExpiresAt ? new Date(invoiceHolder.lnbitsExpiresAt).getTime() : 0; + + if (invoiceHolder.reference && invoiceHolder.lnbitsInvoice && expiresAtMs - now > FIVE_MIN_MS) { + return c.json({ + invoice: { + paymentHash: invoiceHolder.reference, + paymentRequest: invoiceHolder.lnbitsInvoice, + amount: invoiceHolder.lnbitsAmountSats || 0, + fiatAmount: totalAmount, + fiatCurrency: currency, + expiresAt: invoiceHolder.lnbitsExpiresAt, + }, + reused: true, + }); + } + + // No usable stored invoice (never created, expired, or expiring soon) - get a fresh one. + if (!isLNbitsConfigured()) { + return c.json({ error: 'Bitcoin Lightning payments are not available at this time' }, 400); + } + + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)) + ); + + const apiUrl = process.env.API_URL || 'http://localhost:3001'; + const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || ''; + const webhookUrl = webhookSecret + ? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}` + : `${apiUrl}/api/lnbits/webhook`; + const attendeeName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); + + try { + const lnbitsInvoice = await createInvoice({ + amount: totalAmount, + unit: currency, + memo: `Spanglish: ${event?.title || 'Event'} - ${attendeeName}${groupTickets.length > 1 ? ` (${groupTickets.length} tickets)` : ''}`, + webhookUrl, + expiry: LNBITS_INVOICE_EXPIRY_SECONDS, + extra: { + ticketId: invoiceHolder.ticketId, + bookingId: ticket.bookingId || null, + ticketIds: groupTickets.map((t: any) => t.id), + eventId: ticket.eventId, + eventTitle: event?.title, + attendeeName, + attendeeEmail: ticket.attendeeEmail, + ticketCount: groupTickets.length, + }, + }); + + const lnbitsExpiresAt = toDbDate(new Date(now + LNBITS_INVOICE_EXPIRY_SECONDS * 1000)); + + await (db as any) + .update(payments) + .set({ + reference: lnbitsInvoice.paymentHash, + lnbitsInvoice: lnbitsInvoice.paymentRequest, + lnbitsExpiresAt, + lnbitsAmountSats: lnbitsInvoice.amount, + updatedAt: getNow(), + }) + .where(eq((payments as any).id, invoiceHolder.id)); + + return c.json({ + invoice: { + paymentHash: lnbitsInvoice.paymentHash, + paymentRequest: lnbitsInvoice.paymentRequest, + amount: lnbitsInvoice.amount, + fiatAmount: lnbitsInvoice.fiatAmount ?? totalAmount, + fiatCurrency: lnbitsInvoice.fiatCurrency ?? currency, + expiresAt: lnbitsExpiresAt, + }, + reused: false, + }); + } catch (error: any) { + console.error('Failed to create Lightning invoice:', error); + return c.json({ + error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`, + }, 500); + } +}); + /** * Get payment status for a ticket (fallback polling endpoint) */ diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 8e923c7..08fcde1 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -4,8 +4,8 @@ import { z } from 'zod'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js'; import { eq, and, or, sql, inArray } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; -import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; +import { generateId, generateTicketCode, getNow, toDbDate, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; +import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; import emailService from '../lib/email.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; @@ -418,7 +418,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc. memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`, webhookUrl, - expiry: 900, // 15 minutes expiry for faster UX + expiry: LNBITS_INVOICE_EXPIRY_SECONDS, // 15 minutes expiry for faster UX extra: { ticketId: primaryTicket.id, bookingId: ticketCount > 1 ? bookingId : null, @@ -430,13 +430,22 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { ticketCount, }, }); - - // Update primary payment with LNbits payment hash reference + + const lnbitsExpiresAt = toDbDate(new Date(Date.now() + LNBITS_INVOICE_EXPIRY_SECONDS * 1000)); + + // Update primary payment with the LNbits invoice - the BOLT11 string and + // expiry are persisted so the "Pay now" page can redisplay this same + // invoice later instead of erroring out on an unpaid Lightning booking. await (db as any) .update(payments) - .set({ reference: lnbitsInvoice.paymentHash }) + .set({ + reference: lnbitsInvoice.paymentHash, + lnbitsInvoice: lnbitsInvoice.paymentRequest, + lnbitsExpiresAt, + lnbitsAmountSats: lnbitsInvoice.amount, + }) .where(eq((payments as any).id, primaryPayment.id)); - + (primaryPayment as any).reference = lnbitsInvoice.paymentHash; } catch (error: any) { console.error('Failed to create Lightning invoice:', error); diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 8296779..5533f3b 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -16,7 +16,7 @@ import type { PaymentMethod, } from './_types'; import { buildPaymentMethods, formatRuc, rucPattern } from './_logic/booking'; -import { useLightningWatcher } from './_hooks/useLightningWatcher'; +import { useLightningWatcher } from '@/hooks/useLightningWatcher'; import { PayingStep } from './_steps/PayingStep'; import { ManualPaymentStep } from './_steps/ManualPaymentStep'; import { PendingApprovalStep } from './_steps/PendingApprovalStep'; @@ -35,7 +35,6 @@ export default function BookingPage() { const [step, setStep] = useState('form'); const [submitting, setSubmitting] = useState(false); const [bookingResult, setBookingResult] = useState(null); - const [, setPaymentPending] = useState(false); const [markingPaid, setMarkingPaid] = useState(false); // State for payer name (when paid under different name) @@ -245,7 +244,13 @@ export default function BookingPage() { }; // Watch for Lightning payment confirmation while on the paying step. - useLightningWatcher(step, bookingResult?.ticketId, locale, setPaymentPending, setStep); + useLightningWatcher( + step === 'paying', + bookingResult?.ticketId, + locale, + () => setStep('success'), + () => {} + ); // Handle "I Have Paid" button click const handleMarkPaymentSent = async () => { @@ -330,7 +335,6 @@ export default function BookingPage() { }; setBookingResult(result); setStep('paying'); - setPaymentPending(true); // Payment confirmation is handled by the paying-step watcher effect. } else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') { // Manual payment methods - show payment details diff --git a/frontend/src/app/(public)/booking/[ticketId]/page.tsx b/frontend/src/app/(public)/booking/[ticketId]/page.tsx index 5a371ed..f883eb5 100644 --- a/frontend/src/app/(public)/booking/[ticketId]/page.tsx +++ b/frontend/src/app/(public)/booking/[ticketId]/page.tsx @@ -1,15 +1,17 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; -import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api'; +import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig, LightningInvoice } from '@/lib/api'; import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils'; +import { useLightningWatcher } from '@/hooks/useLightningWatcher'; +import { PayingStep } from '@/app/(public)/book/[eventId]/_steps/PayingStep'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; -import { - CheckCircleIcon, +import { + CheckCircleIcon, ClockIcon, XCircleIcon, TicketIcon, @@ -22,7 +24,7 @@ import { } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; -type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error'; +type PaymentStep = 'loading' | 'manual_payment' | 'lightning_payment' | 'pending_approval' | 'confirmed' | 'error'; export default function BookingPaymentPage() { const params = useParams(); @@ -33,10 +35,36 @@ export default function BookingPaymentPage() { const [step, setStep] = useState('loading'); const [markingPaid, setMarkingPaid] = useState(false); const [error, setError] = useState(null); + const [lightningInvoice, setLightningInvoice] = useState(null); + const [invoiceExpired, setInvoiceExpired] = useState(false); + const [fetchingInvoice, setFetchingInvoice] = useState(false); const ticketId = params.ticketId as string; const requestedStep = searchParams.get('step'); + // Get (or refresh) the Lightning invoice for this ticket - reuses the + // stored invoice if still valid, otherwise the backend generates a new one. + const fetchLightningInvoice = useCallback(async () => { + setFetchingInvoice(true); + try { + const result = await ticketsApi.getLightningInvoice(ticketId); + if (result.alreadyPaid) { + setStep('confirmed'); + return; + } + if (result.invoice) { + setLightningInvoice(result.invoice); + setInvoiceExpired(false); + setStep('lightning_payment'); + } + } catch (err: any) { + setError(err.message || 'Failed to load Lightning invoice'); + setStep('error'); + } finally { + setFetchingInvoice(false); + } + }, [ticketId]); + // Fetch ticket and payment config useEffect(() => { if (!ticketId) return; @@ -45,7 +73,7 @@ export default function BookingPaymentPage() { try { // Get ticket with event and payment info const { ticket: ticketData } = await ticketsApi.getById(ticketId); - + if (!ticketData) { setError('Booking not found'); setStep('error'); @@ -54,10 +82,24 @@ export default function BookingPaymentPage() { setTicket(ticketData); - // Only proceed for manual payment methods const paymentMethod = ticketData.payment?.provider; + + if (paymentMethod === 'lightning') { + if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') { + setStep('confirmed'); + } else if (ticketData.status === 'cancelled') { + setError(locale === 'es' + ? 'Esta reserva ya no está activa. Por favor realiza una nueva reserva.' + : 'This booking is no longer active. Please make a new booking.'); + setStep('error'); + } else { + await fetchLightningInvoice(); + } + return; + } + if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) { - // Not a manual payment method, redirect to success page or show appropriate state + // Not a manual or Lightning payment method, show appropriate state if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') { setStep('confirmed'); } else { @@ -75,7 +117,7 @@ export default function BookingPaymentPage() { // Determine which step to show based on payment status const paymentStatus = ticketData.payment?.status; - + if (paymentStatus === 'paid' || ticketData.status === 'confirmed') { setStep('confirmed'); } else if (paymentStatus === 'pending_approval') { @@ -96,6 +138,15 @@ export default function BookingPaymentPage() { loadBookingData(); }, [ticketId]); + // Watch for Lightning payment confirmation while the invoice is on screen. + useLightningWatcher( + step === 'lightning_payment' && !invoiceExpired, + ticketId, + locale, + () => setStep('confirmed'), + () => setInvoiceExpired(true) + ); + // Handle "I Have Paid" button click const handleMarkPaymentSent = async () => { if (!ticket) return; @@ -301,6 +352,49 @@ export default function BookingPaymentPage() { ); } + // Lightning payment step - show the (reused or freshly generated) invoice + if (step === 'lightning_payment' && ticket) { + if (invoiceExpired) { + return ( +
+
+ +
+ +
+

+ {locale === 'es' ? 'La factura expiró' : 'Invoice expired'} +

+

+ {locale === 'es' + ? 'Genera una nueva factura Lightning para continuar con el pago.' + : 'Generate a new Lightning invoice to continue with payment.'} +

+ +
+
+
+ ); + } + + if (!lightningInvoice) { + return ( +
+
+
+

+ {locale === 'es' ? 'Preparando tu factura...' : 'Preparing your invoice...'} +

+
+
+ ); + } + + return ; + } + // Manual payment step - show payment details and "I have paid" button if (step === 'manual_payment' && ticket && paymentConfig) { const isBankTransfer = ticket.payment?.provider === 'bank_transfer'; diff --git a/frontend/src/app/(public)/book/[eventId]/_hooks/useLightningWatcher.ts b/frontend/src/hooks/useLightningWatcher.ts similarity index 84% rename from frontend/src/app/(public)/book/[eventId]/_hooks/useLightningWatcher.ts rename to frontend/src/hooks/useLightningWatcher.ts index c36f5a0..eebc6f6 100644 --- a/frontend/src/app/(public)/book/[eventId]/_hooks/useLightningWatcher.ts +++ b/frontend/src/hooks/useLightningWatcher.ts @@ -1,22 +1,21 @@ import { useEffect } from 'react'; import toast from 'react-hot-toast'; import { ticketsApi } from '@/lib/api'; -import type { BookingStep } from '../_types'; /** - * Watch for Lightning payment confirmation while on the paying step. + * Watch for Lightning payment confirmation while an invoice is on screen. * SSE gives instant updates; a 3s poll runs in parallel as a safety net so a * buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI. */ export function useLightningWatcher( - step: BookingStep, + active: boolean, ticketId: string | undefined, locale: string, - setPaymentPending: (value: boolean) => void, - setStep: (value: BookingStep) => void + onPaid: () => void, + onExpired: () => void ) { useEffect(() => { - if (step !== 'paying' || !ticketId) return; + if (!active || !ticketId) return; let settled = false; let pollTimer: ReturnType | null = null; @@ -25,15 +24,14 @@ export function useLightningWatcher( if (settled) return; settled = true; toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!'); - setPaymentPending(false); - setStep('success'); + onPaid(); }; const expire = () => { if (settled) return; settled = true; toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired'); - setPaymentPending(false); + onExpired(); }; // Always same-origin so the streaming proxy route handler is used (it @@ -79,5 +77,5 @@ export function useLightningWatcher( eventSource.close(); if (pollTimer) clearTimeout(pollTimer); }; - }, [step, ticketId, locale]); + }, [active, ticketId, locale]); } diff --git a/frontend/src/lib/api/tickets.ts b/frontend/src/lib/api/tickets.ts index dd35357..f7e5f03 100644 --- a/frontend/src/lib/api/tickets.ts +++ b/frontend/src/lib/api/tickets.ts @@ -6,6 +6,7 @@ import type { TicketValidationResult, TicketSearchResult, LiveSearchResult, + LightningInvoice, } from './types'; export const ticketsApi = { @@ -137,6 +138,14 @@ export const ticketsApi = { `/api/lnbits/status/${ticketId}` ), + // Get a Lightning invoice to pay/re-pay a ticket - reuses the stored invoice + // if it's still valid, otherwise generates a fresh one. + getLightningInvoice: (ticketId: string) => + fetchApi<{ invoice?: LightningInvoice; reused?: boolean; alreadyPaid?: boolean }>( + `/api/lnbits/invoice/${ticketId}`, + { method: 'POST' } + ), + // Get PDF download URL (returns the URL, not the PDF itself) getPdfUrl: (id: string) => `${API_BASE}/api/tickets/${id}/pdf`, }; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index fd0d6e6..8d5c43a 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -25,6 +25,15 @@ export interface Event { updatedAt: string; } +export interface LightningInvoice { + paymentHash: string; + paymentRequest: string; // BOLT11 invoice + amount: number; // Amount in satoshis + fiatAmount?: number; // Original fiat amount + fiatCurrency?: string; // Original fiat currency + expiresAt?: string; +} + export interface Ticket { id: string; bookingId?: string; // Groups multiple tickets from same booking