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>
215 lines
5.8 KiB
TypeScript
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';
|
|
}
|