diff --git a/crud.py b/crud.py index 26cc755..982594a 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 / GTQ + avg_cost_basis = total_sats / dca_spent if dca_spent > 0 else 0 # Cost basis = sats / fiat spent # 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 # Cost basis = sats / GTQ + avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0 # Use transaction_date (which is COALESCE(transaction_time, created_at)) date_to_use = record["transaction_date"] if date_to_use is None: @@ -514,8 +514,4 @@ async def get_client_by_user_id(user_id: str) -> Optional[dict]: ) return dict(client) if client else None except Exception: - 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 + return None \ No newline at end of file diff --git a/models.py b/models.py index 57592ac..c0a436f 100644 --- a/models.py +++ b/models.py @@ -6,46 +6,16 @@ from typing import List, Optional from pydantic import BaseModel -# 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_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) +# Client Dashboard Data Models class ClientDashboardSummary(BaseModel): - """Internal model - client dashboard summary stored in GTQ""" + """Summary metrics for client dashboard overview""" 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_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_transactions: int dca_mode: str # 'flow' or 'fixed' dca_status: str # 'active' or 'inactive' @@ -54,10 +24,10 @@ class ClientDashboardSummary(BaseModel): class ClientTransaction(BaseModel): - """Internal model - client transaction stored in GTQ""" + """Read-only view of client's DCA transactions""" id: str amount_sats: int - amount_fiat: float # Amount in GTQ (e.g., 150.75) + amount_fiat: int exchange_rate: float transaction_type: str # 'flow', 'fixed', 'manual' status: str diff --git a/static/js/index.js b/static/js/index.js index 1c850bd..c6e6209 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,5 +1,5 @@ window.app = Vue.createApp({ - el: '#vue', + el: '#dcaClient', mixins: [windowMixin], delimiters: ['${', '}'], data: function () { @@ -7,19 +7,14 @@ 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: [], @@ -77,23 +72,6 @@ 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 { @@ -102,16 +80,17 @@ 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) { - // Fetch current user info to get the username - await this.loadCurrentUser() - this.registrationForm.selectedWallet = this.g.user.wallets[0]?.id || null + 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 } - + return data } catch (error) { console.error('Error checking registration status:', error) @@ -120,56 +99,35 @@ 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 using the form's username (already loaded from API) + // Prepare registration data similar to the admin test client creation const registrationData = { dca_mode: this.registrationForm.dca_mode, fixed_mode_daily_limit: this.registrationForm.fixed_mode_daily_limit, - 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') + username: this.registrationForm.username || this.g.user.username || `user_${this.g.user.id.substring(0, 8)}` } const { data } = await LNbits.api.request( 'POST', '/satmachineclient/api/v1/register', - selectedWallet.adminkey, + this.registrationForm.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({ @@ -183,26 +141,24 @@ window.app = Vue.createApp({ // Dashboard Methods formatCurrency(amount) { if (!amount) return 'Q 0.00'; - // Amount is already in GTQ - const gtqAmount = amount; + // Values are already in full currency units, not centavos return new Intl.NumberFormat('es-GT', { style: 'currency', currency: 'GTQ', - }).format(gtqAmount); + }).format(amount); }, formatCurrencyWithCode(amount, currencyCode) { if (!amount) return `${currencyCode} 0.00`; - // Amount is already in GTQ - const currencyAmount = amount; + // Format with the provided currency code try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: currencyCode, - }).format(currencyAmount); + }).format(amount); } catch (error) { // Fallback if currency code is not supported - return `${currencyCode} ${currencyAmount.toFixed(2)}`; + return `${currencyCode} ${amount.toFixed(2)}`; } }, @@ -232,16 +188,10 @@ 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 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 + // Add some excitement for larger amounts + if (amount >= 1000000) return formatted + ' sats 💎' + if (amount >= 100000) return formatted + ' sats 🚀' + if (amount >= 10000) return formatted + ' sats ⚡' return formatted + ' sats' }, @@ -316,20 +266,15 @@ window.app = Vue.createApp({ }, getNextMilestone() { - if (!this.dashboardData) return { target: 10000, name: '10k sats' } + if (!this.dashboardData) return { target: 100000, name: '100k 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 < 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)' } + if (sats < 2100000) return { target: 2100000, name: '2.1M sats' } + return { target: 21000000, name: '21M sats' } }, getMilestoneProgress() { @@ -352,18 +297,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 @@ -376,10 +321,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() @@ -398,13 +343,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 @@ -426,7 +371,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) @@ -438,45 +383,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') @@ -484,7 +429,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', @@ -518,7 +463,7 @@ window.app = Vue.createApp({ borderWidth: 2, cornerRadius: 8, callbacks: { - label: function (context) { + label: function(context) { return `${context.parsed.y.toLocaleString()} sats` } } @@ -541,7 +486,7 @@ window.app = Vue.createApp({ ticks: { color: '#666666', font: { size: 12, weight: '500' }, - callback: function (value) { + callback: function(value) { return value.toLocaleString() + ' sats' } } @@ -553,7 +498,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 => { @@ -568,30 +513,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(); @@ -599,9 +544,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' @@ -612,22 +557,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) { @@ -640,34 +585,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 { @@ -676,7 +621,7 @@ window.app = Vue.createApp({ console.log('Loading state changed during timeout, aborting chart creation') return } - + this.dcaChart = new Chart(ctx, { type: 'line', data: { @@ -717,7 +662,7 @@ window.app = Vue.createApp({ cornerRadius: 8, displayColors: false, callbacks: { - title: function (context) { + title: function(context) { return `📅 ${context[0].label}` }, label: function (context) { @@ -796,13 +741,10 @@ window.app = Vue.createApp({ async created() { try { this.loading = true - - // Load client limits first - await this.loadClientLimits() - - // Check registration status + + // Check registration status first await this.checkRegistrationStatus() - + // Only load dashboard data if registered if (this.isRegistered) { await Promise.all([ @@ -826,7 +768,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() @@ -839,14 +781,6 @@ 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 eb3f23f..af2fa20 100644 --- a/templates/satmachineclient/index.html +++ b/templates/satmachineclient/index.html @@ -7,7 +7,7 @@ {% endblock %} {% block page %} -
+
@@ -21,87 +21,19 @@ - + ${error} - - - -
-
- -
-
Welcome to DCA!
-
Let's set up your Bitcoin Dollar Cost Averaging account
+ + + + +
Welcome to Bitcoin DCA!
+
+ Please complete your registration to start your Dollar Cost Averaging journey.
- - - - - - - - - - -
- 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 🚀 - -
-
@@ -247,7 +179,7 @@
${formatCurrency(dashboardData.pending_fiat_deposits)} ready to DCA @@ -431,7 +363,7 @@