Adds functionality to pay users (Castle pays)

Implements the ability for the super user (Castle) to pay other users for expenses or liabilities.

Introduces a new `PayUser` model to represent these payments, along with API endpoints to process and record them.

Integrates a "Pay User" button into the user list, allowing the super user to initiate payments through either lightning or manual methods (cash, bank transfer, check).

Adds UI elements and logic for handling both lightning payments (generating invoices and paying them) and manual payment recording.

This functionality allows Castle to manage and settle debts with its users directly through the application.
This commit is contained in:
padreug 2025-10-23 10:01:33 +02:00
parent f0257e7c7f
commit 60aba90e00
4 changed files with 560 additions and 1 deletions

View file

@ -99,6 +99,22 @@ window.app = Vue.createApp({
pollIntervalId: null,
exchangeRate: 3571.43, // sats per EUR (TODO: fetch from API)
originalCurrency: 'BTC' // Track original receivable currency
},
payUserDialog: {
show: false,
user_id: '',
username: '',
maxAmount: 0,
maxAmountFiat: 0,
fiatCurrency: null,
amount: 0,
payment_method: 'lightning',
description: '',
reference: '',
loading: false,
paymentSuccess: false,
exchangeRate: 3571.43,
originalCurrency: 'BTC'
}
}
},
@ -135,6 +151,25 @@ window.app = Vue.createApp({
else if (!isOldCash && isNewCash) {
this.settleReceivableDialog.amount = this.settleReceivableDialog.maxAmountFiat || 0
}
},
'payUserDialog.payment_method': function(newVal, oldVal) {
// Convert amount when payment method changes between cash and lightning
if (!oldVal) return
const isOldCash = ['cash', 'bank_transfer', 'check'].includes(oldVal)
const isNewCash = ['cash', 'bank_transfer', 'check'].includes(newVal)
// Only convert if switching between cash and lightning
if (isOldCash === isNewCash) return
// Convert from fiat to sats (when switching from cash to lightning)
if (isOldCash && !isNewCash) {
this.payUserDialog.amount = this.payUserDialog.maxAmount
}
// Convert from sats to fiat (when switching from lightning to cash)
else if (!isOldCash && isNewCash) {
this.payUserDialog.amount = this.payUserDialog.maxAmountFiat || 0
}
}
},
computed: {
@ -180,6 +215,30 @@ window.app = Vue.createApp({
)
return isCashPayment ? '0.01' : '1'
},
paymentAmountLabel() {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.payUserDialog.payment_method
)
if (isCashPayment && this.payUserDialog.fiatCurrency) {
return `Payment Amount (${this.payUserDialog.fiatCurrency}) *`
}
return 'Payment Amount (sats) *'
},
paymentMaxAmount() {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.payUserDialog.payment_method
)
if (isCashPayment && this.payUserDialog.maxAmountFiat) {
return this.payUserDialog.maxAmountFiat
}
return this.payUserDialog.maxAmount
},
paymentAmountStep() {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.payUserDialog.payment_method
)
return isCashPayment ? '0.01' : '1'
},
currencyOptions() {
const options = [{label: 'Satoshis (default)', value: null}]
this.currencies.forEach(curr => {
@ -1132,6 +1191,172 @@ window.app = Vue.createApp({
this.settleReceivableDialog.loading = false
}
},
showPayUserDialog(userBalance) {
// Only show for users castle owes (positive balance)
if (userBalance.balance <= 0) return
// Extract fiat balances (e.g., EUR)
const fiatBalances = userBalance.fiat_balances || {}
const fiatCurrency = Object.keys(fiatBalances)[0] || null
const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0
this.payUserDialog = {
show: true,
user_id: userBalance.user_id,
username: userBalance.username,
maxAmount: userBalance.balance, // Positive sats amount castle owes
maxAmountFiat: fiatAmount, // EUR or other fiat amount
fiatCurrency: fiatCurrency,
amount: fiatCurrency ? fiatAmount : userBalance.balance, // Default to fiat if available
payment_method: 'lightning', // Default to lightning for paying
description: '',
reference: '',
loading: false,
paymentSuccess: false,
exchangeRate: fiatAmount > 0 ? userBalance.balance / fiatAmount : 3571.43,
originalCurrency: fiatCurrency || 'BTC'
}
},
async sendLightningPayment() {
this.payUserDialog.loading = true
try {
// Request an invoice from the user's wallet
const userWallet = await this.getUserWallet(this.payUserDialog.user_id)
if (!userWallet || !userWallet.user_wallet_id) {
throw new Error('User has not configured their wallet. Ask them to set up their wallet first.')
}
// Generate invoice on user's wallet
const invoiceResponse = await LNbits.api.request(
'POST',
`/api/v1/payments`,
userWallet.user_wallet_id_invoice_key,
{
out: false,
amount: this.payUserDialog.amount,
memo: `Payment from Castle to ${this.payUserDialog.username}`
}
)
const paymentRequest = invoiceResponse.data.payment_request
// Pay the invoice from Castle's wallet
const paymentResponse = await LNbits.api.request(
'POST',
`/api/v1/payments`,
this.g.user.wallets[0].adminkey,
{
out: true,
bolt11: paymentRequest
}
)
// Record the payment in Castle accounting
await LNbits.api.request(
'POST',
'/castle/api/v1/payables/pay',
this.g.user.wallets[0].adminkey,
{
user_id: this.payUserDialog.user_id,
amount: this.payUserDialog.amount,
payment_method: 'lightning',
payment_hash: paymentResponse.data.payment_hash
}
)
this.payUserDialog.paymentSuccess = true
this.$q.notify({
type: 'positive',
message: `Successfully sent ${this.formatSats(this.payUserDialog.amount)} sats to ${this.payUserDialog.username}`,
timeout: 3000
})
// Reload balances after a short delay
setTimeout(async () => {
await this.loadBalance()
await this.loadTransactions()
await this.loadAllUserBalances()
this.payUserDialog.show = false
}, 2000)
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.payUserDialog.loading = false
}
},
async submitPayUser() {
this.payUserDialog.loading = true
try {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.payUserDialog.payment_method
)
// Create description with payment method
const paymentMethodLabels = {
'cash': 'Cash payment',
'bank_transfer': 'Bank transfer',
'check': 'Check payment',
'other': 'Payment'
}
const methodLabel = paymentMethodLabels[this.payUserDialog.payment_method] || 'Payment'
const description = this.payUserDialog.description ||
`${methodLabel} to ${this.payUserDialog.username}`
const payload = {
user_id: this.payUserDialog.user_id,
amount: this.payUserDialog.amount,
payment_method: this.payUserDialog.payment_method,
description: description,
reference: this.payUserDialog.reference || null,
}
// Add currency info for fiat payments
if (isCashPayment && this.payUserDialog.fiatCurrency) {
payload.currency = this.payUserDialog.fiatCurrency
payload.amount_sats = this.payUserDialog.maxAmount
}
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/payables/pay',
this.g.user.wallets[0].adminkey,
payload
)
this.$q.notify({
type: 'positive',
message: response.data.message,
timeout: 3000
})
this.payUserDialog.show = false
// Reload balances
await this.loadBalance()
await this.loadTransactions()
await this.loadAllUserBalances()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.payUserDialog.loading = false
}
},
async getUserWallet(userId) {
try {
const response = await LNbits.api.request(
'GET',
`/castle/api/v1/user-wallet/${userId}`,
this.g.user.wallets[0].adminkey
)
return response.data
} catch (error) {
console.error('Error fetching user wallet:', error)
return null
}
},
formatSats(amount) {
return new Intl.NumberFormat().format(amount)
},