Compare commits

...

10 commits

Author SHA1 Message Date
8d442b7c6f 01 Refactor currency handling in client models and calculations: Update models to store values in GTQ instead of centavos, adjust cost basis calculations, and modify API responses and frontend currency formatting to reflect the new structure.
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (3.10) (push) Has been cancelled
CI / tests (3.9) (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-10-12 14:36:12 +02:00
8c3faeec3f 00 Add currency conversion utilities and update API models: Introduce functions for converting between GTQ and centavos, and enhance API models to reflect these changes for client dashboard and transaction data. 2025-10-12 14:36:12 +02:00
35c407f9aa Fix cost basis calculations to convert centavos to GTQ: Update average cost basis formulas in client dashboard summary and analytics functions for accurate currency handling. 2025-10-12 14:36:12 +02:00
d2866276a9 Update currency handling in models and views: Modify model attributes to specify values in centavos for precision. Adjust CSV export and currency formatting functions to convert centavos to GTQ for accurate display and export. 2025-10-12 14:36:12 +02:00
f32e1bb9ae Update milestone rewards and display: Revise milestone thresholds and corresponding labels for 10k, 50k, and 100M sats. Enhance formatting for larger amounts with a consistent progression pattern and update the dashboard to reflect these changes. 2025-10-12 14:36:12 +02:00
340dc22c20 Refactor client configuration access: Remove direct access to sensitive admin config and local client-limits endpoint. Implement fetching of client limits via a secure public API. Update registration form to reflect changes and enhance user experience. 2025-10-12 14:36:12 +02:00
16db140bb6 Enhance milestone rewards display: Add new milestones for 5M, 20M, and 100M sats with corresponding labels and icons. Update progress calculation to reflect these new thresholds in the dashboard. 2025-10-12 14:36:12 +02:00
ae836dad54 update flow mode description 2025-10-12 14:36:12 +02:00
03179647ec Refactor DCA client registration form: Update wallet selection to use walletOptions computed property for better data handling. Change element ID from 'dcaClient' to 'vue' for consistency. Improve error handling and data validation in chart loading and registration processes. 2025-10-12 14:36:12 +02:00
306549a656 wrap q-icon in div to prevent text rendering problem 2025-10-12 14:36:12 +02:00
5 changed files with 333 additions and 207 deletions

10
crud.py
View file

@ -103,7 +103,7 @@ async def get_client_dashboard_summary(user_id: str) -> Optional[ClientDashboard
# Calculate metrics # Calculate metrics
total_invested = confirmed_deposits # Total invested = all confirmed deposits total_invested = confirmed_deposits # Total invested = all confirmed deposits
remaining_balance = confirmed_deposits - dca_spent # Remaining = deposits - DCA spending 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 # Calculate current fiat value of total sats
current_sats_fiat_value = 0.0 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 # Build cost basis history
cost_basis_history = [] cost_basis_history = []
for record in cost_basis_data: 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)) # Use transaction_date (which is COALESCE(transaction_time, created_at))
date_to_use = record["transaction_date"] date_to_use = record["transaction_date"]
if date_to_use is None: 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 return dict(client) if client else None
except Exception: except Exception:
return None return None
# Removed get_active_lamassu_config - client should not access sensitive admin config
# Client limits are now fetched via secure public API endpoint

View file

@ -6,16 +6,46 @@ from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
# Client Dashboard Data Models # API Models for Client Dashboard (Frontend communication in GTQ)
class ClientDashboardSummary(BaseModel): class ClientDashboardSummaryAPI(BaseModel):
"""Summary metrics for client dashboard overview""" """API model - client dashboard summary in GTQ"""
user_id: str user_id: str
total_sats_accumulated: int total_sats_accumulated: int
total_fiat_invested: int # Confirmed deposits total_fiat_invested_gtq: float # Confirmed deposits in GTQ
pending_fiat_deposits: int # Pending deposits awaiting confirmation pending_fiat_deposits_gtq: float # Pending deposits in GTQ
current_sats_fiat_value: float # Current fiat value of total sats current_sats_fiat_value_gtq: float # Current fiat value of total sats in GTQ
average_cost_basis: float # Average sats per fiat unit average_cost_basis: float # Average sats per GTQ
current_fiat_balance: int # Available balance for DCA 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 total_transactions: int
dca_mode: str # 'flow' or 'fixed' dca_mode: str # 'flow' or 'fixed'
dca_status: str # 'active' or 'inactive' dca_status: str # 'active' or 'inactive'
@ -24,10 +54,10 @@ class ClientDashboardSummary(BaseModel):
class ClientTransaction(BaseModel): class ClientTransaction(BaseModel):
"""Read-only view of client's DCA transactions""" """Internal model - client transaction stored in GTQ"""
id: str id: str
amount_sats: int amount_sats: int
amount_fiat: int amount_fiat: float # Amount in GTQ (e.g., 150.75)
exchange_rate: float exchange_rate: float
transaction_type: str # 'flow', 'fixed', 'manual' transaction_type: str # 'flow', 'fixed', 'manual'
status: str status: str

