- Replace the three Attendees-tab modals (Manual Ticket / Add at Door / Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented control, shared fields, "Check in now" for all types, and a live "what happens" preview, backed by one POST /api/tickets/admin/add. - Add tickets.payment_status (paid | unpaid | comp) with a backfill migration; keep it in sync on every payment-settlement path (mark-paid, admin approval, Lightning, free bookings, hold recovery). - Show Paid/Unpaid/Comp badges in the attendee list, count only paid tickets toward revenue, let unpaid tickets be resolved via Mark Paid, and flag unpaid tickets with their balance due in the door scanner. - Replace the per-page useStatsPrivacy hook with an admin-wide PrivacyContext + SensitiveValue mask, toggled from the admin layout. - Add server-side pagination with page-size options to the users page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
635 lines
21 KiB
TypeScript
635 lines
21 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { streamSSE } from 'hono/streaming';
|
|
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, LockUnavailableError } from '../lib/stores/lock.js';
|
|
|
|
const lnbitsRouter = new Hono();
|
|
|
|
// Local SSE connections owned by THIS process (ticketId -> Set of response writers).
|
|
// Cross-instance delivery is handled by pub/sub: see paymentChannel below.
|
|
const activeConnections = new Map<string, Set<(data: any) => Promise<void>>>();
|
|
|
|
// Pub/sub unsubscribe handles per ticket (one local subscription per ticket).
|
|
const channelUnsubs = new Map<string, () => void>();
|
|
|
|
// Store for active background checkers (ticketId -> intervalId)
|
|
const activeCheckers = new Map<string, NodeJS.Timeout>();
|
|
|
|
/** Pub/sub channel that carries payment events for a ticket. */
|
|
function paymentChannel(ticketId: string): string {
|
|
return `payment:${ticketId}`;
|
|
}
|
|
|
|
/**
|
|
* 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 every client for a ticket across all instances.
|
|
*
|
|
* Publishes to the ticket's pub/sub channel. In single-instance / in-memory
|
|
* mode this is an in-process broadcast; with Redis it reaches whichever
|
|
* instance(s) actually hold the SSE socket(s) for this ticket.
|
|
*/
|
|
async function notifyClients(ticketId: string, data: any) {
|
|
await getPubSub().publish(paymentChannel(ticketId), data);
|
|
}
|
|
|
|
/**
|
|
* Deliver an event to the SSE sockets held by THIS process for a ticket.
|
|
* Invoked by the pub/sub subscription handler.
|
|
*/
|
|
async function deliverLocal(ticketId: string, data: any) {
|
|
const connections = activeConnections.get(ticketId);
|
|
if (connections) {
|
|
await Promise.all(
|
|
Array.from(connections).map(async (send) => {
|
|
try {
|
|
await send(data);
|
|
} catch (e) {
|
|
// Connection might be closed
|
|
}
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
// Distributed lock tokens for the per-ticket poller (ticketId -> token).
|
|
const checkerLockTokens = new Map<string, string>();
|
|
|
|
/** Release the per-ticket poller lock if this process holds it. */
|
|
function releaseCheckerLock(ticketId: string) {
|
|
const token = checkerLockTokens.get(ticketId);
|
|
if (token) {
|
|
checkerLockTokens.delete(ticketId);
|
|
void getLock().release(`checker:${ticketId}`, token);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start background payment checking for a ticket.
|
|
*
|
|
* Only one instance should poll LNbits per ticket, so we take a distributed
|
|
* lock for the lifetime of the poll. Other instances skip polling and instead
|
|
* receive the result via pub/sub. With no Redis configured the lock is a local
|
|
* no-op and behavior matches the original single-instance polling.
|
|
*/
|
|
async function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
|
|
// Don't start if already checking on this instance
|
|
if (activeCheckers.has(ticketId)) {
|
|
return;
|
|
}
|
|
|
|
const expiryMs = expirySeconds * 1000;
|
|
|
|
let lockToken: string | null = null;
|
|
let lockUnavailable = false;
|
|
try {
|
|
lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
|
|
} catch (err) {
|
|
if (!(err instanceof LockUnavailableError)) throw err;
|
|
// Fail open: in webhook-less deployments this poller is the only way a
|
|
// Lightning payment gets confirmed, so a Redis outage must not stop it.
|
|
// Duplicate pollers across replicas are harmless because
|
|
// handlePaymentComplete only acts on rows it actually transitions.
|
|
console.warn(`[lnbits] lock backend unavailable, polling ticket ${ticketId} without lock:`, err.message);
|
|
lockUnavailable = true;
|
|
}
|
|
if (!lockToken && !lockUnavailable) {
|
|
// Another instance is already polling this ticket.
|
|
return;
|
|
}
|
|
if (lockToken) {
|
|
checkerLockTokens.set(ticketId, lockToken);
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
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);
|
|
releaseCheckerLock(ticketId);
|
|
await 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);
|
|
releaseCheckerLock(ticketId);
|
|
|
|
await handlePaymentComplete(ticketId, paymentHash);
|
|
await 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);
|
|
releaseCheckerLock(ticketId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* LNbits webhook endpoint
|
|
* Called by LNbits when a payment is received
|
|
*/
|
|
lnbitsRouter.post('/webhook', async (c) => {
|
|
try {
|
|
// Optional shared-secret gate: if LNBITS_WEBHOOK_SECRET is configured, the
|
|
// webhook URL must carry a matching ?token=... (set when the invoice is created).
|
|
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
|
|
if (webhookSecret) {
|
|
const provided = c.req.query('token') || c.req.header('x-webhook-secret') || '';
|
|
if (provided !== webhookSecret) {
|
|
console.warn('LNbits webhook rejected: invalid or missing secret');
|
|
return c.json({ received: true, processed: false }, 401);
|
|
}
|
|
}
|
|
|
|
const payload: LNbitsWebhookPayload = await c.req.json();
|
|
|
|
// Log identifiers only (no full payload / PII)
|
|
console.log('LNbits webhook received:', {
|
|
paymentHash: payload.payment_hash,
|
|
status: payload.status,
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
|
|
// CRITICAL: bind the paid hash to this ticket's own invoice. Without this, a
|
|
// valid paid hash from any other invoice could be replayed with an arbitrary
|
|
// ticketId to confirm tickets for free.
|
|
const ticketPayment = await dbGet<any>(
|
|
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
|
);
|
|
if (!ticketPayment || ticketPayment.reference !== payload.payment_hash) {
|
|
console.warn('LNbits webhook rejected: payment hash does not match the ticket invoice', {
|
|
ticketId,
|
|
paymentHash: payload.payment_hash,
|
|
});
|
|
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
|
|
await 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<any>(
|
|
(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 (idempotent: only flip pending -> confirmed)
|
|
let transitioned = 0;
|
|
for (const ticket of ticketsToConfirm) {
|
|
const result: any = await (db as any)
|
|
.update(tickets)
|
|
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
|
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
|
|
transitioned += result?.changes ?? result?.rowCount ?? 0;
|
|
|
|
await (db as any)
|
|
.update(payments)
|
|
.set({
|
|
status: 'paid',
|
|
reference: paymentHash,
|
|
paidAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending')));
|
|
|
|
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
|
}
|
|
|
|
// Only the caller that actually flipped rows sends the emails. The webhook
|
|
// and the background poller (and pollers on multiple replicas during a Redis
|
|
// outage) can all land here; without this guard they would each email.
|
|
if (transitioned === 0) {
|
|
console.log(`Ticket ${ticketId} was already confirmed by a concurrent caller, skipping emails`);
|
|
return;
|
|
}
|
|
|
|
// Get primary payment for sending receipt
|
|
const payment = await dbGet<any>(
|
|
(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<any>(
|
|
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
|
);
|
|
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
// 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 (only while still pending)
|
|
if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) {
|
|
await 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. The first local connection for a ticket also
|
|
// subscribes to the ticket's pub/sub channel so events published by any
|
|
// instance (webhook or background checker) are delivered to these sockets.
|
|
if (!activeConnections.has(ticketId)) {
|
|
activeConnections.set(ticketId, new Set());
|
|
const unsub = await getPubSub().subscribe(paymentChannel(ticketId), (data) => {
|
|
void deliverLocal(ticketId, data);
|
|
});
|
|
channelUnsubs.set(ticketId, unsub);
|
|
}
|
|
activeConnections.get(ticketId)!.add(sendEvent);
|
|
|
|
// Send initial status
|
|
await sendEvent({ type: 'connected', ticketId });
|
|
|
|
// Keep connection alive with heartbeat
|
|
const heartbeat = setInterval(async () => {
|
|
try {
|
|
await stream.writeSSE({ data: 'ping', event: 'heartbeat' });
|
|
} catch (e) {
|
|
clearInterval(heartbeat);
|
|
}
|
|
}, 15000);
|
|
|
|
const cleanup = () => {
|
|
clearInterval(heartbeat);
|
|
const connections = activeConnections.get(ticketId);
|
|
if (connections) {
|
|
connections.delete(sendEvent);
|
|
if (connections.size === 0) {
|
|
activeConnections.delete(ticketId);
|
|
// Drop the pub/sub subscription once no local sockets remain.
|
|
const unsub = channelUnsubs.get(ticketId);
|
|
if (unsub) {
|
|
unsub();
|
|
channelUnsubs.delete(ticketId);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Clean up on disconnect
|
|
stream.onAbort(cleanup);
|
|
|
|
// Keep the stream open, and every 15s fall back to reading the ticket
|
|
// status from the DB. Pub/sub is best-effort: a message published while the
|
|
// subscriber connection was reconnecting is lost, and without this check
|
|
// the client would wait forever on a payment that already confirmed.
|
|
try {
|
|
while (true) {
|
|
await stream.sleep(15000);
|
|
const current = await dbGet<any>(
|
|
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
|
);
|
|
if (current?.status === 'confirmed') {
|
|
await sendEvent({ type: 'paid', ticketId });
|
|
return;
|
|
}
|
|
}
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
lnbitsRouter.get('/status/: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))
|
|
);
|
|
|
|
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;
|