diff --git a/static/js/index.js b/static/js/index.js index 50b0fce..f1b20a8 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,773 +1,286 @@ +// Satoshi Machine v2 — operator dashboard (P9a foundation). +// +// Vue 3 + Quasar UMD app. Talks to the v2 satmachineadmin REST surface +// (machines / clients / deposits / settlements / commission-splits / +// super-config). All endpoints are operator-scoped via the LNbits session. +// +// LNbits UMD/Quasar conventions in play: +// - Vue delimiters are `${ ... }` because Jinja owns `{{ }}` in the +// template file. Use v-text / :attr binding rather than mustache. +// - For per-element typography overrides, prefer :style — Quasar's +// .text-grey-* / .text-caption utilities collide with LNbits' theme. +// - For pale backgrounds (bg-*-1), pair with explicit dark text class +// so dark-mode users don't get unreadable white-on-cream. + +const SUPER_FEE_PATH = '/satmachineadmin/api/v1/dca/super-config' +const MACHINES_PATH = '/satmachineadmin/api/v1/dca/machines' +const STUCK_PATH = '/satmachineadmin/api/v1/dca/settlements/stuck' + window.app = Vue.createApp({ el: '#vue', mixins: [windowMixin], delimiters: ['${', '}'], - data: function () { + + data() { return { - // DCA Admin Data - dcaClients: [], - deposits: [], - lamassuTransactions: [], + activeTab: 'fleet', + refreshing: false, - // Table configurations - clientsTable: { + // Server state --------------------------------------------------- + superConfig: null, + machines: [], + worklistCount: 0, + + // UI configuration ----------------------------------------------- + machinesTable: { columns: [ - { name: 'username', align: 'left', label: 'Username', field: 'username' }, - { name: 'user_id', align: 'left', label: 'User ID', field: 'user_id' }, - { name: 'wallet_id', align: 'left', label: 'Wallet ID', field: 'wallet_id' }, - { name: 'dca_mode', align: 'left', label: 'DCA Mode', field: 'dca_mode' }, - { name: 'remaining_balance', align: 'right', label: 'Remaining Balance', field: 'remaining_balance' }, - { name: 'fixed_mode_daily_limit', align: 'left', label: 'Daily Limit', field: 'fixed_mode_daily_limit' }, - { name: 'status', align: 'left', label: 'Status', field: 'status' } + {name: 'status', label: '', field: 'is_active', align: 'center'}, + {name: 'name', label: 'Name / Location', field: 'name', align: 'left'}, + {name: 'machine_npub', label: 'npub', field: 'machine_npub', align: 'left'}, + {name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'}, + {name: 'fiat_code', label: 'Fiat', field: 'fiat_code', align: 'left'}, + { + name: 'fallback_commission_pct', + label: 'Fallback %', + field: 'fallback_commission_pct', + align: 'right' + }, + {name: 'actions', label: 'Actions', field: 'id', align: 'right'} ], - pagination: { - rowsPerPage: 10 - } - }, - depositsTable: { - columns: [ - { name: 'client_id', align: 'left', label: 'Client', field: 'client_id' }, - { name: 'amount', align: 'left', label: 'Amount', field: 'amount' }, - { name: 'currency', align: 'left', label: 'Currency', field: 'currency' }, - { name: 'status', align: 'left', label: 'Status', field: 'status' }, - { name: 'created_at', align: 'left', label: 'Created', field: 'created_at' }, - { name: 'notes', align: 'left', label: 'Notes', field: 'notes' } - ], - pagination: { - rowsPerPage: 10 - } - }, - lamassuTransactionsTable: { - columns: [ - { name: 'lamassu_transaction_id', align: 'left', label: 'Transaction ID', field: 'lamassu_transaction_id' }, - { name: 'transaction_time', align: 'left', label: 'Time', field: 'transaction_time' }, - { name: 'fiat_amount', align: 'right', label: 'Fiat Amount', field: 'fiat_amount' }, - { name: 'crypto_amount', align: 'right', label: 'Total Sats', field: 'crypto_amount' }, - { name: 'commission_amount_sats', align: 'right', label: 'Commission', field: 'commission_amount_sats' }, - { name: 'base_amount_sats', align: 'right', label: 'Base Amount', field: 'base_amount_sats' }, - { name: 'distributions_total_sats', align: 'right', label: 'Distributed', field: 'distributions_total_sats' }, - { name: 'clients_count', align: 'center', label: 'Clients', field: 'clients_count' } - ], - pagination: { - rowsPerPage: 10 - } - }, - distributionDetailsTable: { - columns: [ - { name: 'client_username', align: 'left', label: 'Client', field: 'client_username' }, - { name: 'amount_sats', align: 'right', label: 'Amount (sats)', field: 'amount_sats' }, - { name: 'amount_fiat', align: 'right', label: 'Amount (fiat)', field: 'amount_fiat' }, - { name: 'status', align: 'center', label: 'Status', field: 'status' }, - { name: 'created_at', align: 'left', label: 'Created', field: 'created_at' } - ] + pagination: {rowsPerPage: 10, sortBy: 'name'} }, - // Dialog states - depositFormDialog: { + // Dialog state --------------------------------------------------- + addMachineDialog: { show: false, - data: { - currency: 'GTQ' - } + saving: false, + data: this._emptyMachineForm() }, - clientDetailsDialog: { + editMachineDialog: { show: false, - data: null, - balance: null - }, - distributionDialog: { - show: false, - transaction: null, - distributions: [] - }, - - // Quick deposit form - quickDepositForm: { - selectedClient: null, - amount: null, - notes: '' - }, - - // Polling status - lastPollTime: null, - testingConnection: false, - runningManualPoll: false, - runningTestTransaction: false, - processingSpecificTransaction: false, - lamassuConfig: null, - - // Manual transaction processing - manualTransactionDialog: { - show: false, - transactionId: '' - }, - - // Config dialog - configDialog: { - show: false, - data: { - host: '', - port: 5432, - database_name: '', - username: '', - password: '', - selectedWallet: null, - selectedCommissionWallet: null, - // DCA Client Limits - max_daily_limit_gtq: 2000, - // SSH Tunnel settings - use_ssh_tunnel: false, - ssh_host: '', - ssh_port: 22, - ssh_username: '', - ssh_password: '', - ssh_private_key: '' - } - }, - - // Options - currencyOptions: [ - { label: 'GTQ', value: 'GTQ' }, - { label: 'USD', value: 'USD' } - ] + saving: false, + data: {} + } } }, - /////////////////////////////////////////////////// - ////////////////METHODS FUNCTIONS////////////////// - /////////////////////////////////////////////////// - - methods: { - // Utility Methods - formatCurrency(amount) { - if (!amount) return 'Q 0.00'; - - // Amount is now stored as GTQ directly in database - return new Intl.NumberFormat('es-GT', { - style: 'currency', - currency: 'GTQ', - }).format(amount); - }, - - formatDate(dateString) { - if (!dateString) return '' - return new Date(dateString).toLocaleDateString() - }, - - formatDateTime(dateString) { - if (!dateString) return '' - const date = new Date(dateString) - return date.toLocaleDateString() + ' ' + date.toLocaleTimeString('en-US', { hour12: false }) - }, - - formatSats(amount) { - if (!amount) return '0 sats' - return new Intl.NumberFormat('en-US').format(amount) + ' sats' - }, - - getClientUsername(clientId) { - const client = this.dcaClients.find(c => c.id === clientId) - return client ? (client.username || client.user_id.substring(0, 8) + '...') : clientId - }, - - - // Configuration Methods - async getLamassuConfig() { - try { - const { data } = await LNbits.api.request( - 'GET', - '/satmachineadmin/api/v1/dca/config', - null - ) - this.lamassuConfig = data - - // When opening config dialog, populate the selected wallets if they exist - if (data && data.source_wallet_id && this.g.user?.wallets) { - const wallet = this.g.user.wallets.find(w => w.id === data.source_wallet_id) - if (wallet) { - this.configDialog.data.selectedWallet = wallet - } - } - if (data && data.commission_wallet_id && this.g.user?.wallets) { - const commissionWallet = this.g.user.wallets.find(w => w.id === data.commission_wallet_id) - if (commissionWallet) { - this.configDialog.data.selectedCommissionWallet = commissionWallet - } - } - - // Populate other configuration fields - if (data) { - this.configDialog.data.max_daily_limit_gtq = data.max_daily_limit_gtq || 2000 - } - } catch (error) { - // It's OK if no config exists yet - this.lamassuConfig = null - } - }, - - async saveConfiguration() { - try { - const data = { - host: this.configDialog.data.host, - port: this.configDialog.data.port, - database_name: this.configDialog.data.database_name, - username: this.configDialog.data.username, - password: this.configDialog.data.password, - source_wallet_id: this.configDialog.data.selectedWallet?.id, - commission_wallet_id: this.configDialog.data.selectedCommissionWallet?.id, - // SSH Tunnel settings - max_daily_limit_gtq: this.configDialog.data.max_daily_limit_gtq, - use_ssh_tunnel: this.configDialog.data.use_ssh_tunnel, - ssh_host: this.configDialog.data.ssh_host, - ssh_port: this.configDialog.data.ssh_port, - ssh_username: this.configDialog.data.ssh_username, - ssh_password: this.configDialog.data.ssh_password, - ssh_private_key: this.configDialog.data.ssh_private_key - } - - const { data: config } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/config', - null, - data - ) - - this.lamassuConfig = config - this.closeConfigDialog() - - this.$q.notify({ - type: 'positive', - message: 'Database configuration saved successfully', - timeout: 5000 - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - closeConfigDialog() { - this.configDialog.show = false - this.configDialog.data = { - host: '', - port: 5432, - database_name: '', - username: '', - password: '', - selectedWallet: null, - selectedCommissionWallet: null, - // DCA Client Limits - max_daily_limit_gtq: 2000, - // SSH Tunnel settings - use_ssh_tunnel: false, - ssh_host: '', - ssh_port: 22, - ssh_username: '', - ssh_password: '', - ssh_private_key: '' - } - }, - - // DCA Client Methods - async getDcaClients() { - try { - const { data } = await LNbits.api.request( - 'GET', - '/satmachineadmin/api/v1/dca/clients', - null - ) - - // Fetch balance data for each client - const clientsWithBalances = await Promise.all( - data.map(async (client) => { - try { - const { data: balance } = await LNbits.api.request( - 'GET', - `/satmachineadmin/api/v1/dca/clients/${client.id}/balance`, - null - ) - return { - ...client, - remaining_balance: balance.remaining_balance - } - } catch (error) { - console.error(`Error fetching balance for client ${client.id}:`, error) - return { - ...client, - remaining_balance: 0 - } - } - }) - ) - - this.dcaClients = clientsWithBalances - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - - // Quick Deposit Methods - async sendQuickDeposit() { - try { - const data = { - client_id: this.quickDepositForm.selectedClient?.value, - amount: this.quickDepositForm.amount, // Send GTQ directly - now stored as GTQ - currency: 'GTQ', - notes: this.quickDepositForm.notes - } - - const { data: newDeposit } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/deposits', - null, - data - ) - - this.deposits.unshift(newDeposit) - - // Reset form - this.quickDepositForm = { - selectedClient: null, - amount: null, - notes: '' - } - - this.$q.notify({ - type: 'positive', - message: 'Deposit created successfully', - timeout: 5000 - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - async viewClientDetails(client) { - try { - const { data: balance } = await LNbits.api.request( - 'GET', - `/satmachineadmin/api/v1/dca/clients/${client.id}/balance`, - null - ) - this.clientDetailsDialog.data = client - this.clientDetailsDialog.balance = balance - this.clientDetailsDialog.show = true - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - // Deposit Methods - async getDeposits() { - try { - const { data } = await LNbits.api.request( - 'GET', - '/satmachineadmin/api/v1/dca/deposits', - null - ) - this.deposits = data - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - addDepositDialog(client) { - this.depositFormDialog.data = { - client_id: client.id, - client_name: client.username || `${client.user_id.substring(0, 8)}...`, - currency: 'GTQ' - } - this.depositFormDialog.show = true - }, - - async sendDepositData() { - try { - const data = { - client_id: this.depositFormDialog.data.client_id, - amount: this.depositFormDialog.data.amount, // Send GTQ directly - now stored as GTQ - currency: this.depositFormDialog.data.currency, - notes: this.depositFormDialog.data.notes - } - - if (this.depositFormDialog.data.id) { - // Update existing pending deposit - const { data: updatedDeposit } = await LNbits.api.request( - 'PUT', - `/satmachineadmin/api/v1/dca/deposits/${this.depositFormDialog.data.id}`, - null, - { amount: data.amount, currency: data.currency, notes: data.notes } - ) - const index = this.deposits.findIndex(d => d.id === updatedDeposit.id) - if (index !== -1) { - this.deposits.splice(index, 1, updatedDeposit) - } - } else { - // Create new deposit - const { data: newDeposit } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/deposits', - null, - data - ) - this.deposits.unshift(newDeposit) - } - - this.closeDepositFormDialog() - this.$q.notify({ - type: 'positive', - message: this.depositFormDialog.data.id ? 'Deposit updated successfully' : 'Deposit created successfully', - timeout: 5000 - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - closeDepositFormDialog() { - this.depositFormDialog.show = false - this.depositFormDialog.data = { - currency: 'GTQ' - } - }, - - async confirmDeposit(deposit) { - try { - await LNbits.utils - .confirmDialog('Confirm that this deposit has been physically placed in the ATM machine?') - .onOk(async () => { - const { data: updatedDeposit } = await LNbits.api.request( - 'PUT', - `/satmachineadmin/api/v1/dca/deposits/${deposit.id}/status`, - null, - { status: 'confirmed', notes: 'Confirmed by admin - money placed in machine' } - ) - const index = this.deposits.findIndex(d => d.id === deposit.id) - if (index !== -1) { - this.deposits.splice(index, 1, updatedDeposit) - } - this.$q.notify({ - type: 'positive', - message: 'Deposit confirmed! DCA is now active for this client.', - timeout: 5000 - }) - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - editDeposit(deposit) { - this.depositFormDialog.data = { ...deposit } - this.depositFormDialog.show = true - }, - - async deleteDeposit(deposit) { - try { - await LNbits.utils - .confirmDialog('Are you sure you want to delete this pending deposit?') - .onOk(async () => { - await LNbits.api.request( - 'DELETE', - `/satmachineadmin/api/v1/dca/deposits/${deposit.id}`, - null - ) - this.deposits = this.deposits.filter(d => d.id !== deposit.id) - this.$q.notify({ - type: 'positive', - message: 'Deposit deleted successfully', - timeout: 5000 - }) - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - // Export Methods - async exportClientsCSV() { - await LNbits.utils.exportCSV(this.clientsTable.columns, this.dcaClients) - }, - - async exportDepositsCSV() { - await LNbits.utils.exportCSV(this.depositsTable.columns, this.deposits) - }, - - async exportLamassuTransactionsCSV() { - await LNbits.utils.exportCSV(this.lamassuTransactionsTable.columns, this.lamassuTransactions) - }, - - // Polling Methods - async testDatabaseConnection() { - this.testingConnection = true - try { - const { data } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/test-connection', - null - ) - - // Show detailed results in a dialog - const stepsList = data.steps ? data.steps.join('\n') : 'No detailed steps available' - - let dialogContent = `Connection Test Results

` - - if (data.ssh_tunnel_used) { - dialogContent += `SSH Tunnel: ${data.ssh_tunnel_success ? '✅ Success' : '❌ Failed'}
` - } - - dialogContent += `Database: ${data.database_connection_success ? '✅ Success' : '❌ Failed'}

` - dialogContent += `Detailed Steps:
` - dialogContent += stepsList.replace(/\n/g, '
') - - this.$q.dialog({ - title: data.success ? 'Connection Test Passed' : 'Connection Test Failed', - message: dialogContent, - html: true, - ok: { - color: data.success ? 'positive' : 'negative', - label: 'Close' - } - }) - - // Also show a brief notification - this.$q.notify({ - type: data.success ? 'positive' : 'negative', - message: data.message, - timeout: 3000 - }) - - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.testingConnection = false - } - }, - - async manualPoll() { - this.runningManualPoll = true - try { - const { data } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/manual-poll', - null - ) - - this.lastPollTime = new Date().toLocaleString() - this.$q.notify({ - type: 'positive', - message: `Manual poll completed. Found ${data.transactions_processed} new transactions.`, - timeout: 5000 - }) - - // Refresh data - await this.getDcaClients() // Refresh to show updated balances - await this.getDeposits() - await this.getLamassuTransactions() - await this.getLamassuConfig() - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.runningManualPoll = false - } - }, - - async testTransaction() { - this.runningTestTransaction = true - try { - const { data } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/test-transaction', - null - ) - - // Show detailed results in a dialog - const details = data.transaction_details - - let dialogContent = `Test Transaction Results

