speed #25

Merged
Michilis merged 7 commits from speed into main 2026-07-13 01:18:24 +00:00
10 changed files with 331 additions and 34 deletions
Showing only changes of commit 78ffd0ae52 - Show all commits
+18
View File
@@ -238,6 +238,15 @@ async function migrate() {
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Invoices table
await (db as any).run(sql`
@@ -719,6 +728,15 @@ async function migrate() {
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Invoices table
await (db as any).execute(sql`
+6
View File
@@ -121,6 +121,9 @@ export const sqlitePayments = sqliteTable('payments', {
currency: text('currency').notNull().default('PYG'),
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
reference: text('reference'),
lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires
lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
payerName: text('payer_name'), // Name of payer if different from attendee
paidAt: text('paid_at'),
@@ -476,6 +479,9 @@ export const pgPayments = pgTable('payments', {
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
status: varchar('status', { length: 20 }).notNull().default('pending'),
reference: varchar('reference', { length: 255 }),
lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires
lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
userMarkedPaidAt: timestamp('user_marked_paid_at'),
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
paidAt: timestamp('paid_at'),
+5
View File
@@ -36,6 +36,11 @@ export interface CreateInvoiceParams {
extra?: Record<string, any>; // Additional metadata
}
// How long a booking's Lightning invoice is valid for, in seconds. Shared
// between initial booking creation and invoice regeneration so both produce
// invoices with the same lifetime.
export const LNBITS_INVOICE_EXPIRY_SECONDS = 900; // 15 minutes
/**
* Check if LNbits is configured
*/
+149 -4
View File
@@ -1,9 +1,15 @@
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
import { eq, and } from 'drizzle-orm';
import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
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 } from '../lib/stores/lock.js';
@@ -391,6 +397,145 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
});
});
/**
* 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)
*/
+16 -7
View File
@@ -4,8 +4,8 @@ import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
import { generateId, generateTicketCode, getNow, toDbDate, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js';
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
@@ -418,7 +418,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`,
webhookUrl,
expiry: 900, // 15 minutes expiry for faster UX
expiry: LNBITS_INVOICE_EXPIRY_SECONDS, // 15 minutes expiry for faster UX
extra: {
ticketId: primaryTicket.id,
bookingId: ticketCount > 1 ? bookingId : null,
@@ -430,13 +430,22 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
ticketCount,
},
});
// Update primary payment with LNbits payment hash reference
const lnbitsExpiresAt = toDbDate(new Date(Date.now() + LNBITS_INVOICE_EXPIRY_SECONDS * 1000));
// Update primary payment with the LNbits invoice - the BOLT11 string and
// expiry are persisted so the "Pay now" page can redisplay this same
// invoice later instead of erroring out on an unpaid Lightning booking.
await (db as any)
.update(payments)
.set({ reference: lnbitsInvoice.paymentHash })
.set({
reference: lnbitsInvoice.paymentHash,
lnbitsInvoice: lnbitsInvoice.paymentRequest,
lnbitsExpiresAt,
lnbitsAmountSats: lnbitsInvoice.amount,
})
.where(eq((payments as any).id, primaryPayment.id));
(primaryPayment as any).reference = lnbitsInvoice.paymentHash;
} catch (error: any) {
console.error('Failed to create Lightning invoice:', error);
@@ -16,7 +16,7 @@ import type {
PaymentMethod,
} from './_types';
import { buildPaymentMethods, formatRuc, rucPattern } from './_logic/booking';
import { useLightningWatcher } from './_hooks/useLightningWatcher';
import { useLightningWatcher } from '@/hooks/useLightningWatcher';
import { PayingStep } from './_steps/PayingStep';
import { ManualPaymentStep } from './_steps/ManualPaymentStep';
import { PendingApprovalStep } from './_steps/PendingApprovalStep';
@@ -35,7 +35,6 @@ export default function BookingPage() {
const [step, setStep] = useState<BookingStep>('form');
const [submitting, setSubmitting] = useState(false);
const [bookingResult, setBookingResult] = useState<BookingResult | null>(null);
const [, setPaymentPending] = useState(false);
const [markingPaid, setMarkingPaid] = useState(false);
// State for payer name (when paid under different name)
@@ -245,7 +244,13 @@ export default function BookingPage() {
};
// Watch for Lightning payment confirmation while on the paying step.
useLightningWatcher(step, bookingResult?.ticketId, locale, setPaymentPending, setStep);
useLightningWatcher(
step === 'paying',
bookingResult?.ticketId,
locale,
() => setStep('success'),
() => {}
);
// Handle "I Have Paid" button click
const handleMarkPaymentSent = async () => {
@@ -330,7 +335,6 @@ export default function BookingPage() {
};
setBookingResult(result);
setStep('paying');
setPaymentPending(true);
// Payment confirmation is handled by the paying-step watcher effect.
} else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') {
// Manual payment methods - show payment details
@@ -1,15 +1,17 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig, LightningInvoice } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
import { useLightningWatcher } from '@/hooks/useLightningWatcher';
import { PayingStep } from '@/app/(public)/book/[eventId]/_steps/PayingStep';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
CheckCircleIcon,
import {
CheckCircleIcon,
ClockIcon,
XCircleIcon,
TicketIcon,
@@ -22,7 +24,7 @@ import {
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error';
type PaymentStep = 'loading' | 'manual_payment' | 'lightning_payment' | 'pending_approval' | 'confirmed' | 'error';
export default function BookingPaymentPage() {
const params = useParams();
@@ -33,10 +35,36 @@ export default function BookingPaymentPage() {
const [step, setStep] = useState<PaymentStep>('loading');
const [markingPaid, setMarkingPaid] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lightningInvoice, setLightningInvoice] = useState<LightningInvoice | null>(null);
const [invoiceExpired, setInvoiceExpired] = useState(false);
const [fetchingInvoice, setFetchingInvoice] = useState(false);
const ticketId = params.ticketId as string;
const requestedStep = searchParams.get('step');
// Get (or refresh) the Lightning invoice for this ticket - reuses the
// stored invoice if still valid, otherwise the backend generates a new one.
const fetchLightningInvoice = useCallback(async () => {
setFetchingInvoice(true);
try {
const result = await ticketsApi.getLightningInvoice(ticketId);
if (result.alreadyPaid) {
setStep('confirmed');
return;
}
if (result.invoice) {
setLightningInvoice(result.invoice);
setInvoiceExpired(false);
setStep('lightning_payment');
}
} catch (err: any) {
setError(err.message || 'Failed to load Lightning invoice');
setStep('error');
} finally {
setFetchingInvoice(false);
}
}, [ticketId]);
// Fetch ticket and payment config
useEffect(() => {
if (!ticketId) return;
@@ -45,7 +73,7 @@ export default function BookingPaymentPage() {
try {
// Get ticket with event and payment info
const { ticket: ticketData } = await ticketsApi.getById(ticketId);
if (!ticketData) {
setError('Booking not found');
setStep('error');
@@ -54,10 +82,24 @@ export default function BookingPaymentPage() {
setTicket(ticketData);
// Only proceed for manual payment methods
const paymentMethod = ticketData.payment?.provider;
if (paymentMethod === 'lightning') {
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
setStep('confirmed');
} else if (ticketData.status === 'cancelled') {
setError(locale === 'es'
? 'Esta reserva ya no está activa. Por favor realiza una nueva reserva.'
: 'This booking is no longer active. Please make a new booking.');
setStep('error');
} else {
await fetchLightningInvoice();
}
return;
}
if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) {
// Not a manual payment method, redirect to success page or show appropriate state
// Not a manual or Lightning payment method, show appropriate state
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
setStep('confirmed');
} else {
@@ -75,7 +117,7 @@ export default function BookingPaymentPage() {
// Determine which step to show based on payment status
const paymentStatus = ticketData.payment?.status;
if (paymentStatus === 'paid' || ticketData.status === 'confirmed') {
setStep('confirmed');
} else if (paymentStatus === 'pending_approval') {
@@ -96,6 +138,15 @@ export default function BookingPaymentPage() {
loadBookingData();
}, [ticketId]);
// Watch for Lightning payment confirmation while the invoice is on screen.
useLightningWatcher(
step === 'lightning_payment' && !invoiceExpired,
ticketId,
locale,
() => setStep('confirmed'),
() => setInvoiceExpired(true)
);
// Handle "I Have Paid" button click
const handleMarkPaymentSent = async () => {
if (!ticket) return;
@@ -301,6 +352,49 @@ export default function BookingPaymentPage() {
);
}
// Lightning payment step - show the (reused or freshly generated) invoice
if (step === 'lightning_payment' && ticket) {
if (invoiceExpired) {
return (
<div className="section-padding">
<div className="container-page max-w-xl">
<Card className="p-8 text-center">
<div className="w-16 h-16 rounded-full bg-orange-100 flex items-center justify-center mx-auto mb-6">
<ClockIcon className="w-10 h-10 text-orange-600" />
</div>
<h1 className="text-2xl font-bold text-primary-dark mb-2">
{locale === 'es' ? 'La factura expiró' : 'Invoice expired'}
</h1>
<p className="text-gray-600 mb-6">
{locale === 'es'
? 'Genera una nueva factura Lightning para continuar con el pago.'
: 'Generate a new Lightning invoice to continue with payment.'}
</p>
<Button onClick={fetchLightningInvoice} isLoading={fetchingInvoice} size="lg">
{locale === 'es' ? 'Generar Nueva Factura' : 'Generate New Invoice'}
</Button>
</Card>
</div>
</div>
);
}
if (!lightningInvoice) {
return (
<div className="section-padding">
<div className="container-page max-w-xl text-center">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
<p className="mt-4 text-gray-600">
{locale === 'es' ? 'Preparando tu factura...' : 'Preparing your invoice...'}
</p>
</div>
</div>
);
}
return <PayingStep invoice={lightningInvoice} qrCode={ticket.qrCode} locale={locale} />;
}
// Manual payment step - show payment details and "I have paid" button
if (step === 'manual_payment' && ticket && paymentConfig) {
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
@@ -1,22 +1,21 @@
import { useEffect } from 'react';
import toast from 'react-hot-toast';
import { ticketsApi } from '@/lib/api';
import type { BookingStep } from '../_types';
/**
* Watch for Lightning payment confirmation while on the paying step.
* Watch for Lightning payment confirmation while an invoice is on screen.
* SSE gives instant updates; a 3s poll runs in parallel as a safety net so a
* buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI.
*/
export function useLightningWatcher(
step: BookingStep,
active: boolean,
ticketId: string | undefined,
locale: string,
setPaymentPending: (value: boolean) => void,
setStep: (value: BookingStep) => void
onPaid: () => void,
onExpired: () => void
) {
useEffect(() => {
if (step !== 'paying' || !ticketId) return;
if (!active || !ticketId) return;
let settled = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
@@ -25,15 +24,14 @@ export function useLightningWatcher(
if (settled) return;
settled = true;
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
setPaymentPending(false);
setStep('success');
onPaid();
};
const expire = () => {
if (settled) return;
settled = true;
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired');
setPaymentPending(false);
onExpired();
};
// Always same-origin so the streaming proxy route handler is used (it
@@ -79,5 +77,5 @@ export function useLightningWatcher(
eventSource.close();
if (pollTimer) clearTimeout(pollTimer);
};
}, [step, ticketId, locale]);
}, [active, ticketId, locale]);
}
+9
View File
@@ -6,6 +6,7 @@ import type {
TicketValidationResult,
TicketSearchResult,
LiveSearchResult,
LightningInvoice,
} from './types';
export const ticketsApi = {
@@ -137,6 +138,14 @@ export const ticketsApi = {
`/api/lnbits/status/${ticketId}`
),
// Get a Lightning invoice to pay/re-pay a ticket - reuses the stored invoice
// if it's still valid, otherwise generates a fresh one.
getLightningInvoice: (ticketId: string) =>
fetchApi<{ invoice?: LightningInvoice; reused?: boolean; alreadyPaid?: boolean }>(
`/api/lnbits/invoice/${ticketId}`,
{ method: 'POST' }
),
// Get PDF download URL (returns the URL, not the PDF itself)
getPdfUrl: (id: string) => `${API_BASE}/api/tickets/${id}/pdf`,
};
+9
View File
@@ -25,6 +25,15 @@ export interface Event {
updatedAt: string;
}
export interface LightningInvoice {
paymentHash: string;
paymentRequest: string; // BOLT11 invoice
amount: number; // Amount in satoshis
fiatAmount?: number; // Original fiat amount
fiatCurrency?: string; // Original fiat currency
expiresAt?: string;
}
export interface Ticket {
id: string;
bookingId?: string; // Groups multiple tickets from same booking