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.
This commit is contained in:
padreug 2025-10-23 03:04:50 +02:00
parent 1412359172
commit a2a58d323b
2 changed files with 189 additions and 4 deletions

View file

@ -92,7 +92,11 @@ window.app = Vue.createApp({
payment_method: 'cash', payment_method: 'cash',
description: '', description: '',
reference: '', 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) clearInterval(this.payDialog.pollIntervalId)
this.payDialog.pollIntervalId = null 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: { computed: {
@ -867,18 +878,132 @@ window.app = Vue.createApp({
// Only show for users who owe castle (negative balance) // Only show for users who owe castle (negative balance)
if (userBalance.balance >= 0) return if (userBalance.balance >= 0) return
// Clear any existing polling
if (this.settleReceivableDialog.pollIntervalId) {
clearInterval(this.settleReceivableDialog.pollIntervalId)
}
this.settleReceivableDialog = { this.settleReceivableDialog = {
show: true, show: true,
user_id: userBalance.user_id, user_id: userBalance.user_id,
username: userBalance.username, username: userBalance.username,
maxAmount: Math.abs(userBalance.balance), // Convert negative to positive maxAmount: Math.abs(userBalance.balance), // Convert negative to positive
amount: Math.abs(userBalance.balance), // Default to full amount amount: Math.abs(userBalance.balance), // Default to full amount
payment_method: 'cash', payment_method: 'lightning',
description: `Payment from ${userBalance.username}`, description: `Payment from ${userBalance.username}`,
reference: '', 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() { async submitSettleReceivable() {
this.settleReceivableDialog.loading = true this.settleReceivableDialog.loading = true
try { try {

View file

@ -1161,6 +1161,7 @@
dense dense
v-model="settleReceivableDialog.payment_method" v-model="settleReceivableDialog.payment_method"
:options="[ :options="[
{label: 'Lightning Invoice', value: 'lightning'},
{label: 'Cash', value: 'cash'}, {label: 'Cash', value: 'cash'},
{label: 'Bank Transfer', value: 'bank_transfer'}, {label: 'Bank Transfer', value: 'bank_transfer'},
{label: 'Check', value: 'check'}, {label: 'Check', value: 'check'},
@ -1174,7 +1175,46 @@
:rules="[val => !!val || 'Payment method is required']" :rules="[val => !!val || 'Payment method is required']"
></q-select> ></q-select>
<!-- Show invoice if lightning method selected and generated -->
<div v-if="settleReceivableDialog.payment_method === 'lightning' && settleReceivableDialog.invoice">
<q-separator class="q-my-md"></q-separator>
<div class="text-center q-mb-md">
<div class="text-subtitle2 q-mb-sm">Lightning Invoice Generated</div>
<qrcode-vue
:value="settleReceivableDialog.invoice"
:size="280"
level="M"
class="q-mb-md"
></qrcode-vue>
<q-input
filled
dense
readonly
v-model="settleReceivableDialog.invoice"
label="Invoice"
>
<template v-slot:append>
<q-btn
flat
dense
icon="content_copy"
@click="copyToClipboard(settleReceivableDialog.invoice)"
>
<q-tooltip>Copy invoice</q-tooltip>
</q-btn>
</template>
</q-input>
<div class="text-caption text-grey q-mt-sm">
Waiting for payment...
</div>
</div>
</div>
<q-input <q-input
v-if="settleReceivableDialog.payment_method !== 'lightning'"
filled filled
dense dense
v-model="settleReceivableDialog.description" v-model="settleReceivableDialog.description"
@ -1185,6 +1225,7 @@
></q-input> ></q-input>
<q-input <q-input
v-if="settleReceivableDialog.payment_method !== 'lightning'"
filled filled
dense dense
v-model="settleReceivableDialog.reference" v-model="settleReceivableDialog.reference"
@ -1194,9 +1235,28 @@
></q-input> ></q-input>
<div class="row q-mt-md q-gutter-sm"> <div class="row q-mt-md q-gutter-sm">
<q-btn unelevated color="primary" type="submit" :loading="settleReceivableDialog.loading"> <!-- For lightning: generate invoice button, then it auto-settles on payment -->
<q-btn
v-if="settleReceivableDialog.payment_method === 'lightning' && !settleReceivableDialog.invoice"
unelevated
color="primary"
@click="generateSettlementInvoice"
:loading="settleReceivableDialog.loading"
>
Generate Invoice
</q-btn>
<!-- For non-lightning: manual settle button -->
<q-btn
v-if="settleReceivableDialog.payment_method !== 'lightning'"
unelevated
color="primary"
type="submit"
:loading="settleReceivableDialog.loading"
>
Settle Receivable Settle Receivable
</q-btn> </q-btn>
<q-btn v-close-popup flat color="grey">Cancel</q-btn> <q-btn v-close-popup flat color="grey">Cancel</q-btn>
</div> </div>
</q-form> </q-form>