From a2a58d323b17bc7a744806eb78411a2dc558bc0c Mon Sep 17 00:00:00 2001 From: padreug Date: Thu, 23 Oct 2025 03:04:50 +0200 Subject: [PATCH] Adds lightning payment option for settling receivables Implements the ability for users to settle their outstanding balance using a Lightning Network invoice. Generates an invoice on the Castle wallet and polls for payment, automatically recording the transaction once payment is detected. The UI is updated to display the invoice and handle the payment process. --- static/js/index.js | 131 +++++++++++++++++++++++++++++++++++- templates/castle/index.html | 62 ++++++++++++++++- 2 files changed, 189 insertions(+), 4 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 9a29711..c13c628 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -92,7 +92,11 @@ window.app = Vue.createApp({ payment_method: 'cash', description: '', reference: '', - loading: false + loading: false, + invoice: null, + paymentHash: null, + checkWalletKey: null, + pollIntervalId: null } } }, @@ -103,6 +107,13 @@ window.app = Vue.createApp({ clearInterval(this.payDialog.pollIntervalId) this.payDialog.pollIntervalId = null } + }, + 'settleReceivableDialog.show': function(newVal) { + // When dialog is closed, stop polling + if (!newVal && this.settleReceivableDialog.pollIntervalId) { + clearInterval(this.settleReceivableDialog.pollIntervalId) + this.settleReceivableDialog.pollIntervalId = null + } } }, computed: { @@ -867,18 +878,132 @@ window.app = Vue.createApp({ // Only show for users who owe castle (negative balance) if (userBalance.balance >= 0) return + // Clear any existing polling + if (this.settleReceivableDialog.pollIntervalId) { + clearInterval(this.settleReceivableDialog.pollIntervalId) + } + this.settleReceivableDialog = { show: true, user_id: userBalance.user_id, username: userBalance.username, maxAmount: Math.abs(userBalance.balance), // Convert negative to positive amount: Math.abs(userBalance.balance), // Default to full amount - payment_method: 'cash', + payment_method: 'lightning', description: `Payment from ${userBalance.username}`, reference: '', - loading: false + loading: false, + invoice: null, + paymentHash: null, + checkWalletKey: null, + pollIntervalId: null } }, + async generateSettlementInvoice() { + this.settleReceivableDialog.loading = true + + // Clear any existing polling interval + if (this.settleReceivableDialog.pollIntervalId) { + clearInterval(this.settleReceivableDialog.pollIntervalId) + this.settleReceivableDialog.pollIntervalId = null + } + + try { + // Generate an invoice on the Castle wallet for the user to pay + const response = await LNbits.api.request( + 'POST', + '/castle/api/v1/generate-payment-invoice', + this.g.user.wallets[0].adminkey, + { + amount: this.settleReceivableDialog.amount + } + ) + + // Store invoice details + this.settleReceivableDialog.invoice = response.data.payment_request + this.settleReceivableDialog.paymentHash = response.data.payment_hash + this.settleReceivableDialog.checkWalletKey = response.data.check_wallet_key + + this.$q.notify({ + type: 'positive', + message: 'Lightning invoice generated! User can scan QR code or copy to pay.', + timeout: 3000 + }) + + // Start polling for payment + this.pollForSettlementPayment( + response.data.payment_hash, + response.data.check_wallet_key + ) + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.settleReceivableDialog.loading = false + } + }, + async pollForSettlementPayment(paymentHash, checkWalletKey) { + // Clear any existing interval + if (this.settleReceivableDialog.pollIntervalId) { + clearInterval(this.settleReceivableDialog.pollIntervalId) + } + + // Poll every 2 seconds for payment status + const checkPayment = async () => { + try { + const response = await LNbits.api.request( + 'GET', + `/api/v1/payments/${paymentHash}`, + checkWalletKey + ) + + if (response.data && response.data.paid) { + // Record payment in accounting - this creates the journal entry + // that settles the receivable + try { + await LNbits.api.request( + 'POST', + '/castle/api/v1/record-payment', + this.g.user.wallets[0].adminkey, + { + payment_hash: paymentHash + } + ) + } catch (error) { + console.error('Error recording settlement payment:', error) + } + + this.$q.notify({ + type: 'positive', + message: 'Payment received! Receivable has been settled.', + timeout: 3000 + }) + + // Close dialog and refresh + this.settleReceivableDialog.show = false + await this.loadBalance() + await this.loadTransactions() + await this.loadAllUserBalances() + return true + } + return false + } catch (error) { + // Silently ignore errors (payment might not exist yet) + return false + } + } + + // Check every 2 seconds for up to 5 minutes + let attempts = 0 + const maxAttempts = 150 // 5 minutes + this.settleReceivableDialog.pollIntervalId = setInterval(async () => { + attempts++ + const paid = await checkPayment() + if (paid || attempts >= maxAttempts) { + clearInterval(this.settleReceivableDialog.pollIntervalId) + this.settleReceivableDialog.pollIntervalId = null + } + }, 2000) + }, async submitSettleReceivable() { this.settleReceivableDialog.loading = true try { diff --git a/templates/castle/index.html b/templates/castle/index.html index 1cf8d8e..df33a88 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -1161,6 +1161,7 @@ dense v-model="settleReceivableDialog.payment_method" :options="[ + {label: 'Lightning Invoice', value: 'lightning'}, {label: 'Cash', value: 'cash'}, {label: 'Bank Transfer', value: 'bank_transfer'}, {label: 'Check', value: 'check'}, @@ -1174,7 +1175,46 @@ :rules="[val => !!val || 'Payment method is required']" > + +
+ + +
+
Lightning Invoice Generated
+ + + + + + +
+ Waiting for payment... +
+
+
+
- + + + Generate Invoice + + + + Settle Receivable + Cancel