Allow Lightning invoice reuse and re-payment from the dashboard.
Store LNbits invoice data on payments, add an invoice endpoint that reuses valid invoices or regenerates expired ones, and wire the booking payment page to fetch and display invoices via a shared watcher hook. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -36,6 +36,11 @@ export interface CreateInvoiceParams {
|
||||
extra?: Record<string, any>; // 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
|
||||
*/
|
||||
|
||||
@@ -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<any>(
|
||||
(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<any>(
|
||||
(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<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
const groupPayments = await dbAll<any>(
|
||||
(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<any>(
|
||||
(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)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<BookingStep>('form');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [bookingResult, setBookingResult] = useState<BookingResult | null>(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
|
||||
|
||||
@@ -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<PaymentStep>('loading');
|
||||
const [markingPaid, setMarkingPaid] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightningInvoice, setLightningInvoice] = useState<LightningInvoice | null>(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 (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-orange-100 flex items-center justify-center mx-auto mb-6">
|
||||
<ClockIcon className="w-10 h-10 text-orange-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? 'La factura expiró' : 'Invoice expired'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{locale === 'es'
|
||||
? 'Genera una nueva factura Lightning para continuar con el pago.'
|
||||
: 'Generate a new Lightning invoice to continue with payment.'}
|
||||
</p>
|
||||
<Button onClick={fetchLightningInvoice} isLoading={fetchingInvoice} size="lg">
|
||||
{locale === 'es' ? 'Generar Nueva Factura' : 'Generate New Invoice'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!lightningInvoice) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
<p className="mt-4 text-gray-600">
|
||||
{locale === 'es' ? 'Preparando tu factura...' : 'Preparing your invoice...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PayingStep invoice={lightningInvoice} qrCode={ticket.qrCode} locale={locale} />;
|
||||
}
|
||||
|
||||
// Manual payment step - show payment details and "I have paid" button
|
||||
if (step === 'manual_payment' && ticket && paymentConfig) {
|
||||
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
||||
|
||||
+8
-10
@@ -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<typeof setTimeout> | 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]);
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user