` - dialogContent += `Transaction ID: ${details.transaction_id}
` - dialogContent += `Total Amount: ${details.total_amount_sats} sats
` - dialogContent += `Base Amount: ${details.base_amount_sats} sats
` - dialogContent += `Commission: ${details.commission_amount_sats} sats (${details.commission_percentage}%)
` - if (details.discount > 0) { - dialogContent += `Discount: ${details.discount}%
` - dialogContent += `Effective Commission: ${details.effective_commission}%
` - } - dialogContent += `
Check your wallets to see the distributions!` - - this.$q.dialog({ - title: 'Test Transaction Completed', - message: dialogContent, - html: true, - ok: { - color: 'positive', - label: 'Great!' - } - }) - - // Also show a brief notification - this.$q.notify({ - type: 'positive', - message: `Test transaction processed: ${details.total_amount_sats} sats distributed`, - timeout: 5000 - }) - - // Refresh data - await this.getDcaClients() // Refresh to show updated balances - await this.getDeposits() - await this.getLamassuTransactions() - await this.getLamassuConfig() - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.runningTestTransaction = false - } - }, - - openManualTransactionDialog() { - this.manualTransactionDialog.transactionId = '' - this.manualTransactionDialog.show = true - }, - - async processSpecificTransaction() { - if (!this.manualTransactionDialog.transactionId) { - this.$q.notify({ - type: 'warning', - message: 'Please enter a transaction ID', - timeout: 3000 - }) - return - } - - this.processingSpecificTransaction = true - try { - const { data } = await LNbits.api.request( - 'POST', - `/satmachineadmin/api/v1/dca/process-transaction/${this.manualTransactionDialog.transactionId}`, - null - ) - - if (data.already_processed) { - this.$q.notify({ - type: 'warning', - message: `Transaction already processed with ${data.payment_count} distributions`, - timeout: 5000 - }) - this.manualTransactionDialog.show = false - return - } - - // Show detailed results - const details = data.transaction_details - let dialogContent = `Manual Transaction Processing Results

