Compare commits

...

9 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
0e645d9bae Fix MonthlyTrends date parsing and add AnalyticsTransaction interface
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
d51aa9429e Fix MonthlyTrends dynamic title, remove Period Summary, convert BalanceChart to stacked area chart
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
c8f0a103c6 fix: Resolve all CI failures - linting, typing, and test issues
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
5987a759b8 Remove redundant Analytics Dashboard header section
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
6bfbed8fb6 Fix date parsing and add time period filters to Analytics dashboard
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
b7e4ec4a1b Fix Balance Progress Over Time chart by adding historical balance endpoint
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
35b6d98e6a fix(frontend): Align balance calculation between sidebar and Analytics page
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
3e248f95a8 Address PR feedback: add TODO, remove enhanced-stats, keep stats endpoint
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
e136fc4b75 feat(analytics): Fix transaction limits and improve chart legends
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
20 changed files with 845 additions and 273 deletions

View File

@@ -1,6 +1,6 @@
import {
LineChart,
Line,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
@@ -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,16 +27,43 @@ 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<string, Account>);
// Helper function to get bank name from institution_id
const getBankName = (institutionId: string): string => {
const bankMapping: Record<string, string> = {
'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")
.map((balance) => ({
date: new Date(balance.reference_date).toLocaleDateString(),
date: new Date(balance.reference_date).toLocaleDateString('en-GB'), // DD/MM/YYYY format
balance: balance.balance_amount,
account_id: balance.account_id,
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
.sort((a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime());
// Group by account and aggregate
const accountBalances: { [key: string]: ChartDataPoint[] } = {};
@@ -58,7 +86,7 @@ export default function BalanceChart({ data, className }: BalanceChartProps) {
});
const finalData = Object.values(aggregatedData).sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
(a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime()
);
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
@@ -83,14 +111,16 @@ export default function BalanceChart({ data, className }: BalanceChartProps) {
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={finalData}>
<AreaChart data={finalData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString(undefined, {
// Convert DD/MM/YYYY back to a proper date for formatting
const [day, month, year] = value.split('/');
const date = new Date(year, month - 1, day);
return date.toLocaleDateString('en-GB', {
month: "short",
day: "numeric",
});
@@ -101,25 +131,25 @@ export default function BalanceChart({ data, className }: BalanceChartProps) {
tickFormatter={(value) => `${value.toLocaleString()}`}
/>
<Tooltip
formatter={(value: number) => [
formatter={(value: number, name: string) => [
`${value.toLocaleString()}`,
"Balance",
getAccountDisplayName(name),
]}
labelFormatter={(label) => `Date: ${label}`}
/>
<Legend />
{Object.keys(accountBalances).map((accountId, index) => (
<Line
<Area
key={accountId}
type="monotone"
dataKey={accountId}
stackId="1"
fill={colors[index % colors.length]}
stroke={colors[index % colors.length]}
strokeWidth={2}
dot={{ r: 4 }}
name={`Account ${accountId.split('-')[1]}`}
name={getAccountDisplayName(accountId)}
/>
))}
</LineChart>
</AreaChart>
</ResponsiveContainer>
</div>
</div>

View File

@@ -12,6 +12,7 @@ import apiClient from "../../lib/api";
interface MonthlyTrendsProps {
className?: string;
days?: number;
}
interface MonthlyData {
@@ -31,19 +32,12 @@ interface TooltipProps {
label?: string;
}
export default function MonthlyTrends({ className }: MonthlyTrendsProps) {
// Get transactions for the last 12 months
export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsProps) {
// Get transactions for the specified period using analytics endpoint
const { data: transactions, isLoading } = useQuery({
queryKey: ["transactions", "monthly-trends"],
queryKey: ["transactions", "monthly-trends", days],
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;
return await apiClient.getTransactionsForAnalytics(days);
},
});
@@ -54,12 +48,12 @@ 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]) {
monthlyMap[monthKey] = {
month: date.toLocaleDateString(undefined, {
month: date.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'short'
}),
@@ -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;
@@ -83,10 +77,20 @@ export default function MonthlyTrends({ className }: MonthlyTrendsProps) {
...Object.entries(monthlyMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([, data]) => data)
.slice(-12) // Last 12 months
);
}
// Calculate number of months to display based on days filter
const getMonthsToDisplay = (days: number): number => {
if (days <= 30) return 1;
if (days <= 180) return 6;
if (days <= 365) return 12;
return Math.ceil(days / 30);
};
const monthsToDisplay = getMonthsToDisplay(days);
const displayData = monthlyData.slice(-monthsToDisplay);
if (isLoading) {
return (
<div className={className}>
@@ -100,7 +104,7 @@ export default function MonthlyTrends({ className }: MonthlyTrendsProps) {
);
}
if (monthlyData.length === 0) {
if (displayData.length === 0) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
@@ -129,14 +133,22 @@ export default function MonthlyTrends({ className }: MonthlyTrendsProps) {
return null;
};
// Generate dynamic title based on time period
const getTitle = (days: number): string => {
if (days <= 30) return "Monthly Spending Trends (Last 30 Days)";
if (days <= 180) return "Monthly Spending Trends (Last 6 Months)";
if (days <= 365) return "Monthly Spending Trends (Last 12 Months)";
return `Monthly Spending Trends (Last ${Math.ceil(days / 30)} Months)`;
};
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Monthly Spending Trends (Last 12 Months)
{getTitle(days)}
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<BarChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="month"

View File

@@ -0,0 +1,39 @@
import { Calendar } from "lucide-react";
import type { TimePeriod } from "../../lib/timePeriods";
import { TIME_PERIODS } from "../../lib/timePeriods";
interface TimePeriodFilterProps {
selectedPeriod: TimePeriod;
onPeriodChange: (period: TimePeriod) => void;
className?: string;
}
export default function TimePeriodFilter({
selectedPeriod,
onPeriodChange,
className = "",
}: TimePeriodFilterProps) {
return (
<div className={`flex items-center gap-4 ${className}`}>
<div className="flex items-center gap-2 text-gray-700">
<Calendar size={20} />
<span className="font-medium">Time Period:</span>
</div>
<div className="flex gap-2">
{TIME_PERIODS.map((period) => (
<button
key={period.value}
onClick={() => onPeriodChange(period)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
selectedPeriod.value === period.value
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{period.label}
</button>
))}
</div>
</div>
);
}

View File

@@ -30,17 +30,33 @@ export default function TransactionDistribution({
accounts,
className,
}: TransactionDistributionProps) {
// Helper function to get bank name from institution_id
const getBankName = (institutionId: string): string => {
const bankMapping: Record<string, string> = {
'REVOLUT_REVOLT21': 'Revolut',
'NUBANK_NUPBBR25': 'Nu Pagamentos',
'BANCOBPI_BBPIPTPL': 'Banco BPI',
// TODO: Add more bank 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(
(balance) => balance.balance_type === "closingBooked"
);
const primaryBalance = account.balances?.[0]?.amount || 0;
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
return {
name: account.name || `Account ${account.id.split('-')[1]}`,
value: closingBalance?.amount || 0,
name: getAccountDisplayName(account),
value: primaryBalance,
color: colors[index % colors.length],
};
});

View File

@@ -2,6 +2,7 @@ import axios from "axios";
import type {
Account,
Transaction,
AnalyticsTransaction,
Balance,
ApiResponse,
NotificationSettings,
@@ -54,6 +55,18 @@ export const apiClient = {
return response.data.data;
},
// Get historical balances for balance progression chart
getHistoricalBalances: async (days?: number, accountId?: string): Promise<Balance[]> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
if (accountId) queryParams.append("account_id", accountId);
const response = await api.get<ApiResponse<Balance[]>>(
`/balances/history?${queryParams.toString()}`
);
return response.data.data;
},
// Get balances for specific account
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>(
@@ -154,6 +167,17 @@ export const apiClient = {
);
return response.data.data;
},
// Get all transactions for analytics (no pagination)
getTransactionsForAnalytics: async (days?: number): Promise<AnalyticsTransaction[]> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
`/transactions/analytics?${queryParams.toString()}`
);
return response.data.data;
},
};
export default apiClient;

View File

@@ -0,0 +1,19 @@
export type TimePeriod = {
label: string;
days: number;
value: string;
};
function getDaysFromYearStart(): number {
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1);
const diffTime = now.getTime() - yearStart.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
export const TIME_PERIODS: TimePeriod[] = [
{ label: "Last 30 days", days: 30, value: "30d" },
{ label: "Last 6 months", days: 180, value: "6m" },
{ label: "Year to Date", days: getDaysFromYearStart(), value: "ytd" },
{ label: "Last 365 days", days: 365, value: "365d" },
];

View File

@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import {
CreditCard,
TrendingUp,
@@ -13,12 +14,20 @@ import StatCard from "../components/analytics/StatCard";
import BalanceChart from "../components/analytics/BalanceChart";
import TransactionDistribution from "../components/analytics/TransactionDistribution";
import MonthlyTrends from "../components/analytics/MonthlyTrends";
import TimePeriodFilter from "../components/analytics/TimePeriodFilter";
import type { TimePeriod } from "../lib/timePeriods";
import { TIME_PERIODS } from "../lib/timePeriods";
function AnalyticsDashboard() {
// Default to Last 365 days
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(
TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3]
);
// Fetch analytics data
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ["transaction-stats"],
queryFn: () => apiClient.getTransactionStats(365), // Last year
queryKey: ["transaction-stats", selectedPeriod.days],
queryFn: () => apiClient.getTransactionStats(selectedPeriod.days),
});
const { data: accounts, isLoading: accountsLoading } = useQuery({
@@ -27,8 +36,8 @@ function AnalyticsDashboard() {
});
const { data: balances, isLoading: balancesLoading } = useQuery({
queryKey: ["balances"],
queryFn: () => apiClient.getBalances(),
queryKey: ["historical-balances", selectedPeriod.days],
queryFn: () => apiClient.getHistoricalBalances(selectedPeriod.days),
});
const isLoading = statsLoading || accountsLoading || balancesLoading;
@@ -53,20 +62,18 @@ function AnalyticsDashboard() {
}
const totalBalance = accounts?.reduce((sum, account) => {
const closingBalance = account.balances.find(
(balance) => balance.balance_type === "closingBooked"
);
return sum + (closingBalance?.amount || 0);
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
return (
<div className="p-6 space-y-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">Analytics Dashboard</h1>
<p className="mt-2 text-gray-600">
Overview of your financial data and spending patterns
</p>
</div>
{/* Time Period Filter */}
<TimePeriodFilter
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
className="bg-white rounded-lg shadow p-4 border border-gray-200"
/>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
@@ -126,7 +133,7 @@ function AnalyticsDashboard() {
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
<BalanceChart data={balances || []} />
<BalanceChart data={balances || []} accounts={accounts || []} />
</div>
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
<TransactionDistribution accounts={accounts || []} />
@@ -135,43 +142,8 @@ function AnalyticsDashboard() {
{/* Monthly Trends */}
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
<MonthlyTrends />
<MonthlyTrends days={selectedPeriod.days} />
</div>
{/* Summary Section */}
{stats && (
<div className="bg-blue-50 rounded-lg p-6 border border-blue-200">
<h3 className="text-lg font-medium text-blue-900 mb-4">
Period Summary ({stats.period_days} days)
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-blue-700 font-medium">Booked Transactions</p>
<p className="text-blue-900">{stats.booked_transactions}</p>
</div>
<div>
<p className="text-blue-700 font-medium">Pending Transactions</p>
<p className="text-blue-900">{stats.pending_transactions}</p>
</div>
<div>
<p className="text-blue-700 font-medium">Transaction Ratio</p>
<p className="text-blue-900">
{stats.total_transactions > 0
? `${Math.round(
(stats.booked_transactions / stats.total_transactions) * 100
)}% booked`
: "No transactions"}
</p>
</div>
<div>
<p className="text-blue-700 font-medium">Spend Rate</p>
<p className="text-blue-900">
{((stats.total_expenses || 0) / stats.period_days).toFixed(2)}/day
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -59,6 +59,17 @@ export interface RawTransactionData {
[key: string]: unknown; // Allow additional fields
}
// Type for analytics transaction data
export interface AnalyticsTransaction {
transaction_id: string;
date: string;
description: string;
amount: number;
currency: string;
status: string;
account_id: string;
}
export interface Transaction {
transaction_id: string; // NEW: stable bank-provided transaction ID
internal_transaction_id: string | null; // OLD: unstable GoCardless ID

View File

@@ -3,7 +3,6 @@
import click
from pathlib import Path
from leggen.utils.paths import path_manager
@click.command()
@@ -30,29 +29,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 +65,4 @@ def generate_sample_db(ctx: click.Context, database: Path, accounts: int, transa
# Export the command
generate_sample_db = generate_sample_db
generate_sample_db = generate_sample_db

View File

@@ -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: dict[str, dict[str, float]] = {}
# 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

View File

@@ -1,5 +1,6 @@
"""Centralized path management for Leggen."""
import contextlib
import os
from pathlib import Path
from typing import Optional
@@ -7,32 +8,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,32 +46,28 @@ 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:
with contextlib.suppress(PermissionError, OSError):
db_path.parent.mkdir(parents=True, exist_ok=True)
except (PermissionError, OSError):
# 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 +75,4 @@ class PathManager:
# Global instance for the application
path_manager = PathManager()
path_manager = PathManager()

View File

@@ -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 or 365
)
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,

View File

@@ -202,3 +202,55 @@ async def get_transaction_stats(
raise HTTPException(
status_code=500, detail=f"Failed to get 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

View File

@@ -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())

View File

@@ -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,
@@ -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]]:
@@ -424,7 +440,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")

View File

@@ -1,22 +1,22 @@
#!/usr/bin/env python3
"""Sample database generator for Leggen testing and development."""
import argparse
import json
import random
import sqlite3
import sys
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Any
import click
# Add the project root to the Python path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
import click
from leggen.utils.paths import path_manager
# Import after path setup - this is necessary for the script to work
from leggen.utils.paths import path_manager # noqa: E402
class SampleDataGenerator:
@@ -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,87 +355,129 @@ 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:")
click.echo("📊 Summary:")
click.echo(f" - Accounts: {len(accounts)}")
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:")
click.echo("\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,40 +506,41 @@ 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:")
click.echo("\n🚀 Usage instructions:")
click.echo("To use this sample database with leggen commands:")
click.echo(f" export LEGGEN_DATABASE_PATH={db_path}")
click.echo(f" leggen transactions")
click.echo(f"")
click.echo(f"To use this sample database with leggend API:")
click.echo(" leggen transactions")
click.echo("")
click.echo("To use this sample database with leggend API:")
click.echo(f" leggend --database {db_path}")
if __name__ == "__main__":
main()
main()

View File

@@ -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:

View File

@@ -0,0 +1,104 @@
"""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, patch
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, 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 patch('leggend.api.routes.transactions.database_service', mock_database_service):
app = create_app()
client = TestClient(app)
response = 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
@pytest.mark.asyncio
async def test_analytics_endpoint_returns_all_transactions(self, 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 patch('leggend.api.routes.transactions.database_service', mock_database_service):
app = create_app()
client = TestClient(app)
response = 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"

View File

@@ -12,7 +12,7 @@ from leggen.database.sqlite import persist_balances, get_balances
class MockContext:
"""Mock context for testing."""
pass
@pytest.mark.unit
@@ -24,15 +24,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 +44,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 +74,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 +102,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 +122,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 +147,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
path_manager._database_path = original_db

View File

@@ -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: