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
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
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
# 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

View file

@ -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
}))
}
},

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="{{ static_url_for('satmachineclient/static', path='js/index.js') }}"></script>
{% 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">
<!-- Loading State -->
@ -21,19 +21,87 @@
<!-- Error State -->
<q-card v-if="error && !loading" class="bg-negative text-white">
<q-card-section>
<q-icon name="error" class="q-mr-sm" />
<q-icon name="error" class="q-mr-sm"></q-icon>
${error}
</q-card-section>
</q-card>
<!-- Not Registered State -->
<q-card v-if="registrationChecked && !isRegistered && !loading" class="bg-orange-1">
<q-card-section class="text-center">
<q-icon name="account_circle" size="3em" color="orange" />
<div class="text-h6 q-mt-md text-orange-8">Welcome to Bitcoin DCA!</div>
<div class="text-body2 text-grey-7 q-mt-sm">
Please complete your registration to start your Dollar Cost Averaging journey.
<!-- Registration Form Card -->
<q-card v-if="registrationChecked && !isRegistered && !loading" class="q-mb-md">
<q-card-section>
<div class="text-center q-mb-lg">
<div>
<q-icon name="account_circle" size="4em" color="orange"></q-icon>
</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>
<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>
@ -179,7 +247,7 @@
<div v-if="dashboardData.pending_fiat_deposits > 0" class="q-mt-md">
<q-banner rounded class="bg-orange-1 text-orange-9">
<template v-slot:avatar>
<q-icon name="schedule" color="orange" size="md" />
<q-icon name="schedule" color="orange" size="md"></q-icon>
</template>
<div class="text-subtitle2">
<strong>${formatCurrency(dashboardData.pending_fiat_deposits)}</strong> ready to DCA
@ -363,7 +431,7 @@
<template v-slot:no-data="{ message }">
<div class="full-width row flex-center q-pa-lg">
<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-caption text-grey-5">
Visit your nearest Lamassu ATM to begin stacking sats automatically
@ -443,6 +511,17 @@
</q-item-section>
</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-section avatar>
<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-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>
</q-list>
@ -523,106 +646,5 @@
</q-card>
</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>
{% endblock %}

View file

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