From b7e4ec4a1bfc802a6df6db29d7533861aef09dc2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Sep 2025 20:36:07 +0000 Subject: [PATCH] Fix Balance Progress Over Time chart by adding historical balance endpoint Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com> --- frontend/src/lib/api.ts | 12 +++ frontend/src/routes/analytics.tsx | 4 +- leggen/database/sqlite.py | 128 +++++++++++++++++++++++++++ leggend/api/routes/accounts.py | 25 ++++++ leggend/services/database_service.py | 16 ++++ 5 files changed, 183 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 798c467..f7f621f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -54,6 +54,18 @@ export const apiClient = { return response.data.data; }, + // Get historical balances for balance progression chart + getHistoricalBalances: async (days?: number, accountId?: string): Promise => { + const queryParams = new URLSearchParams(); + if (days) queryParams.append("days", days.toString()); + if (accountId) queryParams.append("account_id", accountId); + + const response = await api.get>( + `/balances/history?${queryParams.toString()}` + ); + return response.data.data; + }, + // Get balances for specific account getAccountBalances: async (accountId: string): Promise => { const response = await api.get>( diff --git a/frontend/src/routes/analytics.tsx b/frontend/src/routes/analytics.tsx index 6b13643..83366d2 100644 --- a/frontend/src/routes/analytics.tsx +++ b/frontend/src/routes/analytics.tsx @@ -27,8 +27,8 @@ function AnalyticsDashboard() { }); const { data: balances, isLoading: balancesLoading } = useQuery({ - queryKey: ["balances"], - queryFn: () => apiClient.getBalances(), + queryKey: ["historical-balances"], + queryFn: () => apiClient.getHistoricalBalances(365), // Get 1 year of history }); const isLoading = statsLoading || accountsLoading || balancesLoading; diff --git a/leggen/database/sqlite.py b/leggen/database/sqlite.py index 8fbfcde..1c35ca9 100644 --- a/leggen/database/sqlite.py +++ b/leggen/database/sqlite.py @@ -520,3 +520,131 @@ def get_account(account_id: str): except Exception as e: conn.close() raise e + + +def get_historical_balances(account_id=None, days=365): + """Get historical balance progression based on transaction history""" + db_path = path_manager.get_database_path() + if not db_path.exists(): + return [] + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + try: + # Get current balance for each account/type to use as the final balance + current_balances_query = """ + SELECT account_id, type, amount, currency + FROM balances b1 + WHERE b1.timestamp = ( + SELECT MAX(b2.timestamp) + FROM balances b2 + WHERE b2.account_id = b1.account_id AND b2.type = b1.type + ) + """ + params = [] + + if account_id: + current_balances_query += " AND b1.account_id = ?" + params.append(account_id) + + cursor.execute(current_balances_query, params) + current_balances = { + (row['account_id'], row['type']): { + 'amount': row['amount'], + 'currency': row['currency'] + } + for row in cursor.fetchall() + } + + # Get transactions for the specified period, ordered by date descending + from datetime import datetime, timedelta + cutoff_date = (datetime.now() - timedelta(days=days)).isoformat() + + transactions_query = """ + SELECT accountId, transactionDate, transactionValue + FROM transactions + WHERE transactionDate >= ? + """ + + if account_id: + transactions_query += " AND accountId = ?" + params = [cutoff_date, account_id] + else: + params = [cutoff_date] + + transactions_query += " ORDER BY transactionDate DESC" + + cursor.execute(transactions_query, params) + transactions = cursor.fetchall() + + # Calculate historical balances by working backwards from current balance + historical_balances = [] + account_running_balances = {} + + # Initialize running balances with current balances + for (acc_id, balance_type), balance_info in current_balances.items(): + if acc_id not in account_running_balances: + account_running_balances[acc_id] = {} + account_running_balances[acc_id][balance_type] = balance_info['amount'] + + # Group transactions by date + from collections import defaultdict + transactions_by_date = defaultdict(list) + + for txn in transactions: + date_str = txn['transactionDate'][:10] # Extract just the date part + transactions_by_date[date_str].append(txn) + + # Generate historical balance points + # Start from today and work backwards + current_date = datetime.now().date() + + for day_offset in range(0, days, 7): # Sample every 7 days for performance + target_date = current_date - timedelta(days=day_offset) + target_date_str = target_date.isoformat() + + # For each account, create balance entries + for acc_id in account_running_balances: + for balance_type in ['closingBooked']: # Focus on closingBooked for the chart + if balance_type in account_running_balances[acc_id]: + balance_amount = account_running_balances[acc_id][balance_type] + currency = current_balances.get((acc_id, balance_type), {}).get('currency', 'EUR') + + historical_balances.append({ + 'id': f"{acc_id}_{balance_type}_{target_date_str}", + 'account_id': acc_id, + 'balance_amount': balance_amount, + 'balance_type': balance_type, + 'currency': currency, + 'reference_date': target_date_str, + 'created_at': None, + 'updated_at': None + }) + + # Subtract transactions that occurred on this date and later dates + # to simulate going back in time + for date_str in list(transactions_by_date.keys()): + if date_str >= target_date_str: + for txn in transactions_by_date[date_str]: + acc_id = txn['accountId'] + amount = txn['transactionValue'] + + if acc_id in account_running_balances: + for balance_type in account_running_balances[acc_id]: + account_running_balances[acc_id][balance_type] -= amount + + # Remove processed transactions to avoid double-processing + del transactions_by_date[date_str] + + conn.close() + + # Sort by date for proper chronological order + historical_balances.sort(key=lambda x: x['reference_date']) + + return historical_balances + + except Exception as e: + conn.close() + raise e diff --git a/leggend/api/routes/accounts.py b/leggend/api/routes/accounts.py index af60031..817608f 100644 --- a/leggend/api/routes/accounts.py +++ b/leggend/api/routes/accounts.py @@ -215,6 +215,31 @@ async def get_all_balances() -> APIResponse: ) from e +@router.get("/balances/history", response_model=APIResponse) +async def get_historical_balances( + days: Optional[int] = Query(default=365, le=1095, ge=1, description="Number of days of history to retrieve"), + account_id: Optional[str] = Query(default=None, description="Filter by specific account ID") +) -> APIResponse: + """Get historical balance progression calculated from transaction history""" + try: + # Get historical balances from database + historical_balances = await database_service.get_historical_balances_from_db( + account_id=account_id, days=days + ) + + return APIResponse( + success=True, + data=historical_balances, + message=f"Retrieved {len(historical_balances)} historical balance points over {days} days", + ) + + except Exception as e: + logger.error(f"Failed to get historical balances: {e}") + raise HTTPException( + status_code=500, detail=f"Failed to get historical balances: {str(e)}" + ) from e + + @router.get("/accounts/{account_id}/transactions", response_model=APIResponse) async def get_account_transactions( account_id: str, diff --git a/leggend/services/database_service.py b/leggend/services/database_service.py index acb5649..93a83cd 100644 --- a/leggend/services/database_service.py +++ b/leggend/services/database_service.py @@ -195,6 +195,22 @@ class DatabaseService: logger.error(f"Failed to get balances from database: {e}") return [] + async def get_historical_balances_from_db( + self, account_id: Optional[str] = None, days: int = 365 + ) -> List[Dict[str, Any]]: + """Get historical balance progression from SQLite database""" + if not self.sqlite_enabled: + logger.warning("SQLite database disabled, cannot read historical balances") + return [] + + try: + balances = sqlite_db.get_historical_balances(account_id=account_id, days=days) + logger.debug(f"Retrieved {len(balances)} historical balance points from database") + return balances + except Exception as e: + logger.error(f"Failed to get historical balances from database: {e}") + return [] + async def get_account_summary_from_db( self, account_id: str ) -> Optional[Dict[str, Any]]: