diff --git a/crud.py b/crud.py index 982594a..26cc755 100644 --- a/crud.py +++ b/crud.py @@ -103,7 +103,7 @@ async def get_client_dashboard_summary(user_id: str) -> Optional[ClientDashboard # Calculate metrics total_invested = confirmed_deposits # Total invested = all confirmed deposits remaining_balance = confirmed_deposits - dca_spent # Remaining = deposits - DCA spending - avg_cost_basis = total_sats / dca_spent if dca_spent > 0 else 0 # Cost basis = sats / fiat spent + avg_cost_basis = total_sats / dca_spent if dca_spent > 0 else 0 # Cost basis = sats / GTQ # Calculate current fiat value of total sats current_sats_fiat_value = 0.0 @@ -248,7 +248,7 @@ async def get_client_analytics(user_id: str, time_range: str = "30d") -> Optiona # Build cost basis history cost_basis_history = [] for record in cost_basis_data: - avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0 + avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0 # Cost basis = sats / GTQ # Use transaction_date (which is COALESCE(transaction_time, created_at)) date_to_use = record["transaction_date"] if date_to_use is None: @@ -514,4 +514,8 @@ async def get_client_by_user_id(user_id: str) -> Optional[dict]: ) return dict(client) if client else None except Exception: - return None \ No newline at end of file + return None + + +# Removed get_active_lamassu_config - client should not access sensitive admin config +# Client limits are now fetched via secure public API endpoint \ No newline at end of file diff --git a/models.py b/models.py index c0a436f..57592ac 100644 --- a/models.py +++ b/models.py @@ -6,16 +6,46 @@ from typing import List, Optional from pydantic import BaseModel -# Client Dashboard Data Models -class ClientDashboardSummary(BaseModel): - """Summary metrics for client dashboard overview""" +# API Models for Client Dashboard (Frontend communication in GTQ) +class ClientDashboardSummaryAPI(BaseModel): + """API model - client dashboard summary in GTQ""" user_id: str total_sats_accumulated: int - total_fiat_invested: int # Confirmed deposits - pending_fiat_deposits: int # Pending deposits awaiting confirmation - current_sats_fiat_value: float # Current fiat value of total sats - average_cost_basis: float # Average sats per fiat unit - current_fiat_balance: int # Available balance for DCA + total_fiat_invested_gtq: float # Confirmed deposits in GTQ + pending_fiat_deposits_gtq: float # Pending deposits in GTQ + current_sats_fiat_value_gtq: float # Current fiat value of total sats in GTQ + average_cost_basis: float # Average sats per GTQ + current_fiat_balance_gtq: float # Available balance for DCA in GTQ + total_transactions: int + dca_mode: str # 'flow' or 'fixed' + dca_status: str # 'active' or 'inactive' + last_transaction_date: Optional[datetime] + currency: str = "GTQ" + + +class ClientTransactionAPI(BaseModel): + """API model - client transaction in GTQ""" + id: str + amount_sats: int + amount_fiat_gtq: float # Amount in GTQ + exchange_rate: float + transaction_type: str # 'flow', 'fixed', 'manual' + status: str + created_at: datetime + transaction_time: Optional[datetime] = None # Original ATM transaction time + lamassu_transaction_id: Optional[str] = None + + +# Internal Models for Client Dashboard (Database storage in GTQ) +class ClientDashboardSummary(BaseModel): + """Internal model - client dashboard summary stored in GTQ""" + user_id: str + total_sats_accumulated: int + total_fiat_invested: float # Confirmed deposits in GTQ + pending_fiat_deposits: float # Pending deposits awaiting confirmation in GTQ + current_sats_fiat_value: float # Current fiat value of total sats in GTQ + average_cost_basis: float # Average sats per GTQ + current_fiat_balance: float # Available balance for DCA in GTQ total_transactions: int dca_mode: str # 'flow' or 'fixed' dca_status: str # 'active' or 'inactive' @@ -24,10 +54,10 @@ class ClientDashboardSummary(BaseModel): class ClientTransaction(BaseModel): - """Read-only view of client's DCA transactions""" + """Internal model - client transaction stored in GTQ""" id: str amount_sats: int - amount_fiat: int + amount_fiat: float # Amount in GTQ (e.g., 150.75) exchange_rate: float transaction_type: str # 'flow', 'fixed', 'manual' status: str diff --git a/static/js/index.js b/static/js/index.js index c6e6209..1c850bd 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,5 +1,5 @@ window.app = Vue.createApp({ - el: '#dcaClient', + el: '#vue', mixins: [windowMixin], delimiters: ['${', '}'], data: function () { @@ -7,14 +7,19 @@ window.app = Vue.createApp({ // Registration state isRegistered: false, registrationChecked: false, - showRegistrationDialog: false, registrationForm: { selectedWallet: null, dca_mode: 'flow', fixed_mode_daily_limit: null, username: '' }, - + + // Admin configuration + adminConfig: { + max_daily_limit_gtq: 2000, + currency: 'GTQ' + }, + // Dashboard state dashboardData: null, transactions: [], @@ -72,6 +77,23 @@ window.app = Vue.createApp({ }, methods: { + // Configuration Methods + async loadClientLimits() { + try { + const { data } = await LNbits.api.request( + 'GET', + '/satmachineadmin/api/v1/dca/client-limits' + // No authentication required - public endpoint with safe data only + ) + + this.adminConfig = data + console.log('Client limits loaded:', this.adminConfig) + } catch (error) { + console.error('Error loading client limits:', error) + // Keep default values if client limits fail to load + } + }, + // Registration Methods async checkRegistrationStatus() { try { @@ -80,17 +102,16 @@ window.app = Vue.createApp({ '/satmachineclient/api/v1/registration-status', this.g.user.wallets[0].adminkey ) - + this.isRegistered = data.is_registered this.registrationChecked = true - + if (!this.isRegistered) { - this.showRegistrationDialog = true - // Pre-fill username and default wallet if available - this.registrationForm.username = this.g.user.username || '' - this.registrationForm.selectedWallet = this.g.user.wallets[0] || null + // Fetch current user info to get the username + await this.loadCurrentUser() + this.registrationForm.selectedWallet = this.g.user.wallets[0]?.id || null } - + return data } catch (error) { console.error('Error checking registration status:', error) @@ -99,35 +120,56 @@ window.app = Vue.createApp({ } }, + async loadCurrentUser() { + try { + const { data } = await LNbits.api.getAuthenticatedUser() + + // Set username from API response with priority: display_name > username > email > fallback + const username = data.extra?.display_name || data.username || data.email + this.registrationForm.username = (username !== null && username !== undefined && username !== '') + ? username + : `user_${this.g.user.id.substring(0, 8)}` + } catch (error) { + console.error('Error loading current user:', error) + // Fallback to generated username + this.registrationForm.username = `user_${this.g.user.id.substring(0, 8)}` + } + }, + async registerClient() { try { - // Prepare registration data similar to the admin test client creation + // Prepare registration data using the form's username (already loaded from API) const registrationData = { dca_mode: this.registrationForm.dca_mode, fixed_mode_daily_limit: this.registrationForm.fixed_mode_daily_limit, - username: this.registrationForm.username || this.g.user.username || `user_${this.g.user.id.substring(0, 8)}` + username: this.registrationForm.username || `user_${this.g.user.id.substring(0, 8)}` + } + + // Find the selected wallet object to get the adminkey + const selectedWallet = this.g.user.wallets.find(w => w.id === this.registrationForm.selectedWallet) + if (!selectedWallet) { + throw new Error('Selected wallet not found') } const { data } = await LNbits.api.request( 'POST', '/satmachineclient/api/v1/register', - this.registrationForm.selectedWallet.adminkey, + selectedWallet.adminkey, registrationData ) - + this.isRegistered = true - this.showRegistrationDialog = false - + this.$q.notify({ type: 'positive', message: data.message || 'Successfully registered for DCA!', icon: 'check_circle', position: 'top' }) - + // Load dashboard data after successful registration await this.loadDashboardData() - + } catch (error) { console.error('Error registering client:', error) this.$q.notify({ @@ -141,24 +183,26 @@ window.app = Vue.createApp({ // Dashboard Methods formatCurrency(amount) { if (!amount) return 'Q 0.00'; - // Values are already in full currency units, not centavos + // Amount is already in GTQ + const gtqAmount = amount; return new Intl.NumberFormat('es-GT', { style: 'currency', currency: 'GTQ', - }).format(amount); + }).format(gtqAmount); }, formatCurrencyWithCode(amount, currencyCode) { if (!amount) return `${currencyCode} 0.00`; - // Format with the provided currency code + // Amount is already in GTQ + const currencyAmount = amount; try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: currencyCode, - }).format(amount); + }).format(currencyAmount); } catch (error) { // Fallback if currency code is not supported - return `${currencyCode} ${amount.toFixed(2)}`; + return `${currencyCode} ${currencyAmount.toFixed(2)}`; } }, @@ -188,10 +232,16 @@ window.app = Vue.createApp({ formatSats(amount) { if (!amount) return '0 sats' const formatted = new Intl.NumberFormat('en-US').format(amount) - // Add some excitement for larger amounts - if (amount >= 1000000) return formatted + ' sats 💎' - if (amount >= 100000) return formatted + ' sats 🚀' - if (amount >= 10000) return formatted + ' sats ⚡' + // Add some excitement for larger amounts with consistent 5x→2x progression + if (amount >= 100000000) return formatted + ' sats 🏆' // Full coiner (1 BTC) + if (amount >= 50000000) return formatted + ' sats 🎆' // Bitcoin baron + if (amount >= 10000000) return formatted + ' sats 👑' // Bitcoin royalty + if (amount >= 5000000) return formatted + ' sats 🏆' // Verified bag holder + if (amount >= 1000000) return formatted + ' sats 🌟' // Millionaire + if (amount >= 500000) return formatted + ' sats 🔥' // Half million + if (amount >= 100000) return formatted + ' sats 🚀' // Getting serious + if (amount >= 50000) return formatted + ' sats ⚡' // Lightning quick + if (amount >= 10000) return formatted + ' sats 🎯' // First milestone return formatted + ' sats' }, @@ -266,15 +316,20 @@ window.app = Vue.createApp({ }, getNextMilestone() { - if (!this.dashboardData) return { target: 100000, name: '100k sats' } + if (!this.dashboardData) return { target: 10000, name: '10k sats' } const sats = this.dashboardData.total_sats_accumulated + // Consistent 5x→2x progression pattern if (sats < 10000) return { target: 10000, name: '10k sats' } + if (sats < 50000) return { target: 50000, name: '50k sats' } if (sats < 100000) return { target: 100000, name: '100k sats' } if (sats < 500000) return { target: 500000, name: '500k sats' } if (sats < 1000000) return { target: 1000000, name: '1M sats' } - if (sats < 2100000) return { target: 2100000, name: '2.1M sats' } - return { target: 21000000, name: '21M sats' } + if (sats < 5000000) return { target: 5000000, name: '5M sats' } + if (sats < 10000000) return { target: 10000000, name: '10M sats' } + if (sats < 50000000) return { target: 50000000, name: '50M sats' } + if (sats < 100000000) return { target: 100000000, name: '100M sats (1 BTC!)' } + return { target: 500000000, name: '500M sats (5 BTC)' } }, getMilestoneProgress() { @@ -297,18 +352,18 @@ window.app = Vue.createApp({ console.log('Chart already loading, ignoring request') return } - + try { this.chartLoading = true - + // Destroy existing chart immediately to prevent conflicts if (this.dcaChart) { console.log('Destroying existing chart before loading new data') this.dcaChart.destroy() this.dcaChart = null } - - const {data} = await LNbits.api.request( + + const { data } = await LNbits.api.request( 'GET', `/satmachineclient/api/v1/dashboard/analytics?time_range=${this.chartTimeRange}`, this.g.user.wallets[0].adminkey @@ -321,10 +376,10 @@ window.app = Vue.createApp({ } this.analyticsData = data - + // Wait for DOM update and ensure we're still in loading state await this.$nextTick() - + // Double-check we're still the active loading request if (this.chartLoading) { this.initDCAChart() @@ -343,13 +398,13 @@ window.app = Vue.createApp({ console.log('analyticsData:', this.analyticsData) console.log('dcaChart ref:', this.$refs.dcaChart) console.log('chartLoading state:', this.chartLoading) - + // Skip if we're not in a loading state (indicates this is a stale call) if (!this.chartLoading && this.dcaChart) { console.log('Chart already exists and not loading, skipping initialization') return } - + if (!this.analyticsData) { console.log('No analytics data available') return @@ -371,7 +426,7 @@ window.app = Vue.createApp({ console.error('Chart.js is not loaded') return } - + console.log('Chart.js version:', Chart.version || 'unknown') console.log('Chart.js available:', typeof Chart) @@ -383,45 +438,45 @@ window.app = Vue.createApp({ } const ctx = this.$refs.dcaChart.getContext('2d') - + // Use accumulation_timeline data which is already grouped by day const timelineData = this.analyticsData.accumulation_timeline || [] console.log('Timeline data sample:', timelineData.slice(0, 2)) // Debug first 2 records - + // If we have timeline data, use it (already grouped by day) if (timelineData.length > 0) { // Calculate running totals from daily data let runningSats = 0 const labels = [] const cumulativeSats = [] - + timelineData.forEach(point => { // Ensure sats is a valid number const sats = point.sats || 0 const validSats = typeof sats === 'number' ? sats : parseFloat(sats) || 0 runningSats += validSats - + const date = new Date(point.date) if (!isNaN(date.getTime())) { - labels.push(date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric' + labels.push(date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' })) cumulativeSats.push(runningSats) } }) - + console.log('Timeline chart data:', { labels, cumulativeSats }) - + this.createChart(labels, cumulativeSats) return } - + // Fallback to cost_basis_history but group by date to avoid duplicates console.log('No timeline data, using cost_basis_history as fallback') const chartData = this.analyticsData.cost_basis_history || [] console.log('Chart data sample:', chartData.slice(0, 2)) // Debug first 2 records - + // Handle empty data case if (chartData.length === 0) { console.log('No chart data available') @@ -429,7 +484,7 @@ window.app = Vue.createApp({ const placeholderGradient = ctx.createLinearGradient(0, 0, 0, 300) placeholderGradient.addColorStop(0, 'rgba(255, 149, 0, 0.3)') placeholderGradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)') - + // Show placeholder chart with enhanced styling this.dcaChart = new Chart(ctx, { type: 'line', @@ -463,7 +518,7 @@ window.app = Vue.createApp({ borderWidth: 2, cornerRadius: 8, callbacks: { - label: function(context) { + label: function (context) { return `${context.parsed.y.toLocaleString()} sats` } } @@ -486,7 +541,7 @@ window.app = Vue.createApp({ ticks: { color: '#666666', font: { size: 12, weight: '500' }, - callback: function(value) { + callback: function (value) { return value.toLocaleString() + ' sats' } } @@ -498,7 +553,7 @@ window.app = Vue.createApp({ this.chartLoading = false return } - + // Group cost_basis_history by date to eliminate duplicates const groupedData = new Map() chartData.forEach(point => { @@ -513,30 +568,30 @@ window.app = Vue.createApp({ } } }) - - const uniqueChartData = Array.from(groupedData.values()).sort((a, b) => + + const uniqueChartData = Array.from(groupedData.values()).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() ) - + const labels = uniqueChartData.map(point => { // Handle different date formats with enhanced timezone handling let date; if (point.date) { console.log('Raw date from API:', point.date); // Debug the actual date string - + // If it's an ISO string with timezone info, parse it correctly if (typeof point.date === 'string' && point.date.includes('T')) { // ISO string - parse and convert to local date date = new Date(point.date); // For display purposes, use the date part only to avoid timezone shifts - const localDateStr = date.getFullYear() + '-' + - String(date.getMonth() + 1).padStart(2, '0') + '-' + - String(date.getDate()).padStart(2, '0'); + const localDateStr = date.getFullYear() + '-' + + String(date.getMonth() + 1).padStart(2, '0') + '-' + + String(date.getDate()).padStart(2, '0'); date = new Date(localDateStr + 'T00:00:00'); // Force local midnight } else { date = new Date(point.date); } - + // Check if date is valid if (isNaN(date.getTime())) { date = new Date(); @@ -544,9 +599,9 @@ window.app = Vue.createApp({ } else { date = new Date(); } - + console.log('Formatted date:', date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); - + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' @@ -557,22 +612,22 @@ window.app = Vue.createApp({ const sats = point.cumulative_sats || 0 return typeof sats === 'number' ? sats : parseFloat(sats) || 0 }) - + console.log('Final chart data:', { labels, cumulativeSats }) console.log('Labels array:', labels) console.log('CumulativeSats array:', cumulativeSats) - + // Validate data before creating chart if (labels.length === 0 || cumulativeSats.length === 0) { console.warn('No valid data for chart, skipping creation') return } - + if (labels.length !== cumulativeSats.length) { console.warn('Mismatched data arrays:', { labelsLength: labels.length, dataLength: cumulativeSats.length }) return } - + // Check for any invalid values in cumulativeSats const hasInvalidValues = cumulativeSats.some(val => val === null || val === undefined || isNaN(val)) if (hasInvalidValues) { @@ -585,34 +640,34 @@ window.app = Vue.createApp({ createChart(labels, cumulativeSats) { console.log('createChart called with loading state:', this.chartLoading) - + if (!this.$refs.dcaChart) { console.log('Chart ref not available for createChart') return } - + // Skip if we're not in a loading state (indicates this is a stale call) if (!this.chartLoading) { console.log('Not in loading state, skipping createChart') return } - + // Destroy existing chart if (this.dcaChart) { console.log('Destroying existing chart in createChart') this.dcaChart.destroy() this.dcaChart = null } - + const ctx = this.$refs.dcaChart.getContext('2d') - + try { // Create gradient for the area fill const gradient = ctx.createLinearGradient(0, 0, 0, 300) gradient.addColorStop(0, 'rgba(255, 149, 0, 0.4)') gradient.addColorStop(0.5, 'rgba(255, 149, 0, 0.2)') gradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)') - + // Small delay to ensure Chart.js is fully initialized setTimeout(() => { try { @@ -621,7 +676,7 @@ window.app = Vue.createApp({ console.log('Loading state changed during timeout, aborting chart creation') return } - + this.dcaChart = new Chart(ctx, { type: 'line', data: { @@ -662,7 +717,7 @@ window.app = Vue.createApp({ cornerRadius: 8, displayColors: false, callbacks: { - title: function(context) { + title: function (context) { return `📅 ${context[0].label}` }, label: function (context) { @@ -741,10 +796,13 @@ window.app = Vue.createApp({ async created() { try { this.loading = true - - // Check registration status first + + // Load client limits first + await this.loadClientLimits() + + // Check registration status await this.checkRegistrationStatus() - + // Only load dashboard data if registered if (this.isRegistered) { await Promise.all([ @@ -768,7 +826,7 @@ window.app = Vue.createApp({ console.log('Loading state:', this.loading) console.log('Chart ref available:', !!this.$refs.dcaChart) console.log('Analytics data available:', !!this.analyticsData) - + if (this.analyticsData && this.$refs.dcaChart) { console.log('Initializing chart from mounted hook') this.initDCAChart() @@ -781,6 +839,14 @@ window.app = Vue.createApp({ computed: { hasData() { return this.dashboardData && !this.loading && this.isRegistered + }, + + walletOptions() { + if (!this.g.user?.wallets) return [] + return this.g.user.wallets.map(wallet => ({ + label: `${wallet.name} (${Math.round(wallet.balance_msat / 1000)} sats)`, + value: wallet.id + })) } }, diff --git a/templates/satmachineclient/index.html b/templates/satmachineclient/index.html index af2fa20..eb3f23f 100644 --- a/templates/satmachineclient/index.html +++ b/templates/satmachineclient/index.html @@ -7,7 +7,7 @@ {% endblock %} {% block page %} -
+
@@ -21,19 +21,87 @@ - + ${error} - - - - -
Welcome to Bitcoin DCA!
-
- Please complete your registration to start your Dollar Cost Averaging journey. + + + +
+
+ +
+
Welcome to DCA!
+
Let's set up your Bitcoin Dollar Cost Averaging account
+ + + + + + + + + + +
+ Flow Mode: Your Bitcoin purchases come at 0% fee when people cash ou at the machine.
+ Fixed Mode: Set a daily limit for consistent Bitcoin accumulation. +
+
+ +
+ + + Start My DCA Journey 🚀 + +
+
@@ -179,7 +247,7 @@
${formatCurrency(dashboardData.pending_fiat_deposits)} ready to DCA @@ -363,7 +431,7 @@