const mapJournalEntry = obj => { return obj } window.app = Vue.createApp({ el: '#vue', mixins: [windowMixin], data() { return { balance: null, allUserBalances: [], transactions: [], accounts: [], currencies: [], users: [], settings: null, userWalletSettings: null, 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: { 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() { try { const response = await LNbits.api.request( 'GET', '/castle/api/v1/entries/user', this.g.user.wallets[0].inkey ) this.transactions = response.data } catch (error) { LNbits.utils.notifyApiError(error) } }, 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 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) { if (!entry.lines || entry.lines.length === 0) return 0 return entry.lines.reduce((sum, line) => sum + line.debit + line.credit, 0) / 2 }, getEntryFiatAmount(entry) { // Extract fiat amount from metadata if available if (!entry.lines || entry.lines.length === 0) return null for (const line of entry.lines) { if (line.metadata && line.metadata.fiat_currency && line.metadata.fiat_amount) { return this.formatFiat(line.metadata.fiat_amount, line.metadata.fiat_currency) } } return null }, isReceivable(entry) { // Check if this is a receivable entry (user owes castle) // Receivables have a debit to an "Accounts Receivable" account with the user's ID if (!entry.lines || entry.lines.length === 0) return false for (const line of entry.lines) { // Look for a line with positive debit on an accounts receivable account if (line.debit > 0) { // Check if the account is associated with this user's receivables const account = this.accounts.find(a => a.id === line.account_id) if (account && account.name && account.name.includes('Assets:Receivable') && account.account_type === 'asset') { return true } } } return false }, isPayable(entry) { // Check if this is a payable entry (castle owes user) // Payables have a credit to an "Accounts Payable" account with the user's ID if (!entry.lines || entry.lines.length === 0) return false for (const line of entry.lines) { // Look for a line with positive credit on an accounts payable account if (line.credit > 0) { // Check if the account is associated with this user's payables const account = this.accounts.find(a => a.id === line.account_id) if (account && account.name && account.name.includes('Liabilities:Payable') && account.account_type === 'liability') { return true } } } return false } }, async created() { // Load settings first to determine if user is super user await this.loadSettings() await this.loadUserWallet() 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() } } })