speed #25
@@ -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