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',
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 {

View file

@ -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']"
></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
v-if="settleReceivableDialog.payment_method !== 'lightning'"
filled
dense
v-model="settleReceivableDialog.description"
@ -1185,6 +1225,7 @@
></q-input>
<q-input
v-if="settleReceivableDialog.payment_method !== 'lightning'"
filled
dense
v-model="settleReceivableDialog.reference"
@ -1194,9 +1235,28 @@
></q-input>
<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
</q-btn>
<q-btn v-close-popup flat color="grey">Cancel</q-btn>
</div>
</q-form>