Fix Lightning payment UI not updating after LNbits invoice is paid.

Ensure the booking page receives payment confirmation via a streaming SSE proxy, parallel status polling, and more reliable backend event delivery.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-06-25 04:15:02 +00:00
co-authored by Cursor
parent a6840ea953
commit f0e2de2834
4 changed files with 143 additions and 75 deletions
+31 -28
View File
@@ -9,7 +9,7 @@ import emailService from '../lib/email.js';
const lnbitsRouter = new Hono(); const lnbitsRouter = new Hono();
// Store for active SSE connections (ticketId -> Set of response writers) // Store for active SSE connections (ticketId -> Set of response writers)
const activeConnections = new Map<string, Set<(data: any) => void>>(); const activeConnections = new Map<string, Set<(data: any) => Promise<void>>>();
// Store for active background checkers (ticketId -> intervalId) // Store for active background checkers (ticketId -> intervalId)
const activeCheckers = new Map<string, NodeJS.Timeout>(); const activeCheckers = new Map<string, NodeJS.Timeout>();
@@ -34,16 +34,18 @@ interface LNbitsWebhookPayload {
/** /**
* Notify all connected clients for a ticket * Notify all connected clients for a ticket
*/ */
function notifyClients(ticketId: string, data: any) { async function notifyClients(ticketId: string, data: any) {
const connections = activeConnections.get(ticketId); const connections = activeConnections.get(ticketId);
if (connections) { if (connections) {
connections.forEach(send => { await Promise.all(
try { Array.from(connections).map(async (send) => {
send(data); try {
} catch (e) { await send(data);
// Connection might be closed } catch (e) {
} // Connection might be closed
}); }
})
);
} }
} }
@@ -71,7 +73,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
console.log(`Invoice expired for ticket ${ticketId}`); console.log(`Invoice expired for ticket ${ticketId}`);
clearInterval(checkInterval); clearInterval(checkInterval);
activeCheckers.delete(ticketId); activeCheckers.delete(ticketId);
notifyClients(ticketId, { type: 'expired', ticketId }); await notifyClients(ticketId, { type: 'expired', ticketId });
return; return;
} }
@@ -84,7 +86,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
activeCheckers.delete(ticketId); activeCheckers.delete(ticketId);
await handlePaymentComplete(ticketId, paymentHash); await handlePaymentComplete(ticketId, paymentHash);
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash }); await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
} }
} catch (error) { } catch (error) {
console.error(`Error checking payment for ticket ${ticketId}:`, error); console.error(`Error checking payment for ticket ${ticketId}:`, error);
@@ -165,7 +167,7 @@ lnbitsRouter.post('/webhook', async (c) => {
await handlePaymentComplete(ticketId, payload.payment_hash); await handlePaymentComplete(ticketId, payload.payment_hash);
// Notify connected clients via SSE // Notify connected clients via SSE
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash }); await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
return c.json({ received: true, processed: true }, 200); return c.json({ received: true, processed: true }, 200);
} catch (error) { } catch (error) {
@@ -264,38 +266,39 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
} }
// If already paid, return immediately
if (ticket.status === 'confirmed') {
return c.json({ type: 'already_paid', ticketId }, 200);
}
// Get payment to start background checker // Get payment to start background checker
const payment = await dbGet<any>( const payment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId)) (db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
); );
// Start background checker if not already running // Start background checker if not already running (only while still pending)
if (payment?.reference && !activeCheckers.has(ticketId)) { if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) {
startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
} }
// Prevent proxies/CDNs from buffering the event stream so events flush immediately.
c.header('Cache-Control', 'no-cache, no-transform');
c.header('X-Accel-Buffering', 'no');
return streamSSE(c, async (stream) => { return streamSSE(c, async (stream) => {
const sendEvent = async (data: any) => {
await stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
};
// If already paid, notify over SSE and close (EventSource can parse this).
if (ticket.status === 'confirmed') {
await sendEvent({ type: 'already_paid', ticketId });
return;
}
// Register this connection // Register this connection
if (!activeConnections.has(ticketId)) { if (!activeConnections.has(ticketId)) {
activeConnections.set(ticketId, new Set()); activeConnections.set(ticketId, new Set());
} }
const sendEvent = (data: any) => {
stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
};
activeConnections.get(ticketId)!.add(sendEvent); activeConnections.get(ticketId)!.add(sendEvent);
// Send initial status // Send initial status
await stream.writeSSE({ await sendEvent({ type: 'connected', ticketId });
data: JSON.stringify({ type: 'connected', ticketId }),
event: 'payment'
});
// Keep connection alive with heartbeat // Keep connection alive with heartbeat
const heartbeat = setInterval(async () => { const heartbeat = setInterval(async () => {
+19
View File
@@ -43,6 +43,25 @@ server {
access_log /var/log/nginx/spanglish_frontend_access.log; access_log /var/log/nginx/spanglish_frontend_access.log;
error_log /var/log/nginx/spanglish_frontend_error.log; error_log /var/log/nginx/spanglish_frontend_error.log;
# LNbits payment SSE stream - must not be buffered or events won't flush in
# real time. Regex location takes precedence over the /api prefix below.
location ~ ^/api/lnbits/stream/ {
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
# Disable buffering/caching for Server-Sent Events
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_connect_timeout 300s;
}
# Proxy /api to backend # Proxy /api to backend
location /api { location /api {
proxy_pass http://spanglish_backend; proxy_pass http://spanglish_backend;
@@ -299,71 +299,75 @@ export default function BookingPage() {
); );
}; };
// Connect to SSE for real-time payment updates // Watch for Lightning payment confirmation while on the paying step.
const connectPaymentStream = (ticketId: string) => { // SSE gives instant updates; a 3s poll runs in parallel as a safety net so a
const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; // buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI.
const eventSource = new EventSource(`${apiUrl}/api/lnbits/stream/${ticketId}`); useEffect(() => {
if (step !== 'paying' || !bookingResult?.ticketId) return;
const ticketId = bookingResult.ticketId;
let settled = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
const confirmPaid = () => {
if (settled) return;
settled = true;
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
setPaymentPending(false);
setStep('success');
};
const expire = () => {
if (settled) return;
settled = true;
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired');
setPaymentPending(false);
};
// Always same-origin so the streaming proxy route handler is used (it
// bypasses the rewrite, which buffers SSE).
const eventSource = new EventSource(`/api/lnbits/stream/${ticketId}`);
eventSource.addEventListener('payment', (event) => { eventSource.addEventListener('payment', (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse((event as MessageEvent).data);
console.log('Payment event:', data); if (data.type === 'paid' || data.type === 'already_paid') {
confirmPaid();
if (data.type === 'paid') {
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
setPaymentPending(false);
setStep('success');
eventSource.close();
} else if (data.type === 'expired') { } else if (data.type === 'expired') {
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired'); expire();
setPaymentPending(false);
eventSource.close();
} else if (data.type === 'already_paid') {
setPaymentPending(false);
setStep('success');
eventSource.close();
} }
} catch (e) { } catch (e) {
console.error('Error parsing payment event:', e); console.error('Error parsing payment event:', e);
} }
}); });
eventSource.onerror = (error) => { eventSource.onerror = () => {
console.error('SSE error:', error); // SSE failed or was closed; the poll below remains the source of truth.
// Fallback to polling if SSE fails
eventSource.close(); eventSource.close();
fallbackPoll(ticketId);
}; };
return eventSource;
};
// Fallback polling if SSE is not available
const fallbackPoll = async (ticketId: string) => {
const maxAttempts = 60;
let attempts = 0;
const poll = async () => { const poll = async () => {
attempts++;
try { try {
const status = await ticketsApi.checkPaymentStatus(ticketId); const status = await ticketsApi.checkPaymentStatus(ticketId);
if (status.isPaid) { if (status.isPaid) {
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!'); confirmPaid();
setPaymentPending(false);
setStep('success');
return; return;
} }
} catch (error) { } catch (error) {
console.error('Error checking payment status:', error); console.error('Error checking payment status:', error);
} }
if (!settled) {
if (attempts < maxAttempts && paymentPending) { pollTimer = setTimeout(poll, 3000);
setTimeout(poll, 5000);
} }
}; };
pollTimer = setTimeout(poll, 3000);
poll();
}; return () => {
settled = true;
eventSource.close();
if (pollTimer) clearTimeout(pollTimer);
};
}, [step, bookingResult?.ticketId, locale]);
// Copy invoice to clipboard // Copy invoice to clipboard
const copyInvoiceToClipboard = (invoice: string) => { const copyInvoiceToClipboard = (invoice: string) => {
@@ -464,9 +468,7 @@ export default function BookingPage() {
setBookingResult(result); setBookingResult(result);
setStep('paying'); setStep('paying');
setPaymentPending(true); setPaymentPending(true);
// Payment confirmation is handled by the paying-step watcher effect.
// Connect to SSE for real-time payment updates
connectPaymentStream(primaryTicket.id);
} else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') { } else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') {
// Manual payment methods - show payment details // Manual payment methods - show payment details
setBookingResult({ setBookingResult({
@@ -0,0 +1,44 @@
// Streaming proxy for the backend LNbits SSE endpoint.
//
// Next.js `rewrites` buffer `text/event-stream` responses, so payment events
// never reach the browser in real time when going through the same-origin proxy.
// This route handler streams the backend response body straight through with
// anti-buffering headers, so EventSource on the booking page works in dev and
// proxyless deployments. A route handler takes precedence over the rewrite.
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
export async function GET(
request: Request,
{ params }: { params: { ticketId: string } }
) {
const upstream = await fetch(
`${BACKEND_URL}/api/lnbits/stream/${params.ticketId}`,
{
headers: { Accept: 'text/event-stream' },
signal: request.signal,
}
);
// If the ticket is already confirmed the backend may answer with JSON instead
// of a stream; pass that through unchanged so the client can handle it.
const contentType = upstream.headers.get('content-type') || '';
if (!contentType.includes('text/event-stream')) {
return new Response(upstream.body, {
status: upstream.status,
headers: { 'Content-Type': contentType || 'application/json' },
});
}
return new Response(upstream.body, {
status: upstream.status,
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}