` - dialogContent += `Transaction ID: ${details.transaction_id}
` - dialogContent += `Status: ${details.status}
` - dialogContent += `Dispense: ${details.dispense ? 'Yes' : 'No'}
` - dialogContent += `Dispense Confirmed: ${details.dispense_confirmed ? 'Yes' : 'No'}
` - dialogContent += `Crypto Amount: ${details.crypto_amount} sats
` - dialogContent += `Fiat Amount: ${details.fiat_amount}
` - dialogContent += `
Transaction processed successfully!` - - this.$q.dialog({ - title: 'Transaction Processed', - message: dialogContent, - html: true, - ok: { - color: 'positive', - label: 'Great!' - } - }) - - this.$q.notify({ - type: 'positive', - message: `Transaction ${details.transaction_id} processed successfully`, - timeout: 5000 - }) - - // Close dialog and refresh data - this.manualTransactionDialog.show = false - await this.getDcaClients() - await this.getDeposits() - await this.getLamassuTransactions() - await this.getLamassuConfig() - - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.processingSpecificTransaction = false - } - }, - - // Lamassu Transaction Methods - async getLamassuTransactions() { - try { - const { data } = await LNbits.api.request( - 'GET', - '/satmachineadmin/api/v1/dca/transactions', - null - ) - this.lamassuTransactions = data - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - async viewTransactionDistributions(transaction) { - try { - const { data: distributions } = await LNbits.api.request( - 'GET', - `/satmachineadmin/api/v1/dca/transactions/${transaction.id}/distributions`, - null - ) - - this.distributionDialog.transaction = transaction - this.distributionDialog.distributions = distributions - this.distributionDialog.show = true - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - }, - /////////////////////////////////////////////////// - //////LIFECYCLE FUNCTIONS RUNNING ON PAGE LOAD///// - /////////////////////////////////////////////////// - async created() { - // Load DCA admin data - await Promise.all([ - this.getLamassuConfig(), - this.getDcaClients(), - this.getDeposits(), - this.getLamassuTransactions() - ]) - }, - computed: { - isConfigFormValid() { - const data = this.configDialog.data + walletOptions() { + // g.user is sometimes null on initial mount in LNbits 1.4 — guard it. + const wallets = this.g?.user?.wallets || [] + return wallets.map(w => ({label: w.name, value: w.id})) + } + }, - // Basic database fields are required - const basicValid = data.host && data.database_name && data.username && data.selectedWallet + async created() { + await this.refreshAll() + }, - // If SSH tunnel is enabled, validate SSH fields - if (data.use_ssh_tunnel) { - const sshValid = data.ssh_host && data.ssh_username && - (data.ssh_password || data.ssh_private_key) - return basicValid && sshValid + methods: { + // ----------------------------------------------------------------- + // Loaders + // ----------------------------------------------------------------- + async refreshAll() { + this.refreshing = true + try { + await Promise.all([ + this.loadSuperConfig(), + this.loadMachines(), + this.loadWorklistCount() + ]) + } finally { + this.refreshing = false } - - return basicValid }, - clientOptions() { - return this.dcaClients.map(client => ({ - label: `${client.username || client.user_id.substring(0, 8) + '...'} (${client.dca_mode})`, - value: client.id - })) + async loadSuperConfig() { + try { + const {data} = await LNbits.api.request('GET', SUPER_FEE_PATH) + this.superConfig = data + } catch (e) { + this.superConfig = null + } }, - totalDcaBalance() { - return this.deposits - .filter(d => d.status === 'confirmed') - .reduce((total, deposit) => total + deposit.amount, 0) + async loadMachines() { + try { + const {data} = await LNbits.api.request('GET', MACHINES_PATH) + this.machines = data || [] + } catch (e) { + this.machines = [] + this._notifyError(e, 'Failed to load machines') + } + }, + + async loadWorklistCount() { + // Light read used to badge the Worklist tab. The full worklist + // panel lives in P9g; here we just count for the badge. + try { + const {data} = await LNbits.api.request('GET', STUCK_PATH) + this.worklistCount = + (data?.errored?.length || 0) + + (data?.stuck_pending?.length || 0) + + (data?.stuck_processing?.length || 0) + } catch (e) { + this.worklistCount = 0 + } + }, + + // ----------------------------------------------------------------- + // Add machine + // ----------------------------------------------------------------- + openAddMachineDialog() { + this.addMachineDialog.data = this._emptyMachineForm() + this.addMachineDialog.show = true + }, + + async submitAddMachine() { + const body = this._cleanMachineForm(this.addMachineDialog.data) + if (!body.machine_npub || !body.wallet_id) { + Quasar.Notify.create({ + type: 'negative', + message: 'machine_npub and wallet_id are required' + }) + return + } + this.addMachineDialog.saving = true + try { + const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body) + this.machines.unshift(data) + this.addMachineDialog.show = false + Quasar.Notify.create({ + type: 'positive', + message: `Machine ${data.name || data.machine_npub.slice(0, 12)} added` + }) + } catch (e) { + this._notifyError(e, 'Failed to add machine') + } finally { + this.addMachineDialog.saving = false + } + }, + + // ----------------------------------------------------------------- + // Edit / delete machine + // ----------------------------------------------------------------- + openEditMachineDialog(machine) { + this.editMachineDialog.data = { + id: machine.id, + name: machine.name || '', + location: machine.location || '', + wallet_id: machine.wallet_id, + fiat_code: machine.fiat_code, + fallback_commission_pct: machine.fallback_commission_pct, + is_active: machine.is_active + } + this.editMachineDialog.show = true + }, + + async submitEditMachine() { + const d = this.editMachineDialog.data + this.editMachineDialog.saving = true + try { + const {data} = await LNbits.api.request( + 'PUT', + `${MACHINES_PATH}/${d.id}`, + null, + { + name: d.name, + location: d.location, + wallet_id: d.wallet_id, + fiat_code: d.fiat_code, + fallback_commission_pct: d.fallback_commission_pct, + is_active: d.is_active + } + ) + const idx = this.machines.findIndex(m => m.id === data.id) + if (idx >= 0) this.machines[idx] = data + this.editMachineDialog.show = false + Quasar.Notify.create({type: 'positive', message: 'Machine updated'}) + } catch (e) { + this._notifyError(e, 'Failed to update machine') + } finally { + this.editMachineDialog.saving = false + } + }, + + confirmDeleteMachine(machine) { + Quasar.Dialog.create({ + title: 'Delete machine?', + message: + `This removes ${machine.name || machine.machine_npub.slice(0, 12)}` + + ' from your fleet. Existing settlements and payment history are preserved' + + ' — only the machine row itself is removed. Continue?', + html: true, + cancel: true, + persistent: true + }).onOk(async () => { + try { + await LNbits.api.request('DELETE', `${MACHINES_PATH}/${machine.id}`) + this.machines = this.machines.filter(m => m.id !== machine.id) + Quasar.Notify.create({type: 'positive', message: 'Machine deleted'}) + } catch (e) { + this._notifyError(e, 'Failed to delete machine') + } + }) + }, + + // ----------------------------------------------------------------- + // Future: drill into a machine's detail view. Stub for P9a; P9b + // wires the actual settlement / telemetry / commission-splits panel. + // ----------------------------------------------------------------- + viewMachine(machine) { + Quasar.Notify.create({ + type: 'info', + message: `Machine detail view lands in P9b. (selected ${machine.id})` + }) + }, + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + shortNpub(npub) { + if (!npub) return '' + if (npub.length <= 16) return npub + return npub.slice(0, 8) + '…' + npub.slice(-6) + }, + + shortId(id) { + if (!id) return '' + return id.length <= 12 ? id : id.slice(0, 8) + '…' + }, + + copy(text) { + if (!text) return + Quasar.copyToClipboard(text).then(() => { + Quasar.Notify.create({type: 'info', message: 'Copied', timeout: 800}) + }) + }, + + _emptyMachineForm() { + return { + machine_npub: '', + wallet_id: null, + name: '', + location: '', + fiat_code: 'GTQ', + fallback_commission_pct: 0.05 + } + }, + + _cleanMachineForm(d) { + return { + machine_npub: (d.machine_npub || '').trim(), + wallet_id: d.wallet_id, + name: (d.name || '').trim() || null, + location: (d.location || '').trim() || null, + fiat_code: (d.fiat_code || 'GTQ').trim(), + fallback_commission_pct: Number(d.fallback_commission_pct ?? 0.05) + } + }, + + _notifyError(err, fallback) { + const msg = err?.response?.data?.detail || err?.message || fallback + Quasar.Notify.create({type: 'negative', message: msg, timeout: 5000}) } } }) diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 9c0f3a9..dc1fd8d 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -1,851 +1,317 @@ - - - +{% extends "base.html" %} +{% from "macros.jinja" import window_vars with context %} -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} {% block page %} +{% block scripts %} + {{ window_vars(user) }} + +{% endblock %} + +{% block page %}
-
- - - -
-
-
DCA Deposit Management
-

Manage fiat deposits for existing DCA clients

-
-
-
-
+
- - - -
-
-
Registered DCA Clients
-

Clients registered via the DCA client extension

-
-
- Export to CSV -
-
- - - -
-
+ +
+
+

Satoshi Machine — Operator

+

+ Manage your bitSpire fleet, liquidity providers, and commission distribution. +

+
+
+ + Refresh all data + +
+
- - - -
Quick Add Deposit
-

Add a new deposit for an existing client

- -
- - - No DCA clients registered yet. Clients must first install and configure the DCA client extension. - -
- - -
+ + + + + LNbits platform fee: + ${ (superConfig.super_fee_pct * 100).toFixed(2) }% + of each transaction's commission. + + + Your remainder splits per the rules below. + + + + + + + + + + + + ${ worklistCount } + + + + + + + + + + + + +
- -
-
- +
Your machines
+

+ Each ATM is paired with one dedicated wallet. Inbound payments to + that wallet trigger automatic distribution. +

Add Deposit + color="primary" icon="add" + label="Add machine" + @click="openAddMachineDialog" />
-
-
- -
-
- - -
- - - -
-
-
Recent Deposits
-
-
- Export to CSV -
-
- - - -
-
- - - - -
-
-
Processed Lamassu Transactions
-

ATM transactions processed through DCA distribution

-
-
- Export to CSV -
-
- - - -
-
- -
- -
- - -
- {{SITE_TITLE}} DCA Admin Extension -
-

- Dollar Cost Averaging administration for Lamassu ATM integration.
- Manage client deposits and DCA distribution settings. -

-
- - - - - -
-
Active Clients:
-
${ dcaClients.filter(c => c.status === 'active').length }
-
-
-
Pending Deposits:
-
${ deposits.filter(d => d.status === 'pending').length }
-
-
-
Total DCA Balance:
-
${ formatCurrency(totalDcaBalance) }
-
-
-
- - - -
-

Database: ${ lamassuConfig.host }:${ lamassuConfig.port }/${ lamassuConfig.database_name }

-

Status: - Connected - Failed - Not tested -

-

Last Poll: ${ lamassuConfig.last_poll_time ? formatDateTime(lamassuConfig.last_poll_time) : 'Not yet run' }

-

Last Success: ${ lamassuConfig.last_successful_poll ? formatDateTime(lamassuConfig.last_successful_poll) : 'Never' }

-
-
-

Status: Not configured

-
- -
- - Configure Database - - - Test Connection - - - Manual Poll - - - Process specific transaction by ID (bypasses dispense checks) - Manual TX - -
-
-
- - {% include "satmachineadmin/_api_docs.html" %} -
-
-
-
- - - - - - - - - -
- Deposit for: ${ depositFormDialog.data.client_name } -
- - - -
- Update Deposit - Create Deposit - Cancel -
-
-
-
- - - - - - - -
Client Details
-
- - - - Username - ${ clientDetailsDialog.data.username } - - - - - User ID - ${ clientDetailsDialog.data.user_id } - - - - - Wallet ID - ${ clientDetailsDialog.data.wallet_id } - - - - - DCA Mode - ${ clientDetailsDialog.data.dca_mode } - - - - - Daily Limit - ${ formatCurrency(clientDetailsDialog.data.fixed_mode_daily_limit) } - - - - - Balance Summary - - Deposits: ${ formatCurrency(clientDetailsDialog.balance.total_deposits) } | - Payments: ${ formatCurrency(clientDetailsDialog.balance.total_payments) } | - Remaining: ${ formatCurrency(clientDetailsDialog.balance.remaining_balance) } - - - - -
-
- Close -
-
-
- - - - - - - -
Lamassu Database Configuration
- - - - - - - - - - - - - -
DCA Source Wallet
- - - - - - - -
DCA Client Limits
- - - - - -
SSH Tunnel (Recommended)
- -
- - Use SSH Tunnel -
- -
- - - - - - - - - - - + - SSH tunneling keeps your database secure by avoiding direct internet exposure. - The database connection will be routed through the SSH server. + You haven't registered any machines yet. Click Add machine to + register a bitSpire ATM by its Nostr npub. -
- - - - This configuration will be securely stored and used for hourly polling. - Only read access to the Lamassu database is required. - - -
+ + + + + + + + + + + + Clients tab — pending P9c. + + + + + Deposits tab — pending P9d. + + + + + Commission splits tab — pending P9e. + + + + + Worklist (stuck / errored settlements) — pending P9g. + + + + + Reports / CSV exports — pending P9g. + + + + + + + + + + + + +
Add bitSpire machine
+ + +
+ +

+ Register an ATM by its Nostr public key. Choose the LNbits wallet that + will receive cash-out payments from this machine — settlements there + trigger the automatic distribution chain. +

+ + + + + + + + + + + + +
+ + Save Configuration - Cancel -
-
-
-
+ color="primary" label="Add machine" + :loading="addMachineDialog.saving" + @click="submitAddMachine" /> + +
+ - - - - - - -
Transaction Distribution Details
- -
- - - - Lamassu Transaction ID - ${ distributionDialog.transaction.lamassu_transaction_id } - - - - - Transaction Time - ${ formatDateTime(distributionDialog.transaction.transaction_time) } - - - - - Total Amount - - ${ formatCurrency(distributionDialog.transaction.fiat_amount) } - (${ formatSats(distributionDialog.transaction.crypto_amount) }) - - - - - - Commission - - ${ (distributionDialog.transaction.commission_percentage * 100).toFixed(1) }% - - (with ${ distributionDialog.transaction.discount }% discount = ${ (distributionDialog.transaction.effective_commission * 100).toFixed(1) }% effective) - - = ${ formatSats(distributionDialog.transaction.commission_amount_sats) } - - - - - - Available for Distribution - ${ formatSats(distributionDialog.transaction.base_amount_sats) } - - - - - Total Distributed - ${ formatSats(distributionDialog.transaction.distributions_total_sats) } to ${ distributionDialog.transaction.clients_count } clients - - - -
- - - -
Client Distributions
- - - - - -
- Close -
-
-
- - - - - - - -
Process Specific Transaction
- - - -
- Use with caution: This bypasses all dispense status checks and will process the transaction even if dispense_confirmed is false. Only use this for manually settled transactions. -
-
- - - - - - -
- This will: -
    -
  • Fetch the transaction from Lamassu regardless of dispense status
  • -
  • Process it through the normal DCA distribution flow
  • -
  • Credit the source wallet and distribute to clients
  • -
  • Send commission to the commission wallet (if configured)
  • -
-
- -
+ + + + + + +
Edit machine
+ + +
+ + + + + + + + + + - Process Transaction - - - Cancel - -
-
-
-
+ color="primary" label="Save" + :loading="editMachineDialog.saving" + @click="submitEditMachine" /> + + + +
{% endblock %}