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, expenseDialog: { show: false, description: '', amount: null, expenseAccount: '', isEquity: false, reference: '', currency: null, 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 } } }, watch: { 'payDialog.show': function(newVal) { // When dialog is closed, stop polling if (!newVal && this.payDialog.pollIntervalId) { clearInterval(this.payDialog.pollIntervalId) this.payDialog.pollIntervalId = null } } }, 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) *' }, 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') }, 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 } ) 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 }, 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 = null }, resetReceivableDialog() { this.receivableDialog.selectedUser = '' this.receivableDialog.description = '' this.receivableDialog.amount = null this.receivableDialog.revenueAccount = '' this.receivableDialog.reference = '' this.receivableDialog.currency = null }, 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.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() } } })