Enhance payment processing and rental features
- Updated the BTCPay service to support internal Lightning invoices with private route hints, improving payment routing for users with private channels. - Added reconciliation methods for pending rents and subscriptions to ensure missed payments are processed on startup. - Enhanced the rental and subscription services to handle payments in satoshis, aligning with Lightning Network standards. - Improved the rental modal and content detail components to display rental status and pricing more clearly, including a countdown for rental expiration. - Refactored various components to streamline user experience and ensure accurate rental access checks.
This commit is contained in:
@@ -9,6 +9,8 @@ import type {
|
||||
BTCPayInvoiceResponse,
|
||||
BTCPayPaymentMethod,
|
||||
BTCPayLightningPayResponse,
|
||||
BTCPayInternalLnInvoice,
|
||||
BTCPayCreateLnInvoiceRequest,
|
||||
} from '../dto/btcpay/btcpay-invoice';
|
||||
|
||||
/**
|
||||
@@ -23,18 +25,32 @@ export class BTCPayService implements LightningService {
|
||||
private readonly baseUrl: string;
|
||||
private readonly storeId: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly routeHintsEnabled: boolean;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = (process.env.BTCPAY_URL || process.env.BTCPAY_SERVER_URL || '').replace(/\/+$/, '');
|
||||
this.storeId = process.env.BTCPAY_STORE_ID || '';
|
||||
this.apiKey = process.env.BTCPAY_API_KEY || '';
|
||||
|
||||
// Create a separate internal Lightning invoice with privateRouteHints.
|
||||
// Only needed when BTCPay's own store-level route hints are NOT enabled.
|
||||
// Since BTCPay can include route hints natively (Lightning settings →
|
||||
// "Include hop hints"), this defaults to false to avoid duplicating
|
||||
// invoices. Set BTCPAY_ROUTE_HINTS=true only if you cannot enable
|
||||
// route hints in BTCPay's UI and still need them.
|
||||
const envHints = process.env.BTCPAY_ROUTE_HINTS;
|
||||
this.routeHintsEnabled = envHints === 'true';
|
||||
|
||||
if (!this.baseUrl || !this.storeId || !this.apiKey) {
|
||||
Logger.warn(
|
||||
'BTCPay Server environment variables not fully configured (BTCPAY_URL, BTCPAY_STORE_ID, BTCPAY_API_KEY)',
|
||||
'BTCPayService',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.routeHintsEnabled) {
|
||||
Logger.log('Private route hints enabled for Lightning invoices', 'BTCPayService');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -53,22 +69,29 @@ export class BTCPayService implements LightningService {
|
||||
// ── Invoice Creation (receiving payments from users) ─────────────────────
|
||||
|
||||
/**
|
||||
* Create a BTCPay invoice denominated in USD, paid via Lightning.
|
||||
* Returns data shaped like Strike's Invoice DTO for drop-in compatibility
|
||||
* with the existing RentsService.
|
||||
* Create a BTCPay invoice denominated in sats, paid via Lightning.
|
||||
* The amount parameter is in satoshis. We convert to BTC for the
|
||||
* API call since not all BTCPay versions support "SATS" as a currency.
|
||||
*
|
||||
* Returns data shaped like Strike's Invoice DTO for drop-in
|
||||
* compatibility with the existing RentsService.
|
||||
*/
|
||||
async issueInvoice(
|
||||
amount: number,
|
||||
amountSats: number,
|
||||
description = 'Invoice for order',
|
||||
correlationId?: string,
|
||||
): Promise<Invoice> {
|
||||
try {
|
||||
// Convert sats to BTC (1 BTC = 100,000,000 sats)
|
||||
const amountBtc = (amountSats / 100_000_000).toFixed(8);
|
||||
|
||||
const body = {
|
||||
amount: amount.toString(),
|
||||
currency: 'USD',
|
||||
amount: amountBtc,
|
||||
currency: 'BTC',
|
||||
metadata: {
|
||||
orderId: correlationId || undefined,
|
||||
itemDesc: description,
|
||||
amountSats, // Store original sats amount for reference
|
||||
},
|
||||
checkout: {
|
||||
paymentMethods: ['BTC-LN'],
|
||||
@@ -101,28 +124,92 @@ export class BTCPayService implements LightningService {
|
||||
|
||||
/**
|
||||
* Fetch the Lightning BOLT11 for an existing invoice.
|
||||
*
|
||||
* When route hints are enabled (default), this creates an internal
|
||||
* Lightning invoice with `privateRouteHints: true` so that payers
|
||||
* can find a route even when our node only has private channels.
|
||||
* The store invoice is kept for webhook-based payment tracking.
|
||||
* On subsequent polls, if the internal invoice is paid we mark the
|
||||
* store invoice as complete, which triggers the normal webhook flow.
|
||||
*
|
||||
* Returns data shaped like Strike's InvoiceQuote DTO.
|
||||
*/
|
||||
async issueQuote(invoiceId: string): Promise<InvoiceQuote> {
|
||||
try {
|
||||
// Get the invoice to know the amount
|
||||
const invoice = await this.getRawInvoice(invoiceId);
|
||||
|
||||
// Get payment methods to retrieve the BOLT11
|
||||
const { data: paymentMethods } = await axios.get<BTCPayPaymentMethod[]>(
|
||||
this.storeUrl(`/invoices/${invoiceId}/payment-methods`),
|
||||
{ headers: this.headers },
|
||||
);
|
||||
|
||||
const lightning = paymentMethods.find(
|
||||
(pm) => pm.paymentMethod === 'BTC-LN' || pm.paymentMethod === 'BTC-LNURL',
|
||||
);
|
||||
|
||||
if (!lightning) {
|
||||
throw new Error('No Lightning payment method found for this invoice');
|
||||
if (!this.routeHintsEnabled) {
|
||||
return this.issueQuoteFromPaymentMethods(invoiceId, invoice);
|
||||
}
|
||||
|
||||
// ── Route-hints path: use the internal Lightning node API ───────
|
||||
const internalLnId = invoice.metadata?.internalLnInvoiceId as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
let bolt11: string;
|
||||
let expirationDate: Date;
|
||||
let paid = invoice.status === 'Settled';
|
||||
|
||||
if (internalLnId) {
|
||||
// We already created an internal LN invoice — re-fetch it
|
||||
const lnInvoice = await this.getInternalLnInvoice(internalLnId);
|
||||
bolt11 = lnInvoice.BOLT11;
|
||||
expirationDate = new Date(lnInvoice.expiresAt * 1000);
|
||||
|
||||
if (lnInvoice.status === 'Paid') {
|
||||
paid = true;
|
||||
// Detect payment on the internal invoice and settle the store invoice
|
||||
if (invoice.status !== 'Settled') {
|
||||
Logger.log(
|
||||
`Internal LN invoice ${internalLnId} paid — marking store invoice ${invoiceId} as complete`,
|
||||
'BTCPayService',
|
||||
);
|
||||
await this.markInvoiceComplete(invoiceId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// First call — create a new internal LN invoice with route hints
|
||||
const amountSats =
|
||||
(invoice.metadata?.amountSats as number) ||
|
||||
Math.round(parseFloat(invoice.amount) * 100_000_000);
|
||||
|
||||
// Match expiry to the store invoice so both expire together
|
||||
const expirySeconds = Math.max(
|
||||
60,
|
||||
Math.floor((invoice.expirationTime * 1000 - Date.now()) / 1000),
|
||||
);
|
||||
|
||||
try {
|
||||
const lnInvoice = await this.createInternalLnInvoice(
|
||||
amountSats * 1000, // BTCPay internal API expects millisats
|
||||
(invoice.metadata?.itemDesc as string) || 'Invoice',
|
||||
expirySeconds,
|
||||
);
|
||||
|
||||
bolt11 = lnInvoice.BOLT11;
|
||||
expirationDate = new Date(lnInvoice.expiresAt * 1000);
|
||||
|
||||
// Persist the link so subsequent polls re-use this invoice
|
||||
await this.updateInvoiceMetadata(invoiceId, {
|
||||
...invoice.metadata,
|
||||
internalLnInvoiceId: lnInvoice.id,
|
||||
});
|
||||
|
||||
Logger.log(
|
||||
`Created internal LN invoice ${lnInvoice.id} with route hints for store invoice ${invoiceId}`,
|
||||
'BTCPayService',
|
||||
);
|
||||
} catch (err) {
|
||||
// If the internal Lightning API is unavailable, fall back gracefully
|
||||
Logger.warn(
|
||||
`Failed to create internal LN invoice with route hints: ${err.message}. Falling back to store payment method BOLT11.`,
|
||||
'BTCPayService',
|
||||
);
|
||||
return this.issueQuoteFromPaymentMethods(invoiceId, invoice);
|
||||
}
|
||||
}
|
||||
|
||||
const expirationDate = new Date(invoice.expirationTime * 1000);
|
||||
const expirationInSec = Math.max(
|
||||
0,
|
||||
Math.floor((expirationDate.getTime() - Date.now()) / 1000),
|
||||
@@ -130,31 +217,104 @@ export class BTCPayService implements LightningService {
|
||||
|
||||
return {
|
||||
quoteId: invoiceId,
|
||||
description: invoice.metadata?.itemDesc as string || 'Invoice',
|
||||
lnInvoice: lightning.destination,
|
||||
description: (invoice.metadata?.itemDesc as string) || 'Invoice',
|
||||
lnInvoice: bolt11,
|
||||
onchainAddress: '',
|
||||
expiration: expirationDate,
|
||||
expirationInSec,
|
||||
paid,
|
||||
targetAmount: {
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
},
|
||||
sourceAmount: {
|
||||
amount: lightning.amount,
|
||||
amount: invoice.amount,
|
||||
currency: 'BTC',
|
||||
},
|
||||
conversionRate: {
|
||||
amount: lightning.rate,
|
||||
amount: '1',
|
||||
sourceCurrency: 'BTC',
|
||||
targetCurrency: invoice.currency,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error('BTCPay quote retrieval failed: ' + error.message, 'BTCPayService');
|
||||
Logger.error(
|
||||
'BTCPay quote retrieval failed: ' + error.message,
|
||||
'BTCPayService',
|
||||
);
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Original quote logic: fetch the BOLT11 from the store invoice's
|
||||
* payment methods. Used as fallback when route hints are disabled
|
||||
* or the internal Lightning API is unavailable.
|
||||
*/
|
||||
private async issueQuoteFromPaymentMethods(
|
||||
invoiceId: string,
|
||||
invoice: BTCPayInvoiceResponse,
|
||||
): Promise<InvoiceQuote> {
|
||||
const { data: paymentMethods } = await axios.get<BTCPayPaymentMethod[]>(
|
||||
this.storeUrl(`/invoices/${invoiceId}/payment-methods`),
|
||||
{ headers: this.headers },
|
||||
);
|
||||
|
||||
// BTCPay's payment method IDs vary between versions (BTC-LN, BTC_LN,
|
||||
// BTC-LightningNetwork, etc.). Match any Lightning-related method.
|
||||
const lightning = paymentMethods.find((pm) => {
|
||||
const id = (pm.paymentMethod || pm.paymentMethodId || '').toUpperCase();
|
||||
return (
|
||||
id.includes('LN') ||
|
||||
id.includes('LIGHTNING') ||
|
||||
id === 'BTC-LNURL'
|
||||
);
|
||||
});
|
||||
|
||||
if (!lightning) {
|
||||
const availableMethods = paymentMethods.map(
|
||||
(pm) => pm.paymentMethod || pm.paymentMethodId || 'unknown',
|
||||
);
|
||||
Logger.warn(
|
||||
`No Lightning payment method found. Available methods: ${JSON.stringify(availableMethods)}`,
|
||||
'BTCPayService',
|
||||
);
|
||||
throw new Error(
|
||||
'No Lightning payment method found for this invoice. ' +
|
||||
'Ensure the BTCPay store has a Lightning node configured.',
|
||||
);
|
||||
}
|
||||
|
||||
const expirationDate = new Date(invoice.expirationTime * 1000);
|
||||
const expirationInSec = Math.max(
|
||||
0,
|
||||
Math.floor((expirationDate.getTime() - Date.now()) / 1000),
|
||||
);
|
||||
|
||||
return {
|
||||
quoteId: invoiceId,
|
||||
description: (invoice.metadata?.itemDesc as string) || 'Invoice',
|
||||
lnInvoice: lightning.destination,
|
||||
onchainAddress: '',
|
||||
expiration: expirationDate,
|
||||
expirationInSec,
|
||||
paid: invoice.status === 'Settled',
|
||||
targetAmount: {
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
},
|
||||
sourceAmount: {
|
||||
amount: lightning.amount,
|
||||
currency: 'BTC',
|
||||
},
|
||||
conversionRate: {
|
||||
amount: lightning.rate,
|
||||
sourceCurrency: 'BTC',
|
||||
targetCurrency: invoice.currency,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of an existing invoice.
|
||||
* Returns data shaped like Strike's Invoice DTO.
|
||||
@@ -271,6 +431,90 @@ export class BTCPayService implements LightningService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal Lightning Node API (route hints) ─────────────────────────
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice directly on the node with private route
|
||||
* hints enabled. This allows payers to discover routes to our node
|
||||
* even when it only has private (unannounced) channels.
|
||||
*
|
||||
* @param amountMsat Amount in **millisatoshis**
|
||||
* @param description Invoice description / memo
|
||||
* @param expiry Seconds until expiry
|
||||
*/
|
||||
private async createInternalLnInvoice(
|
||||
amountMsat: number,
|
||||
description: string,
|
||||
expiry: number,
|
||||
): Promise<BTCPayInternalLnInvoice> {
|
||||
const body: BTCPayCreateLnInvoiceRequest = {
|
||||
amount: amountMsat.toString(),
|
||||
description,
|
||||
expiry,
|
||||
privateRouteHints: true,
|
||||
};
|
||||
|
||||
const { data } = await axios.post<BTCPayInternalLnInvoice>(
|
||||
this.storeUrl('/lightning/BTC/invoices'),
|
||||
body,
|
||||
{ headers: this.headers },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an existing internal Lightning invoice by its ID.
|
||||
*/
|
||||
private async getInternalLnInvoice(
|
||||
invoiceId: string,
|
||||
): Promise<BTCPayInternalLnInvoice> {
|
||||
const { data } = await axios.get<BTCPayInternalLnInvoice>(
|
||||
this.storeUrl(`/lightning/BTC/invoices/${invoiceId}`),
|
||||
{ headers: this.headers },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a store invoice as complete (settled).
|
||||
* This triggers the `InvoiceSettled` webhook so the normal payment
|
||||
* processing pipeline (rents, subscriptions) runs automatically.
|
||||
*/
|
||||
private async markInvoiceComplete(invoiceId: string): Promise<void> {
|
||||
try {
|
||||
await axios.post(
|
||||
this.storeUrl(`/invoices/${invoiceId}/status`),
|
||||
{ status: 'Complete' },
|
||||
{ headers: this.headers },
|
||||
);
|
||||
Logger.log(
|
||||
`Store invoice ${invoiceId} marked as complete`,
|
||||
'BTCPayService',
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(
|
||||
`Failed to mark store invoice ${invoiceId} as complete: ${err.message}`,
|
||||
'BTCPayService',
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the metadata on a store invoice (e.g. to persist the
|
||||
* internal Lightning invoice ID for subsequent polls).
|
||||
*/
|
||||
private async updateInvoiceMetadata(
|
||||
invoiceId: string,
|
||||
metadata: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await axios.put(
|
||||
this.storeUrl(`/invoices/${invoiceId}`),
|
||||
{ metadata },
|
||||
{ headers: this.headers },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Private Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private async getRawInvoice(invoiceId: string): Promise<BTCPayInvoiceResponse> {
|
||||
|
||||
Reference in New Issue
Block a user