import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; import { db, dbGet, dbAll, tickets, payments } from '../db/index.js'; import { eq } from 'drizzle-orm'; import { getNow } from '../lib/utils.js'; import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js'; import emailService from '../lib/email.js'; const lnbitsRouter = new Hono(); // Store for active SSE connections (ticketId -> Set of response writers) const activeConnections = new Map void>>(); // Store for active background checkers (ticketId -> intervalId) const activeCheckers = new Map(); /** * LNbits webhook payload structure */ interface LNbitsWebhookPayload { payment_hash: string; payment_request?: string; amount: number; memo?: string; status: string; preimage?: string; extra?: { ticketId?: string; eventId?: string; [key: string]: any; }; } /** * Notify all connected clients for a ticket */ 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 } }); } } /** * Start background payment checking for a ticket */ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) { // Don't start if already checking if (activeCheckers.has(ticketId)) { return; } const startTime = Date.now(); const expiryMs = expirySeconds * 1000; let checkCount = 0; console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`); const checkInterval = setInterval(async () => { checkCount++; const elapsed = Date.now() - startTime; // Stop if expired if (elapsed >= expiryMs) { console.log(`Invoice expired for ticket ${ticketId}`); clearInterval(checkInterval); activeCheckers.delete(ticketId); notifyClients(ticketId, { type: 'expired', ticketId }); return; } try { const status = await getPaymentStatus(paymentHash); if (status?.paid) { console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`); clearInterval(checkInterval); activeCheckers.delete(ticketId); await handlePaymentComplete(ticketId, paymentHash); notifyClients(ticketId, { type: 'paid', ticketId, paymentHash }); } } catch (error) { console.error(`Error checking payment for ticket ${ticketId}:`, error); } }, 3000); // Check every 3 seconds activeCheckers.set(ticketId, checkInterval); } /** * Stop background checker for a ticket */ function stopBackgroundChecker(ticketId: string) { const interval = activeCheckers.get(ticketId); if (interval) { clearInterval(interval); activeCheckers.delete(ticketId); } } /** * LNbits webhook endpoint * Called by LNbits when a payment is received */ lnbitsRouter.post('/webhook', async (c) => { try { const payload: LNbitsWebhookPayload = await c.req.json(); console.log('LNbits webhook received:', { paymentHash: payload.payment_hash, status: payload.status, amount: payload.amount, extra: payload.extra, }); // Verify the payment is actually complete by checking with LNbits const isVerified = await verifyWebhookPayment(payload.payment_hash); if (!isVerified) { console.warn('LNbits webhook payment not verified:', payload.payment_hash); return c.json({ received: true, processed: false }, 200); } const ticketId = payload.extra?.ticketId; if (!ticketId) { console.error('No ticketId in LNbits webhook extra data'); return c.json({ received: true, processed: false }, 200); } // Stop background checker since webhook confirmed payment stopBackgroundChecker(ticketId); await handlePaymentComplete(ticketId, payload.payment_hash); // Notify connected clients via SSE notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash }); return c.json({ received: true, processed: true }, 200); } catch (error) { console.error('LNbits webhook error:', error); return c.json({ error: 'Webhook processing failed' }, 500); } }); /** * Handle successful payment * Supports multi-ticket bookings - confirms all tickets in the booking */ async function handlePaymentComplete(ticketId: string, paymentHash: string) { const now = getNow(); // Get the ticket to check for booking ID const existingTicket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) ); if (!existingTicket) { console.error(`Ticket ${ticketId} not found for payment confirmation`); return; } if (existingTicket.status === 'confirmed') { console.log(`Ticket ${ticketId} already confirmed, skipping update`); return; } // Get all tickets in this booking (if multi-ticket) let ticketsToConfirm: any[] = [existingTicket]; if (existingTicket.bookingId) { // This is a multi-ticket booking - get all tickets with same bookingId ticketsToConfirm = await dbAll( (db as any) .select() .from(tickets) .where(eq((tickets as any).bookingId, existingTicket.bookingId)) ); console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`); } // Confirm all tickets in the booking for (const ticket of ticketsToConfirm) { // Update ticket status to confirmed await (db as any) .update(tickets) .set({ status: 'confirmed' }) .where(eq((tickets as any).id, ticket.id)); // Update payment status to paid await (db as any) .update(payments) .set({ status: 'paid', reference: paymentHash, paidAt: now, updatedAt: now, }) .where(eq((payments as any).ticketId, ticket.id)); console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`); } // Get primary payment for sending receipt const payment = await dbGet( (db as any) .select() .from(payments) .where(eq((payments as any).ticketId, ticketId)) ); // Send confirmation emails asynchronously // For multi-ticket bookings, send email with all ticket info Promise.all([ emailService.sendBookingConfirmation(ticketId), payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(), ]).catch(err => { console.error('[Email] Failed to send confirmation emails:', err); }); } /** * SSE endpoint for real-time payment status updates * Frontend connects here to receive instant payment notifications */ lnbitsRouter.get('/stream/:ticketId', async (c) => { const ticketId = c.req.param('ticketId'); // Verify ticket exists 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); } // 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( (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)) { startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry } return streamSSE(c, async (stream) => { // 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' }); // Keep connection alive with heartbeat const heartbeat = setInterval(async () => { try { await stream.writeSSE({ data: 'ping', event: 'heartbeat' }); } catch (e) { clearInterval(heartbeat); } }, 15000); // Clean up on disconnect stream.onAbort(() => { clearInterval(heartbeat); const connections = activeConnections.get(ticketId); if (connections) { connections.delete(sendEvent); if (connections.size === 0) { activeConnections.delete(ticketId); } } }); // Keep the stream open while (true) { await stream.sleep(30000); } }); }); /** * Get payment status for a ticket (fallback polling endpoint) */ lnbitsRouter.get('/status/: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)) ); return c.json({ ticketStatus: ticket.status, paymentStatus: payment?.status || 'unknown', isPaid: ticket.status === 'confirmed' || payment?.status === 'paid', }); }); /** * Manual payment check endpoint */ lnbitsRouter.post('/check/:paymentHash', async (c) => { const paymentHash = c.req.param('paymentHash'); try { const status = await getPaymentStatus(paymentHash); if (!status) { return c.json({ error: 'Payment not found' }, 404); } return c.json({ paymentHash, paid: status.paid, status: status.status, }); } catch (error) { console.error('Error checking payment:', error); return c.json({ error: 'Failed to check payment status' }, 500); } }); export default lnbitsRouter;