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:
@@ -9,7 +9,7 @@ import emailService from '../lib/email.js';
|
||||
const lnbitsRouter = new Hono();
|
||||
|
||||
// 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)
|
||||
const activeCheckers = new Map<string, NodeJS.Timeout>();
|
||||
@@ -34,16 +34,18 @@ interface LNbitsWebhookPayload {
|
||||
/**
|
||||
* 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);
|
||||
if (connections) {
|
||||
connections.forEach(send => {
|
||||
try {
|
||||
send(data);
|
||||
} catch (e) {
|
||||
// Connection might be closed
|
||||
}
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from(connections).map(async (send) => {
|
||||
try {
|
||||
await send(data);
|
||||
} catch (e) {
|
||||
// Connection might be closed
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +73,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
|
||||
console.log(`Invoice expired for ticket ${ticketId}`);
|
||||
clearInterval(checkInterval);
|
||||
activeCheckers.delete(ticketId);
|
||||
notifyClients(ticketId, { type: 'expired', ticketId });
|
||||
await notifyClients(ticketId, { type: 'expired', ticketId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +86,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
|
||||
activeCheckers.delete(ticketId);
|
||||
|
||||
await handlePaymentComplete(ticketId, paymentHash);
|
||||
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
|
||||
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
|
||||
}
|
||||
} catch (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);
|
||||
|
||||
// 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);
|
||||
} catch (error) {
|
||||
@@ -264,38 +266,39 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
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
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
// Start background checker if not already running
|
||||
if (payment?.reference && !activeCheckers.has(ticketId)) {
|
||||
// Start background checker if not already running (only while still pending)
|
||||
if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) {
|
||||
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) => {
|
||||
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
|
||||
if (!activeConnections.has(ticketId)) {
|
||||
activeConnections.set(ticketId, new Set());
|
||||
}
|
||||
|
||||
const sendEvent = (data: any) => {
|
||||
stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
|
||||
};
|
||||
|
||||
activeConnections.get(ticketId)!.add(sendEvent);
|
||||
|
||||
// Send initial status
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ type: 'connected', ticketId }),
|
||||
event: 'payment'
|
||||
});
|
||||
await sendEvent({ type: 'connected', ticketId });
|
||||
|
||||
// Keep connection alive with heartbeat
|
||||
const heartbeat = setInterval(async () => {
|
||||
|
||||
Reference in New Issue
Block a user