View file

@ -1,5 +1,5 @@
window.app = Vue.createApp({ window.app = Vue.createApp({
el: '#dcaClient', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
delimiters: ['${', '}'], delimiters: ['${', '}'],
data: function () { data: function () {
@ -7,14 +7,19 @@ window.app = Vue.createApp({
// Registration state // Registration state
isRegistered: false, isRegistered: false,
registrationChecked: false, registrationChecked: false,
showRegistrationDialog: false,
registrationForm: { registrationForm: {
selectedWallet: null, selectedWallet: null,
dca_mode: 'flow', dca_mode: 'flow',
fixed_mode_daily_limit: null, fixed_mode_daily_limit: null,
username: '' username: ''
}, },
// Admin configuration
adminConfig: {
max_daily_limit_gtq: 2000,
currency: 'GTQ'
},
// Dashboard state // Dashboard state
dashboardData: null, dashboardData: null,
transactions: [], transactions: [],
@ -72,6 +77,23 @@ window.app = Vue.createApp({
}, },
methods: { 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 // Registration Methods
async checkRegistrationStatus() { async checkRegistrationStatus() {
try { try {
@ -80,17 +102,16 @@ window.app = Vue.createApp({
'/satmachineclient/api/v1/registration-status', '/satmachineclient/api/v1/registration-status',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.isRegistered = data.is_registered this.isRegistered = data.is_registered
this.registrationChecked = true this.registrationChecked = true
if (!this.isRegistered) { if (!this.isRegistered) {
this.showRegistrationDialog = true // Fetch current user info to get the username
// Pre-fill username and default wallet if available await this.loadCurrentUser()
this.registrationForm.username = this.g.user.username || '' this.registrationForm.selectedWallet = this.g.user.wallets[0]?.id || null
this.registrationForm.selectedWallet = this.g.user.wallets[0] || null
} }
return data return data
} catch (error) { } catch (error) {
console.error('Error checking registration status:', 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() { async registerClient() {
try { 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 = { const registrationData = {
dca_mode: this.registrationForm.dca_mode, dca_mode: this.registrationForm.dca_mode,
fixed_mode_daily_limit: this.registrationForm.fixed_mode_daily_limit, 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( const { data } = await LNbits.api.request(
'POST', 'POST',
'/satmachineclient/api/v1/register', '/satmachineclient/api/v1/register',
this.registrationForm.selectedWallet.adminkey, selectedWallet.adminkey,
registrationData registrationData
) )
this.isRegistered = true this.isRegistered = true
this.showRegistrationDialog = false
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
message: data.message || 'Successfully registered for DCA!', message: data.message || 'Successfully registered for DCA!',
icon: 'check_circle', icon: 'check_circle',
position: 'top' position: 'top'
}) })
// Load dashboard data after successful registration // Load dashboard data after successful registration
await this.loadDashboardData() await this.loadDashboardData()
} catch (error) { } catch (error) {
console.error('Error registering client:', error) console.error('Error registering client:', error)
this.$q.notify({ this.$q.notify({
@ -141,24 +183,26 @@ window.app = Vue.createApp({
// Dashboard Methods // Dashboard Methods
formatCurrency(amount) { formatCurrency(amount) {
if (!amount) return 'Q 0.00'; 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', { return new Intl.NumberFormat('es-GT', {
style: 'currency', style: 'currency',
currency: 'GTQ', currency: 'GTQ',
}).format(amount); }).format(gtqAmount);
}, },
formatCurrencyWithCode(amount, currencyCode) { formatCurrencyWithCode(amount, currencyCode) {
if (!amount) return `${currencyCode} 0.00`; if (!amount) return `${currencyCode} 0.00`;
// Format with the provided currency code // Amount is already in GTQ
const currencyAmount = amount;
try { try {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
currency: currencyCode, currency: currencyCode,
}).format(amount); }).format(currencyAmount);
} catch (error) { } catch (error) {
// Fallback if currency code is not supported // 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) { formatSats(amount) {
if (!amount) return '0 sats' if (!amount) return '0 sats'
const formatted = new Intl.NumberFormat('en-US').format(amount) const formatted = new Intl.NumberFormat('en-US').format(amount)
// Add some excitement for larger amounts // Add some excitement for larger amounts with consistent 5x→2x progression
if (amount >= 1000000) return formatted + ' sats 💎' if (amount >= 100000000) return formatted + ' sats 🏆' // Full coiner (1 BTC)
if (amount >= 100000) return formatted + ' sats 🚀' if (amount >= 50000000) return formatted + ' sats 🎆' // Bitcoin baron
if (amount >= 10000) return formatted + ' sats ⚡' 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' return formatted + ' sats'
}, },
@ -266,15 +316,20 @@ window.app = Vue.createApp({
}, },
getNextMilestone() { 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 const sats = this.dashboardData.total_sats_accumulated
// Consistent 5x→2x progression pattern
if (sats < 10000) return { target: 10000, name: '10k sats' } 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 < 100000) return { target: 100000, name: '100k sats' }
if (sats < 500000) return { target: 500000, name: '500k sats' } if (sats < 500000) return { target: 500000, name: '500k sats' }
if (sats < 1000000) return { target: 1000000, name: '1M sats' } if (sats < 1000000) return { target: 1000000, name: '1M sats' }
if (sats < 2100000) return { target: 2100000, name: '2.1M sats' } if (sats < 5000000) return { target: 5000000, name: '5M sats' }
return { target: 21000000, name: '21M 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() { getMilestoneProgress() {
@ -297,18 +352,18 @@ window.app = Vue.createApp({
console.log('Chart already loading, ignoring request') console.log('Chart already loading, ignoring request')
return return
} }
try { try {
this.chartLoading = true this.chartLoading = true
// Destroy existing chart immediately to prevent conflicts // Destroy existing chart immediately to prevent conflicts
if (this.dcaChart) { if (this.dcaChart) {
console.log('Destroying existing chart before loading new data') console.log('Destroying existing chart before loading new data')
this.dcaChart.destroy() this.dcaChart.destroy()
this.dcaChart = null this.dcaChart = null
} }
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'GET', 'GET',
`/satmachineclient/api/v1/dashboard/analytics?time_range=${this.chartTimeRange}`, `/satmachineclient/api/v1/dashboard/analytics?time_range=${this.chartTimeRange}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
@ -321,10 +376,10 @@ window.app = Vue.createApp({
} }
this.analyticsData = data this.analyticsData = data
// Wait for DOM update and ensure we're still in loading state // Wait for DOM update and ensure we're still in loading state
await this.$nextTick() await this.$nextTick()
// Double-check we're still the active loading request // Double-check we're still the active loading request
if (this.chartLoading) { if (this.chartLoading) {
this.initDCAChart() this.initDCAChart()
@ -343,13 +398,13 @@ window.app = Vue.createApp({
console.log('analyticsData:', this.analyticsData) console.log('analyticsData:', this.analyticsData)
console.log('dcaChart ref:', this.$refs.dcaChart) console.log('dcaChart ref:', this.$refs.dcaChart)
console.log('chartLoading state:', this.chartLoading) console.log('chartLoading state:', this.chartLoading)
// Skip if we're not in a loading state (indicates this is a stale call) // Skip if we're not in a loading state (indicates this is a stale call)
if (!this.chartLoading && this.dcaChart) { if (!this.chartLoading && this.dcaChart) {
console.log('Chart already exists and not loading, skipping initialization') console.log('Chart already exists and not loading, skipping initialization')
return return
} }
if (!this.analyticsData) { if (!this.analyticsData) {
console.log('No analytics data available') console.log('No analytics data available')
return return
@ -371,7 +426,7 @@ window.app = Vue.createApp({
console.error('Chart.js is not loaded') console.error('Chart.js is not loaded')
return return
} }
console.log('Chart.js version:', Chart.version || 'unknown') console.log('Chart.js version:', Chart.version || 'unknown')
console.log('Chart.js available:', typeof Chart) console.log('Chart.js available:', typeof Chart)
@ -383,45 +438,45 @@ window.app = Vue.createApp({
} }
const ctx = this.$refs.dcaChart.getContext('2d') const ctx = this.$refs.dcaChart.getContext('2d')
// Use accumulation_timeline data which is already grouped by day // Use accumulation_timeline data which is already grouped by day
const timelineData = this.analyticsData.accumulation_timeline || [] const timelineData = this.analyticsData.accumulation_timeline || []
console.log('Timeline data sample:', timelineData.slice(0, 2)) // Debug first 2 records 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 we have timeline data, use it (already grouped by day)
if (timelineData.length > 0) { if (timelineData.length > 0) {
// Calculate running totals from daily data // Calculate running totals from daily data
let runningSats = 0 let runningSats = 0
const labels = [] const labels = []
const cumulativeSats = [] const cumulativeSats = []
timelineData.forEach(point => { timelineData.forEach(point => {
// Ensure sats is a valid number // Ensure sats is a valid number
const sats = point.sats || 0 const sats = point.sats || 0
const validSats = typeof sats === 'number' ? sats : parseFloat(sats) || 0 const validSats = typeof sats === 'number' ? sats : parseFloat(sats) || 0
runningSats += validSats runningSats += validSats
const date = new Date(point.date) const date = new Date(point.date)
if (!isNaN(date.getTime())) { if (!isNaN(date.getTime())) {
labels.push(date.toLocaleDateString('en-US', { labels.push(date.toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
})) }))
cumulativeSats.push(runningSats) cumulativeSats.push(runningSats)
} }
}) })
console.log('Timeline chart data:', { labels, cumulativeSats }) console.log('Timeline chart data:', { labels, cumulativeSats })
this.createChart(labels, cumulativeSats) this.createChart(labels, cumulativeSats)
return return
} }
// Fallback to cost_basis_history but group by date to avoid duplicates // Fallback to cost_basis_history but group by date to avoid duplicates
console.log('No timeline data, using cost_basis_history as fallback') console.log('No timeline data, using cost_basis_history as fallback')
const chartData = this.analyticsData.cost_basis_history || [] const chartData = this.analyticsData.cost_basis_history || []
console.log('Chart data sample:', chartData.slice(0, 2)) // Debug first 2 records console.log('Chart data sample:', chartData.slice(0, 2)) // Debug first 2 records
// Handle empty data case // Handle empty data case
if (chartData.length === 0) { if (chartData.length === 0) {
console.log('No chart data available') console.log('No chart data available')
@ -429,7 +484,7 @@ window.app = Vue.createApp({
const placeholderGradient = ctx.createLinearGradient(0, 0, 0, 300) const placeholderGradient = ctx.createLinearGradient(0, 0, 0, 300)
placeholderGradient.addColorStop(0, 'rgba(255, 149, 0, 0.3)') placeholderGradient.addColorStop(0, 'rgba(255, 149, 0, 0.3)')
placeholderGradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)') placeholderGradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)')
// Show placeholder chart with enhanced styling // Show placeholder chart with enhanced styling
this.dcaChart = new Chart(ctx, { this.dcaChart = new Chart(ctx, {
type: 'line', type: 'line',
@ -463,7 +518,7 @@ window.app = Vue.createApp({
borderWidth: 2, borderWidth: 2,
cornerRadius: 8, cornerRadius: 8,
callbacks: { callbacks: {
label: function(context) { label: function (context) {
return `${context.parsed.y.toLocaleString()} sats` return `${context.parsed.y.toLocaleString()} sats`
} }
} }
@ -486,7 +541,7 @@ window.app = Vue.createApp({
ticks: { ticks: {
color: '#666666', color: '#666666',
font: { size: 12, weight: '500' }, font: { size: 12, weight: '500' },
callback: function(value) { callback: function (value) {
return value.toLocaleString() + ' sats' return value.toLocaleString() + ' sats'
} }
} }
@ -498,7 +553,7 @@ window.app = Vue.createApp({
this.chartLoading = false this.chartLoading = false
return return
} }
// Group cost_basis_history by date to eliminate duplicates // Group cost_basis_history by date to eliminate duplicates
const groupedData = new Map() const groupedData = new Map()
chartData.forEach(point => { 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() new Date(a.date).getTime() - new Date(b.date).getTime()
) )
const labels = uniqueChartData.map(point => { const labels = uniqueChartData.map(point => {
// Handle different date formats with enhanced timezone handling // Handle different date formats with enhanced timezone handling
let date; let date;
if (point.date) { if (point.date) {
console.log('Raw date from API:', point.date); // Debug the actual date string 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 it's an ISO string with timezone info, parse it correctly
if (typeof point.date === 'string' && point.date.includes('T')) { if (typeof point.date === 'string' && point.date.includes('T')) {
// ISO string - parse and convert to local date // ISO string - parse and convert to local date
date = new Date(point.date); date = new Date(point.date);
// For display purposes, use the date part only to avoid timezone shifts // For display purposes, use the date part only to avoid timezone shifts
const localDateStr = date.getFullYear() + '-' + const localDateStr = date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0'); String(date.getDate()).padStart(2, '0');
date = new Date(localDateStr + 'T00:00:00'); // Force local midnight date = new Date(localDateStr + 'T00:00:00'); // Force local midnight
} else { } else {
date = new Date(point.date); date = new Date(point.date);
} }
// Check if date is valid // Check if date is valid
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
date = new Date(); date = new Date();
@ -544,9 +599,9 @@ window.app = Vue.createApp({
} else { } else {
date = new Date(); date = new Date();
} }
console.log('Formatted date:', date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); console.log('Formatted date:', date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
@ -557,22 +612,22 @@ window.app = Vue.createApp({
const sats = point.cumulative_sats || 0 const sats = point.cumulative_sats || 0
return typeof sats === 'number' ? sats : parseFloat(sats) || 0 return typeof sats === 'number' ? sats : parseFloat(sats) || 0
}) })
console.log('Final chart data:', { labels, cumulativeSats }) console.log('Final chart data:', { labels, cumulativeSats })
console.log('Labels array:', labels) console.log('Labels array:', labels)
console.log('CumulativeSats array:', cumulativeSats) console.log('CumulativeSats array:', cumulativeSats)
// Validate data before creating chart // Validate data before creating chart
if (labels.length === 0 || cumulativeSats.length === 0) { if (labels.length === 0 || cumulativeSats.length === 0) {
console.warn('No valid data for chart, skipping creation') console.warn('No valid data for chart, skipping creation')
return return
} }
if (labels.length !== cumulativeSats.length) { if (labels.length !== cumulativeSats.length) {
console.warn('Mismatched data arrays:', { labelsLength: labels.length, dataLength: cumulativeSats.length }) console.warn('Mismatched data arrays:', { labelsLength: labels.length, dataLength: cumulativeSats.length })
return return
} }
// Check for any invalid values in cumulativeSats // Check for any invalid values in cumulativeSats
const hasInvalidValues = cumulativeSats.some(val => val === null || val === undefined || isNaN(val)) const hasInvalidValues = cumulativeSats.some(val => val === null || val === undefined || isNaN(val))
if (hasInvalidValues) { if (hasInvalidValues) {
@ -585,34 +640,34 @@ window.app = Vue.createApp({
createChart(labels, cumulativeSats) { createChart(labels, cumulativeSats) {
console.log('createChart called with loading state:', this.chartLoading) console.log('createChart called with loading state:', this.chartLoading)
if (!this.$refs.dcaChart) { if (!this.$refs.dcaChart) {
console.log('Chart ref not available for createChart') console.log('Chart ref not available for createChart')
return return
} }
// Skip if we're not in a loading state (indicates this is a stale call) // Skip if we're not in a loading state (indicates this is a stale call)
if (!this.chartLoading) { if (!this.chartLoading) {
console.log('Not in loading state, skipping createChart') console.log('Not in loading state, skipping createChart')
return return
} }
// Destroy existing chart // Destroy existing chart
if (this.dcaChart) { if (this.dcaChart) {
console.log('Destroying existing chart in createChart') console.log('Destroying existing chart in createChart')
this.dcaChart.destroy() this.dcaChart.destroy()
this.dcaChart = null this.dcaChart = null
} }
const ctx = this.$refs.dcaChart.getContext('2d') const ctx = this.$refs.dcaChart.getContext('2d')
try { try {
// Create gradient for the area fill // Create gradient for the area fill
const gradient = ctx.createLinearGradient(0, 0, 0, 300) const gradient = ctx.createLinearGradient(0, 0, 0, 300)
gradient.addColorStop(0, 'rgba(255, 149, 0, 0.4)') gradient.addColorStop(0, 'rgba(255, 149, 0, 0.4)')
gradient.addColorStop(0.5, 'rgba(255, 149, 0, 0.2)') gradient.addColorStop(0.5, 'rgba(255, 149, 0, 0.2)')
gradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)') gradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)')
// Small delay to ensure Chart.js is fully initialized // Small delay to ensure Chart.js is fully initialized
setTimeout(() => { setTimeout(() => {
try { try {
@ -621,7 +676,7 @@ window.app = Vue.createApp({
console.log('Loading state changed during timeout, aborting chart creation') console.log('Loading state changed during timeout, aborting chart creation')
return return
} }
this.dcaChart = new Chart(ctx, { this.dcaChart = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
@ -662,7 +717,7 @@ window.app = Vue.createApp({
cornerRadius: 8, cornerRadius: 8,
displayColors: false, displayColors: false,
callbacks: { callbacks: {
title: function(context) { title: function (context) {
return `📅 ${context[0].label}` return `📅 ${context[0].label}`
}, },
label: function (context) { label: function (context) {
@ -741,10 +796,13 @@ window.app = Vue.createApp({
async created() { async created() {
try { try {
this.loading = true this.loading = true
// Check registration status first // Load client limits first
await this.loadClientLimits()
// Check registration status
await this.checkRegistrationStatus() await this.checkRegistrationStatus()
// Only load dashboard data if registered // Only load dashboard data if registered
if (this.isRegistered) { if (this.isRegistered) {
await Promise.all([ await Promise.all([
@ -768,7 +826,7 @@ window.app = Vue.createApp({
console.log('Loading state:', this.loading) console.log('Loading state:', this.loading)
console.log('Chart ref available:', !!this.$refs.dcaChart) console.log('Chart ref available:', !!this.$refs.dcaChart)
console.log('Analytics data available:', !!this.analyticsData) console.log('Analytics data available:', !!this.analyticsData)
if (this.analyticsData && this.$refs.dcaChart) { if (this.analyticsData && this.$refs.dcaChart) {
console.log('Initializing chart from mounted hook') console.log('Initializing chart from mounted hook')
this.initDCAChart() this.initDCAChart()
@ -781,6 +839,14 @@ window.app = Vue.createApp({
computed: { computed: {
hasData() { hasData() {
return this.dashboardData && !this.loading && this.isRegistered 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
}))
} }
}, },

View file

@ -7,7 +7,7 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
<script src="{{ static_url_for('satmachineclient/static', path='js/index.js') }}"></script> <script src="{{ static_url_for('satmachineclient/static', path='js/index.js') }}"></script>
{% endblock %} {% block page %} {% endblock %} {% block page %}
<div class="row q-col-gutter-md" id="dcaClient"> <div class="row q-col-gutter-md" id="vue">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<!-- Loading State --> <!-- Loading State -->
@ -21,19 +21,87 @@
<!-- Error State --> <!-- Error State -->
<q-card v-if="error && !loading" class="bg-negative text-white"> <q-card v-if="error && !loading" class="bg-negative text-white">
<q-card-section> <q-card-section>
<q-icon name="error" class="q-mr-sm" /> <q-icon name="error" class="q-mr-sm"></q-icon>
${error} ${error}
</q-card-section> </q-card-section>
</q-card> </q-card>
<!-- Not Registered State --> <!-- Registration Form Card -->
<q-card v-if="registrationChecked && !isRegistered && !loading" class="bg-orange-1"> <q-card v-if="registrationChecked && !isRegistered && !loading" class="q-mb-md">
<q-card-section class="text-center"> <q-card-section>
<q-icon name="account_circle" size="3em" color="orange" /> <div class="text-center q-mb-lg">
<div class="text-h6 q-mt-md text-orange-8">Welcome to Bitcoin DCA!</div> <div>
<div class="text-body2 text-grey-7 q-mt-sm"> <q-icon name="account_circle" size="4em" color="orange"></q-icon>
Please complete your registration to start your Dollar Cost Averaging journey. </div>
<div class="text-h5 q-mt-md text-orange-8">Welcome to DCA!</div>
<div class="text-body2 text-grey-7">Let's set up your Bitcoin Dollar Cost Averaging account</div>
</div> </div>
<q-form @submit="registerClient" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="registrationForm.selectedWallet"
:options="walletOptions"
label="DCA Wallet *"
hint="Choose which wallet will receive your Bitcoin DCA purchases"
></q-select>
<q-select
filled
dense
emit-value
v-model="registrationForm.dca_mode"
:options="[
{ label: 'Flow Mode (Recommended)', value: 'flow' },
{ label: 'Fixed Mode', value: 'fixed' }
]"
option-label="label"
option-value="value"
label="DCA Strategy *"
hint="Choose how your Bitcoin purchases will be distributed"
></q-select>
<q-input
v-if="registrationForm.dca_mode === 'fixed'"
filled
dense
type="number"
v-model.number="registrationForm.fixed_mode_daily_limit"
label="Daily Limit (GTQ)"
placeholder="Enter daily purchase limit"
:hint="`Maximum amount to purchase per day (Admin limit: ${adminConfig.max_daily_limit_gtq} GTQ)`"
:rules="[
val => registrationForm.dca_mode !== 'fixed' || (val && val > 0) || 'Daily limit is required for fixed mode',
val => registrationForm.dca_mode !== 'fixed' || val <= adminConfig.max_daily_limit_gtq || `Daily limit cannot exceed ${adminConfig.max_daily_limit_gtq} GTQ (admin maximum)`
]"
></q-input>
<q-banner class="bg-blue-1 text-blue-9 q-mt-md">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
<div class="text-caption">
<strong>Flow Mode:</strong> Your Bitcoin purchases come at 0% fee when people cash ou at the machine.<br>
<strong>Fixed Mode:</strong> Set a daily limit for consistent Bitcoin accumulation.
</div>
</q-banner>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:disable="!registrationForm.selectedWallet || !registrationForm.dca_mode"
class="full-width"
size="lg"
>
<q-icon name="flash_on" class="q-mr-sm"></q-icon>
Start My DCA Journey 🚀
</q-btn>
</div>
</q-form>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -179,7 +247,7 @@
<div v-if="dashboardData.pending_fiat_deposits > 0" class="q-mt-md"> <div v-if="dashboardData.pending_fiat_deposits > 0" class="q-mt-md">
<q-banner rounded class="bg-orange-1 text-orange-9"> <q-banner rounded class="bg-orange-1 text-orange-9">
<template v-slot:avatar> <template v-slot:avatar>
<q-icon name="schedule" color="orange" size="md" /> <q-icon name="schedule" color="orange" size="md"></q-icon>
</template> </template>
<div class="text-subtitle2"> <div class="text-subtitle2">
<strong>${formatCurrency(dashboardData.pending_fiat_deposits)}</strong> ready to DCA <strong>${formatCurrency(dashboardData.pending_fiat_deposits)}</strong> ready to DCA
@ -363,7 +431,7 @@
<template v-slot:no-data="{ message }"> <template v-slot:no-data="{ message }">
<div class="full-width row flex-center q-pa-lg"> <div class="full-width row flex-center q-pa-lg">
<div class="text-center"> <div class="text-center">
<q-icon name="rocket_launch" size="3em" class="text-orange-5 q-mb-md" /> <q-icon name="rocket_launch" size="3em" class="text-orange-5 q-mb-md"></q-icon>
<div class="text-h6 text-grey-7 q-mb-sm">${message}</div> <div class="text-h6 text-grey-7 q-mb-sm">${message}</div>
<div class="text-caption text-grey-5"> <div class="text-caption text-grey-5">
Visit your nearest Lamassu ATM to begin stacking sats automatically Visit your nearest Lamassu ATM to begin stacking sats automatically
@ -443,6 +511,17 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item>
<q-item-section avatar>
<div v-if="dashboardData.total_sats_accumulated >= 50000" class="text-positive text-h6"></div>
<div v-else class="text-grey-5 text-h6"></div>
</q-item-section>
<q-item-section>
<q-item-label class="text-body2">50,000 sats</q-item-label>
<q-item-label caption>Lightning quick ⚡</q-item-label>
</q-item-section>
</q-item>
<q-item> <q-item>
<q-item-section avatar> <q-item-section avatar>
<div v-if="dashboardData.total_sats_accumulated >= 100000" class="text-positive text-h6"></div> <div v-if="dashboardData.total_sats_accumulated >= 100000" class="text-positive text-h6"></div>
@ -472,7 +551,51 @@
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label class="text-body2">1,000,000 sats</q-item-label> <q-item-label class="text-body2">1,000,000 sats</q-item-label>
<q-item-label caption>True HODLer 💎</q-item-label> <q-item-label caption>Millionaire! 🌟</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<div v-if="dashboardData.total_sats_accumulated >= 5000000" class="text-positive text-h6"></div>
<div v-else class="text-grey-5 text-h6"></div>
</q-item-section>
<q-item-section>
<q-item-label class="text-body2">5,000,000 sats</q-item-label>
<q-item-label caption>Verified Bag Holder 🏆</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<div v-if="dashboardData.total_sats_accumulated >= 10000000" class="text-positive text-h6"></div>
<div v-else class="text-grey-5 text-h6"></div>
</q-item-section>
<q-item-section>
<q-item-label class="text-body2">10,000,000 sats</q-item-label>
<q-item-label caption>Bitcoin royalty 👑</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<div v-if="dashboardData.total_sats_accumulated >= 50000000" class="text-positive text-h6"></div>
<div v-else class="text-grey-5 text-h6"></div>
</q-item-section>
<q-item-section>
<q-item-label class="text-body2">50,000,000 sats</q-item-label>
<q-item-label caption>Bitcoin baron 🎆</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<div v-if="dashboardData.total_sats_accumulated >= 100000000" class="text-positive text-h6"></div>
<div v-else class="text-grey-5 text-h6"></div>
</q-item-section>
<q-item-section>
<q-item-label class="text-body2">100,000,000 sats</q-item-label>
<q-item-label caption>Legendary Full coiner! 🏆</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@ -523,106 +646,5 @@
</q-card> </q-card>
</div> </div>
<!-- Registration Dialog -->
<q-dialog v-model="showRegistrationDialog" persistent position="top">
<q-card class="q-pa-lg" style="width: 500px; max-width: 90vw">
<div class="text-center q-mb-lg">
<q-icon name="account_circle" size="4em" color="orange" />
<div class="text-h5 q-mt-md text-orange-8">Welcome to DCA!</div>
<div class="text-body2 text-grey-7">Let's set up your Bitcoin Dollar Cost Averaging account</div>
</div>
<q-form @submit="registerClient" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="registrationForm.username"
label="Username (Optional)"
placeholder="Enter a friendly name"
hint="How you'd like to be identified in the system"
/>
<q-select
filled
dense
v-model="registrationForm.selectedWallet"
:options="g.user.wallets"
option-label="name"
label="DCA Wallet *"
hint="Choose which wallet will receive your Bitcoin DCA purchases"
>
<template v-slot:option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<q-item-label>
${scope.opt.name} (ID: ${scope.opt.id.substring(0, 8)}... • ${Math.round(scope.opt.balance_msat / 1000)} sats)
</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<q-select
filled
dense
v-model="registrationForm.dca_mode"
:options="[
{ label: 'Flow Mode (Recommended)', value: 'flow', description: 'Proportional distribution based on your balance' },
{ label: 'Fixed Mode', value: 'fixed', description: 'Set daily purchase limits' }
]"
option-label="label"
option-value="value"
label="DCA Strategy *"
hint="Choose how your Bitcoin purchases will be distributed"
>
<template v-slot:option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<q-item-label>${scope.opt.label} - ${scope.opt.description}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
v-if="registrationForm.dca_mode === 'fixed'"
filled
dense
type="number"
v-model.number="registrationForm.fixed_mode_daily_limit"
label="Daily Limit (GTQ)"
placeholder="Enter daily purchase limit in centavos"
hint="Maximum amount to purchase per day (in centavos: 1 GTQ = 100 centavos)"
:rules="[
val => registrationForm.dca_mode !== 'fixed' || (val && val > 0) || 'Daily limit is required for fixed mode'
]"
/>
<q-banner class="bg-blue-1 text-blue-9 q-mt-md">
<template v-slot:avatar>
<q-icon name="info" color="blue" />
</template>
<div class="text-caption">
<strong>Flow Mode:</strong> Your Bitcoin purchases are proportional to your deposit balance.<br>
<strong>Fixed Mode:</strong> Set a daily limit for consistent Bitcoin accumulation.
</div>
</q-banner>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:disable="!registrationForm.selectedWallet || !registrationForm.dca_mode"
class="full-width"
size="lg"
>
<q-icon name="rocket_launch" class="q-mr-sm" />
Start My DCA Journey
</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -199,7 +199,7 @@ async def api_export_transactions(
writer.writerow([ writer.writerow([
tx.created_at.isoformat(), tx.created_at.isoformat(),
tx.amount_sats, tx.amount_sats,
tx.amount_fiat, # Values are already in full currency units tx.amount_fiat, # Amount already in GTQ
tx.exchange_rate, tx.exchange_rate,
tx.transaction_type, tx.transaction_type,
tx.status tx.status
@ -214,3 +214,7 @@ async def api_export_transactions(
) )
else: else:
return {"transactions": transactions} return {"transactions": transactions}
# Removed local client-limits endpoint
# Client should call admin extension's public endpoint directly