castle/static/js/index.js
padreug 56a3e9d4e9 Refactors pending entries and adds fiat amounts
Improves the handling of pending entries by extracting and deduplicating data from Fava's query results.

Adds support for displaying fiat amounts alongside entries and extracts them from the position data in Fava.

Streamlines receivables/payables/equity checks on the frontend by relying on BQL query to supply account type metadata and tags.
2025-11-10 01:06:51 +01:00

1551 lines
50 KiB
JavaScript

const mapJournalEntry = obj => {
return obj
}
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
balance: null,
allUserBalances: [],
transactions: [],
transactionPagination: {
total: 0,
limit: 10,
offset: 0,
has_next: false,
has_prev: false
},
transactionFilter: {
user_id: null, // For filtering by user
account_type: null // For filtering by receivable/payable (asset/liability)
},
accounts: [],
currencies: [],
users: [],
settings: null,
userWalletSettings: null,
userInfo: null, // User information including equity eligibility
isAdmin: false,
isSuperUser: false,
castleWalletConfigured: false,
userWalletConfigured: false,
currentExchangeRate: null, // BTC/EUR rate (sats per EUR)
expenseDialog: {
show: false,
description: '',
amount: null,
expenseAccount: '',
isEquity: false,
reference: '',
currency: 'EUR',
date: new Date().toISOString().split('T')[0], // YYYY-MM-DD format
loading: false
},
payDialog: {
show: false,
amount: null,
paymentRequest: null,
paymentHash: null,
checkWalletKey: null,
pollIntervalId: null,
loading: false
},
settingsDialog: {
show: false,
castleWalletId: '',
loading: false
},
userWalletDialog: {
show: false,
userWalletId: '',
loading: false
},
receivableDialog: {
show: false,
selectedUser: '',
description: '',
amount: null,
revenueAccount: '',
reference: '',
currency: null,
loading: false
},
manualPaymentDialog: {
show: false,
amount: null,
description: '',
loading: false
},
manualPaymentRequests: [],
pendingExpenses: [],
balanceAssertions: [],
assertionDialog: {
show: false,
account_id: '',
expected_balance_sats: 0,
expected_balance_fiat: null,
fiat_currency: null,
tolerance_sats: 0,
tolerance_fiat: 0,
loading: false
},
reconciliation: {
summary: null,
discrepancies: null,
checking: false,
showDiscrepancies: false
},
settleReceivableDialog: {
show: false,
user_id: '',
username: '',
maxAmount: 0,
amount: 0,
payment_method: 'cash',
description: '',
reference: '',
loading: false,
invoice: null,
paymentHash: null,
checkWalletKey: null,
pollIntervalId: null,
exchangeRate: null, // Will be set from currentExchangeRate
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: null, // Will be set from currentExchangeRate
originalCurrency: 'BTC'
}
}
},
watch: {
'payDialog.show': function(newVal) {
// When dialog is closed, stop polling
if (!newVal && this.payDialog.pollIntervalId) {
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
}
},
'settleReceivableDialog.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.settleReceivableDialog.amount = this.settleReceivableDialog.maxAmount
}
// Convert from sats to fiat (when switching from lightning to cash)
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: {
transactionColumns() {
return [
{ name: 'flag', label: 'Status', field: 'flag', align: 'left', sortable: true },
{ name: 'username', label: 'User', field: 'username', align: 'left', sortable: true },
{ name: 'date', label: 'Date', field: 'entry_date', align: 'left', sortable: true },
{ name: 'description', label: 'Description', field: 'description', align: 'left', sortable: false },
{ name: 'amount', label: 'Amount (sats)', field: 'amount', align: 'right', sortable: false },
{ name: 'fiat', label: 'Fiat Amount', field: 'fiat', align: 'right', sortable: false },
{ name: 'reference', label: 'Reference', field: 'reference', align: 'left', sortable: false }
]
},
accountTypeOptions() {
return [
{ label: 'All Types', value: null },
{ label: 'Receivable (User owes Castle)', value: 'asset' },
{ label: 'Payable (Castle owes User)', value: 'liability' },
{ label: 'Equity (User Balance)', value: 'equity' }
]
},
expenseAccounts() {
return this.accounts.filter(a => a.account_type === 'expense')
},
revenueAccounts() {
return this.accounts.filter(a => a.account_type === 'revenue')
},
amountLabel() {
if (this.expenseDialog.currency) {
return `Amount (${this.expenseDialog.currency}) *`
}
return 'Amount (sats) *'
},
receivableAmountLabel() {
if (this.receivableDialog.currency) {
return `Amount (${this.receivableDialog.currency}) *`
}
return 'Amount (sats) *'
},
settlementAmountLabel() {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.settleReceivableDialog.payment_method
)
if (isCashPayment && this.settleReceivableDialog.fiatCurrency) {
return `Settlement Amount (${this.settleReceivableDialog.fiatCurrency}) *`
}
return 'Settlement Amount (sats) *'
},
settlementMaxAmount() {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.settleReceivableDialog.payment_method
)
if (isCashPayment && this.settleReceivableDialog.maxAmountFiat) {
return this.settleReceivableDialog.maxAmountFiat
}
return this.settleReceivableDialog.maxAmount
},
settlementAmountStep() {
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.settleReceivableDialog.payment_method
)
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 => {
options.push({label: curr, value: curr})
})
return options
},
userOptions() {
const options = []
this.users.forEach(user => {
options.push({
label: user.username,
value: user.user_id
})
})
return options
},
pendingManualPaymentRequests() {
return this.manualPaymentRequests.filter(r => r.status === 'pending')
},
failedAssertions() {
return this.balanceAssertions.filter(a => a.status === 'failed')
},
outstandingUserBalances() {
// Filter to only show users with non-zero balances
return this.allUserBalances.filter(user => user.balance !== 0)
},
passedAssertions() {
return this.balanceAssertions.filter(a => a.status === 'passed')
},
allAccounts() {
return this.accounts
}
},
methods: {
async loadBalance() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/balance',
this.g.user.wallets[0].inkey
)
this.balance = response.data
// If super user, also load all user balances
if (this.isSuperUser) {
await this.loadAllUserBalances()
}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async loadAllUserBalances() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/balances/all',
this.g.user.wallets[0].adminkey
)
this.allUserBalances = response.data
} catch (error) {
console.error('Error loading all user balances:', error)
}
},
async loadTransactions(offset = null) {
try {
// Use provided offset or current pagination offset, ensure it's an integer
let currentOffset = 0
if (offset !== null && offset !== undefined) {
currentOffset = parseInt(offset)
} else if (this.transactionPagination && this.transactionPagination.offset !== null && this.transactionPagination.offset !== undefined) {
currentOffset = parseInt(this.transactionPagination.offset)
}
// Final safety check - ensure it's a valid number
if (isNaN(currentOffset)) {
currentOffset = 0
}
const limit = parseInt(this.transactionPagination.limit) || 20
// Build query params with filters
let queryParams = `limit=${limit}&offset=${currentOffset}`
if (this.transactionFilter.user_id) {
queryParams += `&filter_user_id=${this.transactionFilter.user_id}`
}
if (this.transactionFilter.account_type) {
queryParams += `&filter_account_type=${this.transactionFilter.account_type}`
}
const response = await LNbits.api.request(
'GET',
`/castle/api/v1/entries/user?${queryParams}`,
this.g.user.wallets[0].inkey
)
// Update transactions and pagination info
this.transactions = response.data.entries
this.transactionPagination.total = response.data.total
this.transactionPagination.offset = parseInt(response.data.offset) || 0
this.transactionPagination.has_next = response.data.has_next
this.transactionPagination.has_prev = response.data.has_prev
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
applyTransactionFilter() {
// Reset to first page when applying filter
this.transactionPagination.offset = 0
this.loadTransactions(0)
},
clearTransactionFilter() {
this.transactionFilter.user_id = null
this.transactionFilter.account_type = null
this.transactionPagination.offset = 0
this.loadTransactions(0)
},
nextTransactionsPage() {
if (this.transactionPagination.has_next) {
const newOffset = this.transactionPagination.offset + this.transactionPagination.limit
this.loadTransactions(newOffset)
}
},
prevTransactionsPage() {
if (this.transactionPagination.has_prev) {
const newOffset = Math.max(0, this.transactionPagination.offset - this.transactionPagination.limit)
this.loadTransactions(newOffset)
}
},
async loadAccounts() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/accounts',
this.g.user.wallets[0].inkey
)
this.accounts = response.data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async loadCurrencies() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/currencies',
this.g.user.wallets[0].inkey
)
this.currencies = response.data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async loadUsers() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/users',
this.g.user.wallets[0].adminkey
)
this.users = response.data
} catch (error) {
console.error('Error loading users:', error)
}
},
async loadUserInfo() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/user/info',
this.g.user.wallets[0].inkey
)
this.userInfo = response.data
} catch (error) {
console.error('Error loading user info:', error)
this.userInfo = { is_equity_eligible: false }
}
},
async loadSettings() {
try {
// Try with admin key first to check settings
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/settings',
this.g.user.wallets[0].inkey
)
this.settings = response.data
this.castleWalletConfigured = !!(this.settings && this.settings.castle_wallet_id)
// Check if user is super user by seeing if they can access admin features
this.isSuperUser = this.g.user.super_user || false
this.isAdmin = this.g.user.admin || this.isSuperUser
} catch (error) {
// Settings not available
this.castleWalletConfigured = false
}
},
async loadUserWallet() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/user/wallet',
this.g.user.wallets[0].inkey
)
this.userWalletSettings = response.data
this.userWalletConfigured = !!(this.userWalletSettings && this.userWalletSettings.user_wallet_id)
} catch (error) {
this.userWalletConfigured = false
}
},
showSettingsDialog() {
this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || ''
this.settingsDialog.show = true
},
showUserWalletDialog() {
this.userWalletDialog.userWalletId = this.userWalletSettings?.user_wallet_id || ''
this.userWalletDialog.show = true
},
async submitSettings() {
if (!this.settingsDialog.castleWalletId) {
this.$q.notify({
type: 'warning',
message: 'Castle Wallet ID is required'
})
return
}
this.settingsDialog.loading = true
try {
await LNbits.api.request(
'PUT',
'/castle/api/v1/settings',
this.g.user.wallets[0].adminkey,
{
castle_wallet_id: this.settingsDialog.castleWalletId
}
)
this.$q.notify({
type: 'positive',
message: 'Settings updated successfully'
})
this.settingsDialog.show = false
await this.loadSettings()
// Reload user wallet to reflect castle wallet for super user
if (this.isSuperUser) {
await this.loadUserWallet()
}
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.settingsDialog.loading = false
}
},
async submitUserWallet() {
if (!this.userWalletDialog.userWalletId) {
this.$q.notify({
type: 'warning',
message: 'Wallet ID is required'
})
return
}
this.userWalletDialog.loading = true
try {
await LNbits.api.request(
'PUT',
'/castle/api/v1/user/wallet',
this.g.user.wallets[0].inkey,
{
user_wallet_id: this.userWalletDialog.userWalletId
}
)
this.$q.notify({
type: 'positive',
message: 'Wallet configured successfully'
})
this.userWalletDialog.show = false
await this.loadUserWallet()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.userWalletDialog.loading = false
}
},
async submitExpense() {
this.expenseDialog.loading = true
try {
await LNbits.api.request(
'POST',
'/castle/api/v1/entries/expense',
this.g.user.wallets[0].inkey,
{
description: this.expenseDialog.description,
amount: this.expenseDialog.amount,
expense_account: this.expenseDialog.expenseAccount,
is_equity: this.expenseDialog.isEquity,
user_wallet: this.g.user.wallets[0].id,
reference: this.expenseDialog.reference || null,
currency: this.expenseDialog.currency || null,
entry_date: this.expenseDialog.date ? `${this.expenseDialog.date}T00:00:00` : null
}
)
this.$q.notify({
type: 'positive',
message: 'Expense added successfully'
})
this.expenseDialog.show = false
this.resetExpenseDialog()
await this.loadBalance()
await this.loadTransactions()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.expenseDialog.loading = false
}
},
async submitPayment() {
this.payDialog.loading = true
// Clear any existing polling interval
if (this.payDialog.pollIntervalId) {
clearInterval(this.payDialog.pollIntervalId)
this.payDialog.pollIntervalId = null
}
try {
// Generate an invoice on the Castle wallet
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/generate-payment-invoice',
this.g.user.wallets[0].inkey,
{
amount: this.payDialog.amount
}
)
// Show the payment request in the dialog
this.payDialog.paymentRequest = response.data.payment_request
this.payDialog.paymentHash = response.data.payment_hash
this.payDialog.checkWalletKey = response.data.check_wallet_key
this.$q.notify({
type: 'positive',
message: 'Invoice generated! Scan QR code or copy to pay.',
timeout: 3000
})
// Poll for payment completion
this.pollForPayment(response.data.payment_hash, response.data.check_wallet_key)
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.payDialog.loading = false
}
},
async pollForPayment(paymentHash, checkWalletKey) {
// Clear any existing interval
if (this.payDialog.pollIntervalId) {
clearInterval(this.payDialog.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
try {
await LNbits.api.request(
'POST',
'/castle/api/v1/record-payment',
this.g.user.wallets[0].inkey,
{
payment_hash: paymentHash
}
)
} catch (error) {
console.error('Error recording payment:', error)
}
this.$q.notify({
type: 'positive',
message: 'Payment received! Your balance has been updated.',
timeout: 3000
})
this.payDialog.show = false
this.payDialog.paymentRequest = null
this.payDialog.amount = null
await this.loadBalance()
await this.loadTransactions()
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.payDialog.pollIntervalId = setInterval(async () => {
attempts++
const paid = await checkPayment()
if (paid || attempts >= maxAttempts) {
clearInterval(this.payDialog.pollIntervalId)
this.payDialog.pollIntervalId = null
}
}, 2000)
},
showManualPaymentOption() {
// This is for when user wants to pay their debt manually
// For now, just notify them to contact castle
this.$q.notify({
type: 'info',
message: 'Please contact Castle directly to arrange manual payment.',
timeout: 3000
})
},
showManualPaymentDialog() {
// This is for when Castle owes user and they want to request manual payment
this.manualPaymentDialog.amount = Math.abs(this.balance.balance)
this.manualPaymentDialog.description = ''
this.manualPaymentDialog.show = true
},
async submitManualPaymentRequest() {
this.manualPaymentDialog.loading = true
try {
await LNbits.api.request(
'POST',
'/castle/api/v1/manual-payment-request',
this.g.user.wallets[0].inkey,
{
amount: this.manualPaymentDialog.amount,
description: this.manualPaymentDialog.description
}
)
this.$q.notify({
type: 'positive',
message: 'Manual payment request submitted successfully!'
})
this.manualPaymentDialog.show = false
this.manualPaymentDialog.amount = null
this.manualPaymentDialog.description = ''
await this.loadManualPaymentRequests()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.manualPaymentDialog.loading = false
}
},
async loadManualPaymentRequests() {
try {
// If super user, load all requests; otherwise load user's own requests
const endpoint = this.isSuperUser
? '/castle/api/v1/manual-payment-requests/all'
: '/castle/api/v1/manual-payment-requests'
const key = this.isSuperUser
? this.g.user.wallets[0].adminkey
: this.g.user.wallets[0].inkey
const response = await LNbits.api.request(
'GET',
endpoint,
key,
this.isSuperUser ? {status: 'pending'} : {}
)
this.manualPaymentRequests = response.data
} catch (error) {
console.error('Error loading manual payment requests:', error)
}
},
async loadPendingExpenses() {
try {
if (!this.isSuperUser) return
if (!this.g.user.wallets[0]?.adminkey) return
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/entries/pending',
this.g.user.wallets[0].adminkey
)
this.pendingExpenses = response.data
} catch (error) {
console.error('Error loading pending expenses:', error)
}
},
async approveManualPaymentRequest(requestId) {
try {
await LNbits.api.request(
'POST',
`/castle/api/v1/manual-payment-requests/${requestId}/approve`,
this.g.user.wallets[0].adminkey
)
this.$q.notify({
type: 'positive',
message: 'Manual payment request approved!'
})
await this.loadManualPaymentRequests()
await this.loadBalance()
await this.loadTransactions()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async rejectManualPaymentRequest(requestId) {
try {
await LNbits.api.request(
'POST',
`/castle/api/v1/manual-payment-requests/${requestId}/reject`,
this.g.user.wallets[0].adminkey
)
this.$q.notify({
type: 'warning',
message: 'Manual payment request rejected'
})
await this.loadManualPaymentRequests()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async approveExpense(entryId) {
try {
await LNbits.api.request(
'POST',
`/castle/api/v1/entries/${entryId}/approve`,
this.g.user.wallets[0].adminkey
)
this.$q.notify({
type: 'positive',
message: 'Expense approved!'
})
await this.loadPendingExpenses()
await this.loadBalance()
await this.loadTransactions()
await this.loadAllUserBalances()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async rejectExpense(entryId) {
try {
await LNbits.api.request(
'POST',
`/castle/api/v1/entries/${entryId}/reject`,
this.g.user.wallets[0].adminkey
)
this.$q.notify({
type: 'warning',
message: 'Expense rejected'
})
await this.loadPendingExpenses()
await this.loadTransactions()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async loadBalanceAssertions() {
if (!this.isSuperUser) return
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/assertions',
this.g.user.wallets[0].adminkey
)
this.balanceAssertions = response.data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async submitAssertion() {
this.assertionDialog.loading = true
try {
const payload = {
account_id: this.assertionDialog.account_id,
expected_balance_sats: this.assertionDialog.expected_balance_sats,
tolerance_sats: this.assertionDialog.tolerance_sats || 0
}
// Add fiat balance check if currency selected
if (this.assertionDialog.fiat_currency) {
payload.fiat_currency = this.assertionDialog.fiat_currency
payload.expected_balance_fiat = this.assertionDialog.expected_balance_fiat
payload.tolerance_fiat = this.assertionDialog.tolerance_fiat || 0
}
await LNbits.api.request(
'POST',
'/castle/api/v1/assertions',
this.g.user.wallets[0].adminkey,
payload
)
this.$q.notify({
type: 'positive',
message: 'Balance assertion passed!',
timeout: 3000
})
// Reset dialog
this.assertionDialog = {
show: false,
account_id: '',
expected_balance_sats: 0,
expected_balance_fiat: null,
fiat_currency: null,
tolerance_sats: 0,
tolerance_fiat: 0,
loading: false
}
// Reload assertions
await this.loadBalanceAssertions()
} catch (error) {
// Check if it's a 409 Conflict (assertion failed)
if (error.response && error.response.status === 409) {
const detail = error.response.data.detail
this.$q.notify({
type: 'negative',
message: `Assertion Failed! Expected: ${this.formatSats(detail.expected_sats)} sats, Got: ${this.formatSats(detail.actual_sats)} sats (diff: ${this.formatSats(detail.difference_sats)} sats)`,
timeout: 5000,
html: true
})
// Still reload to show the failed assertion
await this.loadBalanceAssertions()
this.assertionDialog.show = false
} else {
LNbits.utils.notifyApiError(error)
}
} finally {
this.assertionDialog.loading = false
}
},
async recheckAssertion(assertionId) {
try {
await LNbits.api.request(
'POST',
`/castle/api/v1/assertions/${assertionId}/check`,
this.g.user.wallets[0].adminkey
)
this.$q.notify({
type: 'positive',
message: 'Assertion re-checked',
timeout: 2000
})
await this.loadBalanceAssertions()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async deleteAssertion(assertionId) {
try {
await LNbits.api.request(
'DELETE',
`/castle/api/v1/assertions/${assertionId}`,
this.g.user.wallets[0].adminkey
)
this.$q.notify({
type: 'positive',
message: 'Assertion deleted',
timeout: 2000
})
await this.loadBalanceAssertions()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getAccountName(accountId) {
const account = this.accounts.find(a => a.id === accountId)
return account ? account.name : accountId
},
getUserName(userId) {
const user = this.users.find(u => u.user_id === userId)
return user ? user.username : userId.substring(0, 16) + '...'
},
async loadReconciliationSummary() {
if (!this.isSuperUser) return
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/reconciliation/summary',
this.g.user.wallets[0].adminkey
)
this.reconciliation.summary = response.data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async loadReconciliationDiscrepancies() {
if (!this.isSuperUser) return
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/reconciliation/discrepancies',
this.g.user.wallets[0].adminkey
)
this.reconciliation.discrepancies = response.data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async runFullReconciliation() {
this.reconciliation.checking = true
try {
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/reconciliation/check-all',
this.g.user.wallets[0].adminkey
)
const results = response.data
this.$q.notify({
type: results.failed > 0 ? 'warning' : 'positive',
message: `Checked ${results.checked} assertions: ${results.passed} passed, ${results.failed} failed`,
timeout: 3000
})
// Reload reconciliation data
await this.loadReconciliationSummary()
await this.loadReconciliationDiscrepancies()
await this.loadBalanceAssertions()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.reconciliation.checking = false
}
},
copyToClipboard(text) {
navigator.clipboard.writeText(text)
this.$q.notify({
type: 'positive',
message: 'Copied to clipboard!',
timeout: 1000
})
},
showPayBalanceDialog() {
// Clear any existing polling
if (this.payDialog.pollIntervalId) {
clearInterval(this.payDialog.pollIntervalId)
this.payDialog.pollIntervalId = null
}
this.payDialog.amount = Math.abs(this.balance.balance)
this.payDialog.paymentRequest = null
this.payDialog.paymentHash = null
this.payDialog.checkWalletKey = null
this.payDialog.show = true
},
async showReceivableDialog() {
// Load users if not already loaded
if (this.users.length === 0 && this.isSuperUser) {
await this.loadUsers()
}
this.receivableDialog.show = true
},
async submitReceivable() {
this.receivableDialog.loading = true
try {
await LNbits.api.request(
'POST',
'/castle/api/v1/entries/receivable',
this.g.user.wallets[0].adminkey,
{
description: this.receivableDialog.description,
amount: this.receivableDialog.amount,
revenue_account: this.receivableDialog.revenueAccount,
user_id: this.receivableDialog.selectedUser,
reference: this.receivableDialog.reference || null,
currency: this.receivableDialog.currency || null
}
)
this.$q.notify({
type: 'positive',
message: 'Receivable added successfully'
})
this.receivableDialog.show = false
this.resetReceivableDialog()
await this.loadBalance()
await this.loadTransactions()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.receivableDialog.loading = false
}
},
resetExpenseDialog() {
this.expenseDialog.description = ''
this.expenseDialog.amount = null
this.expenseDialog.expenseAccount = ''
this.expenseDialog.isEquity = false
this.expenseDialog.reference = ''
this.expenseDialog.currency = 'EUR'
this.expenseDialog.date = new Date().toISOString().split('T')[0]
},
resetReceivableDialog() {
this.receivableDialog.selectedUser = ''
this.receivableDialog.description = ''
this.receivableDialog.amount = null
this.receivableDialog.revenueAccount = ''
this.receivableDialog.reference = ''
this.receivableDialog.currency = null
},
showSettleReceivableDialog(userBalance) {
// 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)
}
// Extract fiat balances (e.g., EUR)
const fiatBalances = userBalance.fiat_balances || {}
const fiatCurrency = Object.keys(fiatBalances)[0] || null // Get first fiat currency (e.g., 'EUR')
const fiatAmount = fiatCurrency ? Math.abs(fiatBalances[fiatCurrency]) : 0
this.settleReceivableDialog = {
show: true,
user_id: userBalance.user_id,
username: userBalance.username,
maxAmount: Math.abs(userBalance.balance), // Sats amount
maxAmountFiat: fiatAmount, // EUR or other fiat amount
fiatCurrency: fiatCurrency, // 'EUR', 'USD', etc.
amount: fiatCurrency ? fiatAmount : Math.abs(userBalance.balance), // Default to fiat if available, otherwise sats
payment_method: fiatCurrency ? 'cash' : 'lightning', // Default to cash if fiat balance exists
description: '', // Will be auto-generated based on payment method if left empty
reference: '',
loading: false,
invoice: null,
paymentHash: null,
checkWalletKey: null,
pollIntervalId: null,
exchangeRate: fiatAmount > 0 ? Math.abs(userBalance.balance) / fiatAmount : this.currentExchangeRate, // Calculate rate from actual amounts or use current rate
originalCurrency: fiatCurrency || 'BTC'
}
},
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,
user_id: this.settleReceivableDialog.user_id // Specify which user this invoice is for
}
)
// 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) {
// Stop polling immediately
clearInterval(this.settleReceivableDialog.pollIntervalId)
this.settleReceivableDialog.pollIntervalId = null
// Record payment in accounting - this creates the journal entry
// that settles the receivable
try {
const recordResponse = await LNbits.api.request(
'POST',
'/castle/api/v1/record-payment',
this.g.user.wallets[0].adminkey,
{
payment_hash: paymentHash
}
)
console.log('Settlement payment recorded:', recordResponse.data)
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()
} catch (error) {
console.error('Error recording settlement payment:', error)
this.$q.notify({
type: 'negative',
message: 'Payment detected but failed to record: ' + (error.response?.data?.detail || error.message),
timeout: 5000
})
}
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 {
// Determine if this is a fiat payment
const isCashPayment = ['cash', 'bank_transfer', 'check'].includes(
this.settleReceivableDialog.payment_method
)
// Create description with payment method
const paymentMethodLabels = {
'cash': 'Cash payment',
'bank_transfer': 'Bank transfer',
'check': 'Check payment',
'lightning': 'Lightning payment',
'other': 'Payment'
}
const methodLabel = paymentMethodLabels[this.settleReceivableDialog.payment_method] || 'Payment'
const description = this.settleReceivableDialog.description ||
`${methodLabel} from ${this.settleReceivableDialog.username}`
const payload = {
user_id: this.settleReceivableDialog.user_id,
amount: this.settleReceivableDialog.amount,
payment_method: this.settleReceivableDialog.payment_method,
description: description,
reference: this.settleReceivableDialog.reference || null,
}
// Add currency info for fiat payments
if (isCashPayment && this.settleReceivableDialog.fiatCurrency) {
payload.currency = this.settleReceivableDialog.fiatCurrency
payload.amount_sats = this.settleReceivableDialog.maxAmount
}
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/receivables/settle',
this.g.user.wallets[0].adminkey,
payload
)
this.$q.notify({
type: 'positive',
message: response.data.message,
timeout: 3000
})
this.settleReceivableDialog.show = false
// Reload balances
await this.loadBalance()
await this.loadTransactions()
await this.loadAllUserBalances()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
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 : this.currentExchangeRate,
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
}
},
async loadExchangeRate() {
try {
// Fetch current BTC/EUR rate from CoinGecko API
const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur')
const data = await response.json()
if (data && data.bitcoin && data.bitcoin.eur) {
// Convert BTC/EUR to sats/EUR (1 BTC = 100,000,000 sats)
const btcPerEur = 1 / data.bitcoin.eur
this.currentExchangeRate = Math.round(btcPerEur * 100000000)
console.log('Exchange rate loaded:', this.currentExchangeRate, 'sats per EUR')
} else {
throw new Error('Invalid response from exchange rate API')
}
} catch (error) {
console.error('Error loading exchange rate:', error)
this.currentExchangeRate = null
this.$q.notify({
type: 'warning',
message: 'Failed to load exchange rate. Fiat currency conversions may not be available.',
timeout: 5000
})
}
},
formatSats(amount) {
return new Intl.NumberFormat().format(amount)
},
formatFiat(amount, currency) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount)
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString()
},
getTotalAmount(entry) {
return entry.amount
},
getEntryFiatAmount(entry) {
if (entry.fiat_amount && entry.fiat_currency) {
return this.formatFiat(entry.fiat_amount, entry.fiat_currency)
}
return null
},
isReceivable(entry) {
// Check if this is a receivable entry (user owes castle)
if (entry.tags && entry.tags.includes('receivable-entry')) return true
if (entry.account && entry.account.includes('Receivable')) return true
return false
},
isPayable(entry) {
// Check if this is a payable entry (castle owes user)
if (entry.tags && entry.tags.includes('expense-entry')) return true
if (entry.account && entry.account.includes('Payable')) return true
return false
},
isEquity(entry) {
// Check if this is an equity entry (user capital contribution/balance)
if (entry.tags && entry.tags.includes('equity-contribution')) return true
if (entry.account && entry.account.includes('Equity')) return true
return false
}
},
async created() {
// Load settings first to determine if user is super user
await this.loadSettings()
await this.loadUserWallet()
await this.loadUserInfo()
await this.loadExchangeRate()
await this.loadBalance()
await this.loadTransactions()
await this.loadAccounts()
await this.loadCurrencies()
await this.loadManualPaymentRequests()
// Load users and pending expenses if super user
if (this.isSuperUser) {
await this.loadUsers()
await this.loadPendingExpenses()
await this.loadBalanceAssertions()
await this.loadReconciliationSummary()
await this.loadReconciliationDiscrepancies()
}
}
})