first commit
This commit is contained in:
340
backend/src/routes/lnbits.ts
Normal file
340
backend/src/routes/lnbits.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { db, 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<string, Set<(data: any) => void>>();
|
||||
|
||||
// Store for active background checkers (ticketId -> intervalId)
|
||||
const activeCheckers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
const now = getNow();
|
||||
|
||||
// Check if already confirmed to avoid duplicate updates
|
||||
const existingTicket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
|
||||
if (existingTicket?.status === 'confirmed') {
|
||||
console.log(`Ticket ${ticketId} already confirmed, skipping update`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, ticketId));
|
||||
|
||||
// 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, ticketId));
|
||||
|
||||
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||
|
||||
// Get payment for sending receipt
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
|
||||
// Send confirmation emails asynchronously
|
||||
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 (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
|
||||
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 (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
|
||||
// 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 (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user