diff --git a/frontend/src/components/analytics/BalanceChart.tsx b/frontend/src/components/analytics/BalanceChart.tsx index e140a60..9eba397 100644 --- a/frontend/src/components/analytics/BalanceChart.tsx +++ b/frontend/src/components/analytics/BalanceChart.tsx @@ -8,10 +8,11 @@ import { ResponsiveContainer, Legend, } from "recharts"; -import type { Balance } from "../../types/api"; +import type { Balance, Account } from "../../types/api"; interface BalanceChartProps { data: Balance[]; + accounts: Account[]; className?: string; } @@ -26,7 +27,34 @@ interface AggregatedDataPoint { [key: string]: string | number; } -export default function BalanceChart({ data, className }: BalanceChartProps) { +export default function BalanceChart({ data, accounts, className }: BalanceChartProps) { + // Create a lookup map for account info + const accountMap = accounts.reduce((map, account) => { + map[account.id] = account; + return map; + }, {} as Record); + + // Helper function to get bank name from institution_id + const getBankName = (institutionId: string): string => { + const bankMapping: Record = { + 'REVOLUT_REVOLT21': 'Revolut', + 'NUBANK_NUPBBR25': 'Nu Pagamentos', + 'BANCOBPI_BBPIPTPL': 'Banco BPI', + // Add more mappings as needed + }; + return bankMapping[institutionId] || institutionId.split('_')[0]; + }; + + // Helper function to create display name for account + const getAccountDisplayName = (accountId: string): string => { + const account = accountMap[accountId]; + if (account) { + const bankName = getBankName(account.institution_id); + const accountName = account.name || `Account ${accountId.split('-')[1]}`; + return `${bankName} - ${accountName}`; + } + return `Account ${accountId.split('-')[1]}`; + }; // Process balance data for the chart const chartData = data .filter((balance) => balance.balance_type === "closingBooked") @@ -116,7 +144,7 @@ export default function BalanceChart({ data, className }: BalanceChartProps) { stroke={colors[index % colors.length]} strokeWidth={2} dot={{ r: 4 }} - name={`Account ${accountId.split('-')[1]}`} + name={getAccountDisplayName(accountId)} /> ))} diff --git a/frontend/src/components/analytics/MonthlyTrends.tsx b/frontend/src/components/analytics/MonthlyTrends.tsx index f6a87d0..800f0ec 100644 --- a/frontend/src/components/analytics/MonthlyTrends.tsx +++ b/frontend/src/components/analytics/MonthlyTrends.tsx @@ -32,18 +32,12 @@ interface TooltipProps { } export default function MonthlyTrends({ className }: MonthlyTrendsProps) { - // Get transactions for the last 12 months + // Get transactions for the last 12 months using analytics endpoint const { data: transactions, isLoading } = useQuery({ queryKey: ["transactions", "monthly-trends"], queryFn: async () => { - const response = await apiClient.getTransactions({ - startDate: new Date( - Date.now() - 365 * 24 * 60 * 60 * 1000 - ).toISOString().split("T")[0], - endDate: new Date().toISOString().split("T")[0], - perPage: 1000, - }); - return response.data; + // Get last 365 days of transactions for monthly trends + return await apiClient.getTransactionsForAnalytics(365); }, }); @@ -54,7 +48,7 @@ export default function MonthlyTrends({ className }: MonthlyTrendsProps) { const monthlyMap: { [key: string]: MonthlyData } = {}; transactions.forEach((transaction) => { - const date = new Date(transaction.transaction_date); + const date = new Date(transaction.date); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; if (!monthlyMap[monthKey]) { @@ -69,10 +63,10 @@ export default function MonthlyTrends({ className }: MonthlyTrendsProps) { }; } - if (transaction.transaction_value > 0) { - monthlyMap[monthKey].income += transaction.transaction_value; + if (transaction.amount > 0) { + monthlyMap[monthKey].income += transaction.amount; } else { - monthlyMap[monthKey].expenses += Math.abs(transaction.transaction_value); + monthlyMap[monthKey].expenses += Math.abs(transaction.amount); } monthlyMap[monthKey].net = monthlyMap[monthKey].income - monthlyMap[monthKey].expenses; diff --git a/frontend/src/components/analytics/TransactionDistribution.tsx b/frontend/src/components/analytics/TransactionDistribution.tsx index b003a38..ebc3996 100644 --- a/frontend/src/components/analytics/TransactionDistribution.tsx +++ b/frontend/src/components/analytics/TransactionDistribution.tsx @@ -30,6 +30,24 @@ export default function TransactionDistribution({ accounts, className, }: TransactionDistributionProps) { + // Helper function to get bank name from institution_id + const getBankName = (institutionId: string): string => { + const bankMapping: Record = { + 'REVOLUT_REVOLT21': 'Revolut', + 'NUBANK_NUPBBR25': 'Nu Pagamentos', + 'BANCOBPI_BBPIPTPL': 'Banco BPI', + // Add more mappings as needed + }; + return bankMapping[institutionId] || institutionId.split('_')[0]; + }; + + // Helper function to create display name for account + const getAccountDisplayName = (account: Account): string => { + const bankName = getBankName(account.institution_id); + const accountName = account.name || `Account ${account.id.split('-')[1]}`; + return `${bankName} - ${accountName}`; + }; + // Create pie chart data from account balances const pieData: PieDataPoint[] = accounts.map((account, index) => { const closingBalance = account.balances.find( @@ -39,7 +57,7 @@ export default function TransactionDistribution({ const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"]; return { - name: account.name || `Account ${account.id.split('-')[1]}`, + name: getAccountDisplayName(account), value: closingBalance?.amount || 0, color: colors[index % colors.length], }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b2319f4..798c467 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -154,6 +154,17 @@ export const apiClient = { ); return response.data.data; }, + + // Get all transactions for analytics (no pagination) + getTransactionsForAnalytics: async (days?: number): Promise => { + const queryParams = new URLSearchParams(); + if (days) queryParams.append("days", days.toString()); + + const response = await api.get>( + `/transactions/analytics?${queryParams.toString()}` + ); + return response.data.data; + }, }; export default apiClient; diff --git a/frontend/src/routes/analytics.tsx b/frontend/src/routes/analytics.tsx index 5c3bfb1..4b894e0 100644 --- a/frontend/src/routes/analytics.tsx +++ b/frontend/src/routes/analytics.tsx @@ -126,7 +126,7 @@ function AnalyticsDashboard() { {/* Charts */}
- +
diff --git a/leggen/commands/generate_sample_db.py b/leggen/commands/generate_sample_db.py index 3dc9ea0..a4afbb2 100644 --- a/leggen/commands/generate_sample_db.py +++ b/leggen/commands/generate_sample_db.py @@ -30,29 +30,33 @@ from leggen.utils.paths import path_manager help="Overwrite existing database without confirmation", ) @click.pass_context -def generate_sample_db(ctx: click.Context, database: Path, accounts: int, transactions: int, force: bool): +def generate_sample_db( + ctx: click.Context, database: Path, accounts: int, transactions: int, force: bool +): """Generate a sample database with realistic financial data for testing.""" - + # Import here to avoid circular imports import sys import subprocess from pathlib import Path as PathlibPath - + # Get the script path - script_path = PathlibPath(__file__).parent.parent.parent / "scripts" / "generate_sample_db.py" - + script_path = ( + PathlibPath(__file__).parent.parent.parent / "scripts" / "generate_sample_db.py" + ) + # Build command arguments cmd = [sys.executable, str(script_path)] - + if database: cmd.extend(["--database", str(database)]) - + cmd.extend(["--accounts", str(accounts)]) cmd.extend(["--transactions", str(transactions)]) - + if force: cmd.append("--force") - + # Execute the script try: subprocess.run(cmd, check=True) @@ -62,4 +66,4 @@ def generate_sample_db(ctx: click.Context, database: Path, accounts: int, transa # Export the command -generate_sample_db = generate_sample_db \ No newline at end of file +generate_sample_db = generate_sample_db diff --git a/leggen/utils/paths.py b/leggen/utils/paths.py index 26fb2c8..ef39738 100644 --- a/leggen/utils/paths.py +++ b/leggen/utils/paths.py @@ -7,32 +7,32 @@ from typing import Optional class PathManager: """Manages configurable paths for config and database files.""" - + def __init__(self): self._config_dir: Optional[Path] = None self._database_path: Optional[Path] = None - + def get_config_dir(self) -> Path: """Get the configuration directory.""" if self._config_dir is not None: return self._config_dir - + # Check environment variable first config_dir = os.environ.get("LEGGEN_CONFIG_DIR") if config_dir: return Path(config_dir) - + # Default to ~/.config/leggen return Path.home() / ".config" / "leggen" - + def set_config_dir(self, path: Path) -> None: """Set the configuration directory.""" self._config_dir = Path(path) - + def get_config_file_path(self) -> Path: """Get the configuration file path.""" return self.get_config_dir() / "config.toml" - + def get_database_path(self) -> Path: """Get the database file path and ensure the directory exists.""" if self._database_path is not None: @@ -45,7 +45,7 @@ class PathManager: else: # Default to config_dir/leggen.db db_path = self.get_config_dir() / "leggen.db" - + # Try to ensure the directory exists, but handle permission errors gracefully try: db_path.parent.mkdir(parents=True, exist_ok=True) @@ -53,24 +53,24 @@ class PathManager: # If we can't create the directory, continue anyway # This allows tests and error cases to work as expected pass - + return db_path - + def set_database_path(self, path: Path) -> None: """Set the database file path.""" self._database_path = Path(path) - + def get_auth_file_path(self) -> Path: """Get the authentication file path.""" return self.get_config_dir() / "auth.json" - + def ensure_config_dir_exists(self) -> None: """Ensure the configuration directory exists.""" self.get_config_dir().mkdir(parents=True, exist_ok=True) - + def ensure_database_dir_exists(self) -> None: - """Ensure the database directory exists. - + """Ensure the database directory exists. + Note: get_database_path() now automatically ensures the directory exists, so this method is mainly for explicit directory creation in tests. """ @@ -78,4 +78,4 @@ class PathManager: # Global instance for the application -path_manager = PathManager() \ No newline at end of file +path_manager = PathManager() diff --git a/leggend/api/routes/transactions.py b/leggend/api/routes/transactions.py index 778d775..6945614 100644 --- a/leggend/api/routes/transactions.py +++ b/leggend/api/routes/transactions.py @@ -121,6 +121,219 @@ async def get_all_transactions( ) from e +@router.get("/transactions/enhanced-stats", response_model=APIResponse) +async def get_enhanced_transaction_stats( + days: int = Query(default=365, description="Number of days to include in stats"), + account_id: Optional[str] = Query(default=None, description="Filter by account ID"), +) -> APIResponse: + """Get enhanced transaction statistics with monthly breakdown and account details""" + try: + # Date range for stats + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + # Format dates for database query + date_from = start_date.isoformat() + date_to = end_date.isoformat() + + # Get all transactions from database for comprehensive stats + recent_transactions = await database_service.get_transactions_from_db( + account_id=account_id, + date_from=date_from, + date_to=date_to, + limit=None, # Get all matching transactions + ) + + # Basic stats + total_transactions = len(recent_transactions) + total_income = sum( + txn["transactionValue"] + for txn in recent_transactions + if txn["transactionValue"] > 0 + ) + total_expenses = sum( + abs(txn["transactionValue"]) + for txn in recent_transactions + if txn["transactionValue"] < 0 + ) + net_change = total_income - total_expenses + + # Count by status + booked_count = len( + [txn for txn in recent_transactions if txn["transactionStatus"] == "booked"] + ) + pending_count = len( + [ + txn + for txn in recent_transactions + if txn["transactionStatus"] == "pending" + ] + ) + + # Count unique accounts + unique_accounts = len({txn["accountId"] for txn in recent_transactions}) + + # Monthly breakdown + monthly_stats = {} + for txn in recent_transactions: + try: + txn_date = datetime.fromisoformat( + txn["transactionDate"].replace("Z", "+00:00") + ) + month_key = txn_date.strftime("%Y-%m") + + if month_key not in monthly_stats: + monthly_stats[month_key] = { + "month": txn_date.strftime("%Y %b"), + "income": 0, + "expenses": 0, + "net": 0, + "transaction_count": 0, + } + + monthly_stats[month_key]["transaction_count"] += 1 + if txn["transactionValue"] > 0: + monthly_stats[month_key]["income"] += txn["transactionValue"] + else: + monthly_stats[month_key]["expenses"] += abs(txn["transactionValue"]) + + monthly_stats[month_key]["net"] = ( + monthly_stats[month_key]["income"] + - monthly_stats[month_key]["expenses"] + ) + except (ValueError, TypeError): + # Skip transactions with invalid dates + continue + + # Account breakdown + account_stats = {} + for txn in recent_transactions: + acc_id = txn["accountId"] + if acc_id not in account_stats: + account_stats[acc_id] = { + "account_id": acc_id, + "transaction_count": 0, + "income": 0, + "expenses": 0, + "net": 0, + } + + account_stats[acc_id]["transaction_count"] += 1 + if txn["transactionValue"] > 0: + account_stats[acc_id]["income"] += txn["transactionValue"] + else: + account_stats[acc_id]["expenses"] += abs(txn["transactionValue"]) + + account_stats[acc_id]["net"] = ( + account_stats[acc_id]["income"] - account_stats[acc_id]["expenses"] + ) + + enhanced_stats = { + "period_days": days, + "date_range": { + "start": start_date.isoformat(), + "end": end_date.isoformat(), + }, + "summary": { + "total_transactions": total_transactions, + "booked_transactions": booked_count, + "pending_transactions": pending_count, + "total_income": round(total_income, 2), + "total_expenses": round(total_expenses, 2), + "net_change": round(net_change, 2), + "average_transaction": round( + sum(txn["transactionValue"] for txn in recent_transactions) + / total_transactions, + 2, + ) + if total_transactions > 0 + else 0, + "accounts_included": unique_accounts, + }, + "monthly_breakdown": [ + { + **stats, + "income": round(stats["income"], 2), + "expenses": round(stats["expenses"], 2), + "net": round(stats["net"], 2), + } + for month, stats in sorted(monthly_stats.items()) + ], + "account_breakdown": [ + { + **stats, + "income": round(stats["income"], 2), + "expenses": round(stats["expenses"], 2), + "net": round(stats["net"], 2), + } + for stats in account_stats.values() + ], + } + + return APIResponse( + success=True, + data=enhanced_stats, + message=f"Enhanced transaction statistics for last {days} days", + ) + + except Exception as e: + logger.error(f"Failed to get enhanced transaction stats: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to get enhanced transaction stats: {str(e)}", + ) from e + + +@router.get("/transactions/analytics", response_model=APIResponse) +async def get_transactions_for_analytics( + days: int = Query(default=365, description="Number of days to include"), + account_id: Optional[str] = Query(default=None, description="Filter by account ID"), +) -> APIResponse: + """Get all transactions for analytics (no pagination) for the last N days""" + try: + # Date range for analytics + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + # Format dates for database query + date_from = start_date.isoformat() + date_to = end_date.isoformat() + + # Get ALL transactions from database (no limit for analytics) + transactions = await database_service.get_transactions_from_db( + account_id=account_id, + date_from=date_from, + date_to=date_to, + limit=None, # No limit - get all transactions + ) + + # Transform for frontend (summary format) + transaction_summaries = [ + { + "transaction_id": txn["transactionId"], + "date": txn["transactionDate"], + "description": txn["description"], + "amount": txn["transactionValue"], + "currency": txn["transactionCurrency"], + "status": txn["transactionStatus"], + "account_id": txn["accountId"], + } + for txn in transactions + ] + + return APIResponse( + success=True, + data=transaction_summaries, + message=f"Retrieved {len(transaction_summaries)} transactions for analytics", + ) + + except Exception as e: + logger.error(f"Failed to get transactions for analytics: {e}") + raise HTTPException( + status_code=500, detail=f"Failed to get analytics transactions: {str(e)}" + ) from e + + @router.get("/transactions/stats", response_model=APIResponse) async def get_transaction_stats( days: int = Query(default=30, description="Number of days to include in stats"), diff --git a/leggend/config.py b/leggend/config.py index 662c0ef..ea12e40 100644 --- a/leggend/config.py +++ b/leggend/config.py @@ -23,9 +23,7 @@ class Config: return self._config if config_path is None: - config_path = os.environ.get( - "LEGGEN_CONFIG_FILE" - ) + config_path = os.environ.get("LEGGEN_CONFIG_FILE") if not config_path: config_path = str(path_manager.get_config_file_path()) @@ -54,9 +52,7 @@ class Config: config_data = self._config if config_path is None: - config_path = self._config_path or os.environ.get( - "LEGGEN_CONFIG_FILE" - ) + config_path = self._config_path or os.environ.get("LEGGEN_CONFIG_FILE") if not config_path: config_path = str(path_manager.get_config_file_path()) diff --git a/leggend/services/database_service.py b/leggend/services/database_service.py index b05cc8d..acb5649 100644 --- a/leggend/services/database_service.py +++ b/leggend/services/database_service.py @@ -118,7 +118,7 @@ class DatabaseService: async def get_transactions_from_db( self, account_id: Optional[str] = None, - limit: Optional[int] = 100, + limit: Optional[int] = None, # None means no limit, used for stats offset: Optional[int] = 0, date_from: Optional[str] = None, date_to: Optional[str] = None, @@ -134,7 +134,7 @@ class DatabaseService: try: transactions = sqlite_db.get_transactions( account_id=account_id, - limit=limit or 100, + limit=limit, # Pass limit as-is, None means no limit offset=offset or 0, date_from=date_from, date_to=date_to, @@ -424,7 +424,7 @@ class DatabaseService: async def _migrate_null_transaction_ids(self): """Populate null internalTransactionId fields using transactionId from raw data""" import uuid - + db_path = path_manager.get_database_path() if not db_path.exists(): logger.warning("Database file not found, skipping migration") diff --git a/scripts/generate_sample_db.py b/scripts/generate_sample_db.py index 51bfba3..4b7cfd7 100755 --- a/scripts/generate_sample_db.py +++ b/scripts/generate_sample_db.py @@ -32,7 +32,7 @@ class SampleDataGenerator: "country": "LT", }, { - "id": "BANCOBPI_BBPIPTPL", + "id": "BANCOBPI_BBPIPTPL", "name": "Banco BPI", "bic": "BBPIPTPL", "country": "PT", @@ -40,7 +40,7 @@ class SampleDataGenerator: { "id": "MONZO_MONZGB2L", "name": "Monzo Bank", - "bic": "MONZGB2L", + "bic": "MONZGB2L", "country": "GB", }, { @@ -50,16 +50,40 @@ class SampleDataGenerator: "country": "BR", }, ] - + self.transaction_types = [ - {"description": "Grocery Store", "amount_range": (-150, -20), "frequency": 0.3}, + { + "description": "Grocery Store", + "amount_range": (-150, -20), + "frequency": 0.3, + }, {"description": "Coffee Shop", "amount_range": (-15, -3), "frequency": 0.2}, - {"description": "Gas Station", "amount_range": (-80, -30), "frequency": 0.1}, - {"description": "Online Shopping", "amount_range": (-200, -25), "frequency": 0.15}, - {"description": "Restaurant", "amount_range": (-60, -15), "frequency": 0.15}, + { + "description": "Gas Station", + "amount_range": (-80, -30), + "frequency": 0.1, + }, + { + "description": "Online Shopping", + "amount_range": (-200, -25), + "frequency": 0.15, + }, + { + "description": "Restaurant", + "amount_range": (-60, -15), + "frequency": 0.15, + }, {"description": "Salary", "amount_range": (2500, 5000), "frequency": 0.02}, - {"description": "ATM Withdrawal", "amount_range": (-200, -20), "frequency": 0.05}, - {"description": "Transfer to Savings", "amount_range": (-1000, -100), "frequency": 0.03}, + { + "description": "ATM Withdrawal", + "amount_range": (-200, -20), + "frequency": 0.05, + }, + { + "description": "Transfer to Savings", + "amount_range": (-1000, -100), + "frequency": 0.03, + }, ] def ensure_database_dir(self): @@ -120,15 +144,33 @@ class SampleDataGenerator: """) # Create indexes - cursor.execute("CREATE INDEX IF NOT EXISTS idx_transactions_internal_id ON transactions(internalTransactionId)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transactionDate)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_transactions_account_date ON transactions(accountId, transactionDate)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_transactions_amount ON transactions(transactionValue)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_balances_account_id ON balances(account_id)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_balances_timestamp ON balances(timestamp)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp ON balances(account_id, type, timestamp)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_institution_id ON accounts(institution_id)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)") + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_transactions_internal_id ON transactions(internalTransactionId)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transactionDate)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_transactions_account_date ON transactions(accountId, transactionDate)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_transactions_amount ON transactions(transactionValue)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_balances_account_id ON balances(account_id)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_balances_timestamp ON balances(timestamp)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp ON balances(account_id, type, timestamp)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_accounts_institution_id ON accounts(institution_id)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)" + ) conn.commit() conn.close() @@ -141,78 +183,109 @@ class SampleDataGenerator: "GB": lambda: f"GB{random.randint(10, 99)}MONZ{random.randint(100000, 999999)}{random.randint(100000, 999999)}", "BR": lambda: f"BR{random.randint(10, 99)}{random.randint(10000000, 99999999)}{random.randint(1000, 9999)}{random.randint(10000000, 99999999)}", } - return ibans.get(country_code, lambda: f"{country_code}{random.randint(1000000000000000, 9999999999999999)}")() + return ibans.get( + country_code, + lambda: f"{country_code}{random.randint(1000000000000000, 9999999999999999)}", + )() def generate_accounts(self, num_accounts: int = 3) -> List[Dict[str, Any]]: """Generate sample accounts.""" accounts = [] base_date = datetime.now() - timedelta(days=90) - + for i in range(num_accounts): institution = random.choice(self.institutions) - account_id = f"account-{i+1:03d}-{random.randint(1000, 9999)}" - + account_id = f"account-{i + 1:03d}-{random.randint(1000, 9999)}" + account = { "id": account_id, "institution_id": institution["id"], "status": "READY", "iban": self.generate_iban(institution["country"]), - "name": f"Personal Account {i+1}", + "name": f"Personal Account {i + 1}", "currency": "EUR", - "created": (base_date + timedelta(days=random.randint(0, 30))).isoformat(), - "last_accessed": (datetime.now() - timedelta(hours=random.randint(1, 48))).isoformat(), + "created": ( + base_date + timedelta(days=random.randint(0, 30)) + ).isoformat(), + "last_accessed": ( + datetime.now() - timedelta(hours=random.randint(1, 48)) + ).isoformat(), "last_updated": datetime.now().isoformat(), } accounts.append(account) - + return accounts - def generate_transactions(self, accounts: List[Dict[str, Any]], num_transactions_per_account: int = 50) -> List[Dict[str, Any]]: + def generate_transactions( + self, accounts: List[Dict[str, Any]], num_transactions_per_account: int = 50 + ) -> List[Dict[str, Any]]: """Generate sample transactions for accounts.""" transactions = [] base_date = datetime.now() - timedelta(days=60) - + for account in accounts: account_transactions = [] current_balance = random.uniform(500, 3000) - + for i in range(num_transactions_per_account): # Choose transaction type based on frequency weights transaction_type = random.choices( self.transaction_types, - weights=[t["frequency"] for t in self.transaction_types] + weights=[t["frequency"] for t in self.transaction_types], )[0] - + # Generate transaction amount min_amount, max_amount = transaction_type["amount_range"] amount = round(random.uniform(min_amount, max_amount), 2) - + # Generate transaction date (more recent transactions are more likely) days_ago = random.choices( - range(60), - weights=[1.5 ** (60 - d) for d in range(60)] + range(60), weights=[1.5 ** (60 - d) for d in range(60)] )[0] - transaction_date = base_date + timedelta(days=days_ago, hours=random.randint(6, 22), minutes=random.randint(0, 59)) - + transaction_date = base_date + timedelta( + days=days_ago, + hours=random.randint(6, 22), + minutes=random.randint(0, 59), + ) + # Generate transaction IDs - transaction_id = f"bank-txn-{account['id']}-{i+1:04d}" + transaction_id = f"bank-txn-{account['id']}-{i + 1:04d}" internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}" - + # Create realistic descriptions descriptions = { - "Grocery Store": ["TESCO", "SAINSBURY'S", "LIDL", "ALDI", "WALMART", "CARREFOUR"], - "Coffee Shop": ["STARBUCKS", "COSTA COFFEE", "PRET A MANGER", "LOCAL CAFE"], + "Grocery Store": [ + "TESCO", + "SAINSBURY'S", + "LIDL", + "ALDI", + "WALMART", + "CARREFOUR", + ], + "Coffee Shop": [ + "STARBUCKS", + "COSTA COFFEE", + "PRET A MANGER", + "LOCAL CAFE", + ], "Gas Station": ["BP", "SHELL", "ESSO", "GALP", "PETROBRAS"], "Online Shopping": ["AMAZON", "EBAY", "ZALANDO", "ASOS", "APPLE"], - "Restaurant": ["PIZZA HUT", "MCDONALD'S", "BURGER KING", "LOCAL RESTAURANT"], + "Restaurant": [ + "PIZZA HUT", + "MCDONALD'S", + "BURGER KING", + "LOCAL RESTAURANT", + ], "Salary": ["MONTHLY SALARY", "PAYROLL DEPOSIT", "SALARY PAYMENT"], "ATM Withdrawal": ["ATM WITHDRAWAL", "CASH WITHDRAWAL"], "Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"], } - - specific_descriptions = descriptions.get(transaction_type["description"], [transaction_type["description"]]) + + specific_descriptions = descriptions.get( + transaction_type["description"], [transaction_type["description"]] + ) description = random.choice(specific_descriptions) - + # Create raw transaction (simplified GoCardless format) raw_transaction = { "transactionId": transaction_id, @@ -220,15 +293,17 @@ class SampleDataGenerator: "valueDate": transaction_date.strftime("%Y-%m-%d"), "transactionAmount": { "amount": str(amount), - "currency": account["currency"] + "currency": account["currency"], }, "remittanceInformationUnstructured": description, "bankTransactionCode": "PMNT" if amount < 0 else "RCDT", } - + # Determine status (most are booked, some recent ones might be pending) - status = "pending" if days_ago < 2 and random.random() < 0.1 else "booked" - + status = ( + "pending" if days_ago < 2 and random.random() < 0.1 else "booked" + ) + transaction = { "accountId": account["id"], "transactionId": transaction_id, @@ -242,31 +317,33 @@ class SampleDataGenerator: "transactionStatus": status, "rawTransaction": raw_transaction, } - + account_transactions.append(transaction) current_balance += amount - + # Sort transactions by date for realistic ordering account_transactions.sort(key=lambda x: x["transactionDate"]) transactions.extend(account_transactions) - + return transactions def generate_balances(self, accounts: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Generate sample balances for accounts.""" balances = [] - + for account in accounts: # Calculate balance from transactions (simplified) base_balance = random.uniform(500, 2000) - + balance_types = ["interimAvailable", "closingBooked", "authorised"] - + for balance_type in balance_types: # Add some variation to balance types - variation = random.uniform(-50, 50) if balance_type != "interimAvailable" else 0 + variation = ( + random.uniform(-50, 50) if balance_type != "interimAvailable" else 0 + ) balance_amount = base_balance + variation - + balance = { "account_id": account["id"], "bank": account["institution_id"], @@ -278,75 +355,113 @@ class SampleDataGenerator: "timestamp": datetime.now().isoformat(), } balances.append(balance) - + return balances - def insert_data(self, accounts: List[Dict[str, Any]], transactions: List[Dict[str, Any]], balances: List[Dict[str, Any]]): + def insert_data( + self, + accounts: List[Dict[str, Any]], + transactions: List[Dict[str, Any]], + balances: List[Dict[str, Any]], + ): """Insert generated data into the database.""" conn = sqlite3.connect(str(self.db_path)) cursor = conn.cursor() # Insert accounts for account in accounts: - cursor.execute(""" + cursor.execute( + """ INSERT OR REPLACE INTO accounts (id, institution_id, status, iban, name, currency, created, last_accessed, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - account["id"], account["institution_id"], account["status"], account["iban"], - account["name"], account["currency"], account["created"], - account["last_accessed"], account["last_updated"] - )) + """, + ( + account["id"], + account["institution_id"], + account["status"], + account["iban"], + account["name"], + account["currency"], + account["created"], + account["last_accessed"], + account["last_updated"], + ), + ) # Insert transactions for transaction in transactions: - cursor.execute(""" + cursor.execute( + """ INSERT OR REPLACE INTO transactions (accountId, transactionId, internalTransactionId, institutionId, iban, transactionDate, description, transactionValue, transactionCurrency, transactionStatus, rawTransaction) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - transaction["accountId"], transaction["transactionId"], - transaction["internalTransactionId"], transaction["institutionId"], - transaction["iban"], transaction["transactionDate"], transaction["description"], - transaction["transactionValue"], transaction["transactionCurrency"], - transaction["transactionStatus"], json.dumps(transaction["rawTransaction"]) - )) + """, + ( + transaction["accountId"], + transaction["transactionId"], + transaction["internalTransactionId"], + transaction["institutionId"], + transaction["iban"], + transaction["transactionDate"], + transaction["description"], + transaction["transactionValue"], + transaction["transactionCurrency"], + transaction["transactionStatus"], + json.dumps(transaction["rawTransaction"]), + ), + ) # Insert balances for balance in balances: - cursor.execute(""" + cursor.execute( + """ INSERT INTO balances (account_id, bank, status, iban, amount, currency, type, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, ( - balance["account_id"], balance["bank"], balance["status"], balance["iban"], - balance["amount"], balance["currency"], balance["type"], balance["timestamp"] - )) + """, + ( + balance["account_id"], + balance["bank"], + balance["status"], + balance["iban"], + balance["amount"], + balance["currency"], + balance["type"], + balance["timestamp"], + ), + ) conn.commit() conn.close() - def generate_sample_database(self, num_accounts: int = 3, num_transactions_per_account: int = 50): + def generate_sample_database( + self, num_accounts: int = 3, num_transactions_per_account: int = 50 + ): """Generate complete sample database.""" click.echo(f"šŸ—„ļø Creating sample database at: {self.db_path}") - + self.ensure_database_dir() self.create_tables() - + click.echo(f"šŸ‘„ Generating {num_accounts} sample accounts...") accounts = self.generate_accounts(num_accounts) - - click.echo(f"šŸ’³ Generating {num_transactions_per_account} transactions per account...") - transactions = self.generate_transactions(accounts, num_transactions_per_account) - + + click.echo( + f"šŸ’³ Generating {num_transactions_per_account} transactions per account..." + ) + transactions = self.generate_transactions( + accounts, num_transactions_per_account + ) + click.echo("šŸ’° Generating account balances...") balances = self.generate_balances(accounts) - + click.echo("šŸ’¾ Inserting data into database...") self.insert_data(accounts, transactions, balances) - + # Print summary click.echo("\nāœ… Sample database created successfully!") click.echo(f"šŸ“Š Summary:") @@ -354,11 +469,15 @@ class SampleDataGenerator: click.echo(f" - Transactions: {len(transactions)}") click.echo(f" - Balances: {len(balances)}") click.echo(f" - Database: {self.db_path}") - + # Show account details click.echo(f"\nšŸ“‹ Sample accounts:") for account in accounts: - institution_name = next(inst["name"] for inst in self.institutions if inst["id"] == account["institution_id"]) + institution_name = next( + inst["name"] + for inst in self.institutions + if inst["id"] == account["institution_id"] + ) click.echo(f" - {account['id']} ({institution_name}) - {account['iban']}") @@ -387,31 +506,32 @@ class SampleDataGenerator: ) def main(database: Path, accounts: int, transactions: int, force: bool): """Generate a sample database with realistic financial data for testing Leggen.""" - + # Determine database path if database: db_path = database else: # Use development database by default to avoid overwriting production data import os + env_path = os.environ.get("LEGGEN_DATABASE_PATH") if env_path: db_path = Path(env_path) else: # Default to development database in config directory db_path = path_manager.get_config_dir() / "leggen-dev.db" - + # Check if database exists and ask for confirmation if db_path.exists() and not force: click.echo(f"āš ļø Database already exists: {db_path}") if not click.confirm("Do you want to overwrite it?"): click.echo("Aborted.") return - + # Generate the sample database generator = SampleDataGenerator(db_path) generator.generate_sample_database(accounts, transactions) - + # Show usage instructions click.echo(f"\nšŸš€ Usage instructions:") click.echo(f"To use this sample database with leggen commands:") @@ -423,4 +543,4 @@ def main(database: Path, accounts: int, transactions: int, force: bool): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/conftest.py b/tests/conftest.py index f1821ba..a686cf9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,11 +87,11 @@ def api_client(fastapi_app): def mock_db_path(temp_db_path): """Mock the database path to use temporary database for testing.""" from leggen.utils.paths import path_manager - + # Set the path manager to use the temporary database original_database_path = path_manager._database_path path_manager.set_database_path(temp_db_path) - + try: yield temp_db_path finally: diff --git a/tests/unit/test_analytics_fix.py b/tests/unit/test_analytics_fix.py new file mode 100644 index 0000000..3de236b --- /dev/null +++ b/tests/unit/test_analytics_fix.py @@ -0,0 +1,118 @@ +"""Tests for analytics fixes to ensure all transactions are used in statistics.""" + +import pytest +from datetime import datetime, timedelta +from unittest.mock import Mock, AsyncMock +from fastapi.testclient import TestClient + +from leggend.main import create_app +from leggend.services.database_service import DatabaseService + + +class TestAnalyticsFix: + """Test analytics fixes for transaction limits""" + + @pytest.fixture + def client(self): + app = create_app() + return TestClient(app) + + @pytest.fixture + def mock_database_service(self): + return Mock(spec=DatabaseService) + + @pytest.mark.asyncio + async def test_transaction_stats_uses_all_transactions(self, client, mock_database_service): + """Test that transaction stats endpoint uses all transactions (not limited to 100)""" + # Mock data for 600 transactions (simulating the issue) + mock_transactions = [] + for i in range(600): + mock_transactions.append({ + "transactionId": f"txn-{i}", + "transactionDate": (datetime.now() - timedelta(days=i % 365)).isoformat(), + "description": f"Transaction {i}", + "transactionValue": 10.0 if i % 2 == 0 else -5.0, + "transactionCurrency": "EUR", + "transactionStatus": "booked", + "accountId": f"account-{i % 3}", + }) + + mock_database_service.get_transactions_from_db = AsyncMock(return_value=mock_transactions) + + # Test that the endpoint calls get_transactions_from_db with limit=None + with client as test_client: + # Replace the database service in the route handler + from leggend.api.routes import transactions + original_service = transactions.database_service + transactions.database_service = mock_database_service + + try: + response = test_client.get("/api/v1/transactions/stats?days=365") + + assert response.status_code == 200 + data = response.json() + + # Verify that limit=None was passed to get all transactions + mock_database_service.get_transactions_from_db.assert_called_once() + call_args = mock_database_service.get_transactions_from_db.call_args + assert call_args.kwargs.get("limit") is None, "Stats endpoint should pass limit=None to get all transactions" + + # Verify that the response contains stats for all 600 transactions + assert data["success"] is True + stats = data["data"] + assert stats["total_transactions"] == 600, "Should process all 600 transactions, not just 100" + + # Verify calculations are correct for all transactions + expected_income = sum(txn["transactionValue"] for txn in mock_transactions if txn["transactionValue"] > 0) + expected_expenses = sum(abs(txn["transactionValue"]) for txn in mock_transactions if txn["transactionValue"] < 0) + + assert stats["total_income"] == expected_income + assert stats["total_expenses"] == expected_expenses + + finally: + # Restore original service + transactions.database_service = original_service + + @pytest.mark.asyncio + async def test_analytics_endpoint_returns_all_transactions(self, client, mock_database_service): + """Test that the new analytics endpoint returns all transactions without pagination""" + # Mock data for 600 transactions + mock_transactions = [] + for i in range(600): + mock_transactions.append({ + "transactionId": f"txn-{i}", + "transactionDate": (datetime.now() - timedelta(days=i % 365)).isoformat(), + "description": f"Transaction {i}", + "transactionValue": 10.0 if i % 2 == 0 else -5.0, + "transactionCurrency": "EUR", + "transactionStatus": "booked", + "accountId": f"account-{i % 3}", + }) + + mock_database_service.get_transactions_from_db = AsyncMock(return_value=mock_transactions) + + with client as test_client: + # Replace the database service in the route handler + from leggend.api.routes import transactions + original_service = transactions.database_service + transactions.database_service = mock_database_service + + try: + response = test_client.get("/api/v1/transactions/analytics?days=365") + + assert response.status_code == 200 + data = response.json() + + # Verify that limit=None was passed to get all transactions + mock_database_service.get_transactions_from_db.assert_called_once() + call_args = mock_database_service.get_transactions_from_db.call_args + assert call_args.kwargs.get("limit") is None, "Analytics endpoint should pass limit=None" + + # Verify that all 600 transactions are returned + assert data["success"] is True + transactions_data = data["data"] + assert len(transactions_data) == 600, "Analytics endpoint should return all 600 transactions" + + finally: + # Restore original service + transactions.database_service = original_service \ No newline at end of file diff --git a/tests/unit/test_configurable_paths.py b/tests/unit/test_configurable_paths.py index 13ab5c2..8e84895 100644 --- a/tests/unit/test_configurable_paths.py +++ b/tests/unit/test_configurable_paths.py @@ -12,6 +12,7 @@ from leggen.database.sqlite import persist_balances, get_balances class MockContext: """Mock context for testing.""" + pass @@ -24,15 +25,15 @@ class TestConfigurablePaths: # Reset path manager original_config = path_manager._config_dir original_db = path_manager._database_path - + try: path_manager._config_dir = None path_manager._database_path = None - + # Test defaults config_dir = path_manager.get_config_dir() db_path = path_manager.get_database_path() - + assert config_dir == Path.home() / ".config" / "leggen" assert db_path == Path.home() / ".config" / "leggen" / "leggen.db" finally: @@ -44,22 +45,25 @@ class TestConfigurablePaths: with tempfile.TemporaryDirectory() as tmpdir: test_config_dir = Path(tmpdir) / "test-config" test_db_path = Path(tmpdir) / "test.db" - - with patch.dict(os.environ, { - 'LEGGEN_CONFIG_DIR': str(test_config_dir), - 'LEGGEN_DATABASE_PATH': str(test_db_path) - }): + + with patch.dict( + os.environ, + { + "LEGGEN_CONFIG_DIR": str(test_config_dir), + "LEGGEN_DATABASE_PATH": str(test_db_path), + }, + ): # Reset path manager to pick up environment variables original_config = path_manager._config_dir original_db = path_manager._database_path - + try: path_manager._config_dir = None path_manager._database_path = None - + config_dir = path_manager.get_config_dir() db_path = path_manager.get_database_path() - + assert config_dir == test_config_dir assert db_path == test_db_path finally: @@ -71,20 +75,25 @@ class TestConfigurablePaths: with tempfile.TemporaryDirectory() as tmpdir: test_config_dir = Path(tmpdir) / "explicit-config" test_db_path = Path(tmpdir) / "explicit.db" - + # Save original paths original_config = path_manager._config_dir original_db = path_manager._database_path - + try: # Set explicit paths path_manager.set_config_dir(test_config_dir) path_manager.set_database_path(test_db_path) - + assert path_manager.get_config_dir() == test_config_dir assert path_manager.get_database_path() == test_db_path - assert path_manager.get_config_file_path() == test_config_dir / "config.toml" - assert path_manager.get_auth_file_path() == test_config_dir / "auth.json" + assert ( + path_manager.get_config_file_path() + == test_config_dir / "config.toml" + ) + assert ( + path_manager.get_auth_file_path() == test_config_dir / "auth.json" + ) finally: # Restore original paths path_manager._config_dir = original_config @@ -94,14 +103,14 @@ class TestConfigurablePaths: """Test that database operations work with custom paths.""" with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file: test_db_path = Path(tmp_file.name) - + # Save original database path original_db = path_manager._database_path - + try: # Set custom database path path_manager.set_database_path(test_db_path) - + # Test database operations ctx = MockContext() balance = { @@ -114,20 +123,20 @@ class TestConfigurablePaths: "type": "available", "timestamp": "2023-01-01T00:00:00", } - + # Persist balance persist_balances(ctx, balance) - + # Retrieve balances balances = get_balances() - + assert len(balances) == 1 assert balances[0]["account_id"] == "test-account" assert balances[0]["amount"] == 1000.0 - + # Verify database file exists at custom location assert test_db_path.exists() - + finally: # Restore original path and cleanup path_manager._database_path = original_db @@ -139,24 +148,24 @@ class TestConfigurablePaths: with tempfile.TemporaryDirectory() as tmpdir: test_config_dir = Path(tmpdir) / "new" / "config" / "dir" test_db_path = Path(tmpdir) / "new" / "db" / "dir" / "test.db" - + # Save original paths original_config = path_manager._config_dir original_db = path_manager._database_path - + try: # Set paths to non-existent directories path_manager.set_config_dir(test_config_dir) path_manager.set_database_path(test_db_path) - + # Ensure directories are created path_manager.ensure_config_dir_exists() path_manager.ensure_database_dir_exists() - + assert test_config_dir.exists() assert test_db_path.parent.exists() - + finally: # Restore original paths path_manager._config_dir = original_config - path_manager._database_path = original_db \ No newline at end of file + path_manager._database_path = original_db diff --git a/tests/unit/test_sqlite_database.py b/tests/unit/test_sqlite_database.py index 0d0da67..dcf88d5 100644 --- a/tests/unit/test_sqlite_database.py +++ b/tests/unit/test_sqlite_database.py @@ -23,11 +23,11 @@ def temp_db_path(): def mock_home_db_path(temp_db_path): """Mock the database path to use temp file.""" from leggen.utils.paths import path_manager - + # Set the path manager to use the temporary database original_database_path = path_manager._database_path path_manager.set_database_path(temp_db_path) - + try: yield temp_db_path finally: