Files
Spanglish/backend/src/lib/lnbits.ts
T
MichilisandCursor 78ffd0ae52 Allow Lightning invoice reuse and re-payment from the dashboard.
Store LNbits invoice data on payments, add an invoice endpoint that reuses valid invoices or regenerates expired ones, and wire the booking payment page to fetch and display invoices via a shared watcher hook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:48:28 +00:00

215 lines
5.8 KiB
TypeScript

/**
* LNbits API client for Lightning Network payments
*
* Uses LNbits API to create and manage Lightning invoices with webhook support
* for payment detection.
*/
// Read environment variables dynamically to ensure dotenv has loaded
function getConfig() {
return {
url: process.env.LNBITS_URL || '',
apiKey: process.env.LNBITS_API_KEY || '', // Invoice/read key
webhookSecret: process.env.LNBITS_WEBHOOK_SECRET || '',
};
}
export interface LNbitsInvoice {
paymentHash: string;
paymentRequest: string; // BOLT11 invoice string
checkingId: string;
amount: number; // Amount in satoshis (after conversion)
fiatAmount?: number; // Original fiat amount
fiatCurrency?: string; // Original fiat currency
memo: string;
expiry: string;
status: string;
extra?: Record<string, any>;
}
export interface CreateInvoiceParams {
amount: number; // Amount in the specified unit
unit?: string; // Currency unit: 'sat', 'USD', 'PYG', etc. (default: 'sat')
memo: string;
webhookUrl?: string;
expiry?: number; // Expiry in seconds (default 3600 = 1 hour)
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
*/
export function isLNbitsConfigured(): boolean {
const config = getConfig();
return !!(config.url && config.apiKey);
}
/**
* Create a Lightning invoice using LNbits
* LNbits supports fiat currencies directly - it will convert to sats automatically
*/
export async function createInvoice(params: CreateInvoiceParams): Promise<LNbitsInvoice> {
const config = getConfig();
if (!config.url || !config.apiKey) {
throw new Error('LNbits is not configured. Please set LNBITS_URL and LNBITS_API_KEY.');
}
const apiEndpoint = '/api/v1/payments';
// LNbits supports fiat currencies via the 'unit' parameter
// It will automatically convert to sats using its exchange rate provider
const payload: any = {
out: false, // false = create invoice for receiving payment
amount: params.amount,
unit: params.unit || 'sat', // Support fiat currencies like 'USD', 'PYG', etc.
memo: params.memo,
};
if (params.webhookUrl) {
payload.webhook = params.webhookUrl;
}
if (params.expiry) {
payload.expiry = params.expiry;
}
if (params.extra) {
payload.extra = params.extra;
}
console.log('Creating LNbits invoice:', {
amount: params.amount,
unit: payload.unit,
});
const response = await fetch(`${config.url}${apiEndpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': config.apiKey,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
console.error('LNbits invoice creation failed:', {
status: response.status,
statusText: response.statusText,
error: errorText,
});
throw new Error(`Failed to create Lightning invoice: ${errorText}`);
}
const data = await response.json();
// LNbits returns amount in millisatoshis for the actual invoice
// Convert to satoshis for display
const amountSats = data.amount ? Math.round(data.amount / 1000) : params.amount;
console.log('LNbits invoice created successfully:', {
paymentHash: data.payment_hash,
amountMsats: data.amount,
amountSats,
hasPaymentRequest: !!data.payment_request,
});
return {
paymentHash: data.payment_hash,
paymentRequest: data.payment_request || data.bolt11,
checkingId: data.checking_id,
amount: amountSats, // Amount in satoshis
fiatAmount: params.unit && params.unit !== 'sat' ? params.amount : undefined,
fiatCurrency: params.unit && params.unit !== 'sat' ? params.unit : undefined,
memo: params.memo,
expiry: data.expiry || '',
status: data.status || 'pending',
extra: params.extra,
};
}
/**
* Get invoice/payment status from LNbits
*/
export async function getPaymentStatus(paymentHash: string): Promise<{
paid: boolean;
status: string;
preimage?: string;
} | null> {
const config = getConfig();
if (!config.url || !config.apiKey) {
throw new Error('LNbits is not configured');
}
const apiEndpoint = `/api/v1/payments/${paymentHash}`;
const response = await fetch(`${config.url}${apiEndpoint}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': config.apiKey,
},
});
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error('Failed to get payment status from LNbits');
}
const data = await response.json();
// LNbits payment status: "pending", "complete", "failed"
// For incoming payments, "complete" means paid
const isPaid = data.status === 'complete' || data.paid === true;
return {
paid: isPaid,
status: data.status,
preimage: data.preimage,
};
}
/**
* Verify webhook payload from LNbits
* LNbits webhooks don't have signature verification by default,
* but we can verify the payment hash matches and check payment status
*/
export async function verifyWebhookPayment(paymentHash: string): Promise<boolean> {
try {
const status = await getPaymentStatus(paymentHash);
return status?.paid === true;
} catch (error) {
console.error('Error verifying webhook payment:', error);
return false;
}
}
/**
* Payment status types
*/
export type LNbitsPaymentStatus = 'pending' | 'complete' | 'failed' | 'expired';
/**
* Check if payment is complete
*/
export function isPaymentComplete(status: string): boolean {
return status === 'complete';
}
/**
* Check if payment is still pending
*/
export function isPaymentPending(status: string): boolean {
return status === 'pending';
}