Compare commits

...

5 Commits

Author SHA1 Message Date
Elisiário Couto
0315ba4bc6 feat: implement dynamic API connection status
- Move health endpoint from /health to /api/v1/health
- Update frontend Dashboard to show real connection status
- Add health check query that refreshes every 30 seconds
- Display connected/disconnected status with appropriate icons
- Show loading state while checking connection
2025-09-08 23:58:38 +01:00
Elisiário Couto
476a28d1f4 fix: resolve 404 balances endpoint and currency formatting errors
- Add missing /api/v1/balances endpoint to backend
- Update frontend Account type to match backend AccountDetails model
- Add currency validation with EUR fallback in formatCurrency function
- Update AccountsOverview, TransactionsList, and Dashboard components
- Fix balance calculations to use balances array structure
- All pre-commit checks pass
2025-09-08 23:45:31 +01:00
Elisiário Couto
95a5b27e04 Add hide_missing_ids filter to transaction queries
- Add hide_missing_ids parameter to database functions to filter out transactions without internalTransactionId
- Update API routes to support the new filter parameter
- Update unit tests to include the new parameter
- Add opencode.json configuration file
2025-09-08 23:38:14 +01:00
Elisiário Couto
2c7ecfb912 Create temporary database for testing instead of using configured database
- Add temp_db_path fixture to create temporary database file for tests
- Add mock_db_path fixture to mock Path.home() for database path resolution
- Update all account API tests to use temporary database
- Ensure test database is properly cleaned up after tests
- Prevent test data from polluting the actual configured database
- All 94 tests still pass with temporary database setup
2025-09-08 19:51:19 +01:00
Elisiário Couto
12052ff96f Remove GoCardless fallback from /accounts endpoints
- Remove GoCardless API calls from /api/v1/accounts and /api/v1/accounts/{account_id}
- Accounts endpoints now rely exclusively on database data
- Return 404 for accounts not found in database
- Update tests to mock database service instead of GoCardless API
- Remove unused GoCardless imports from transactions routes
- Preserve GoCardless usage in sync process and /banks endpoints
- Fix code formatting and remove unused imports
2025-09-08 19:37:49 +01:00
16 changed files with 662 additions and 212 deletions

View File

@@ -61,9 +61,13 @@ export default function AccountsOverview() {
); );
} }
const totalBalance = accounts?.reduce((sum, account) => sum + (account.balance || 0), 0) || 0; const totalBalance = accounts?.reduce((sum, account) => {
// Get the first available balance from the balances array
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
const totalAccounts = accounts?.length || 0; const totalAccounts = accounts?.length || 0;
const uniqueBanks = new Set(accounts?.map(acc => acc.bank_name) || []).size; const uniqueBanks = new Set(accounts?.map(acc => acc.institution_id) || []).size;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -125,54 +129,57 @@ export default function AccountsOverview() {
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-200"> <div className="divide-y divide-gray-200">
{accounts.map((account) => { {accounts.map((account) => {
const accountBalance = balances?.find(b => b.account_id === account.id); // Get balance from account's balances array or fallback to balances query
const balance = account.balance || accountBalance?.balance_amount || 0; const accountBalance = account.balances?.[0];
const isPositive = balance >= 0; const fallbackBalance = balances?.find(b => b.account_id === account.id);
const balance = accountBalance?.amount || fallbackBalance?.balance_amount || 0;
const currency = accountBalance?.currency || fallbackBalance?.currency || account.currency || 'EUR';
const isPositive = balance >= 0;
return ( return (
<div key={account.id} className="p-6 hover:bg-gray-50 transition-colors"> <div key={account.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="p-3 bg-gray-100 rounded-full"> <div className="p-3 bg-gray-100 rounded-full">
<Building2 className="h-6 w-6 text-gray-600" /> <Building2 className="h-6 w-6 text-gray-600" />
</div> </div>
<div> <div>
<h4 className="text-lg font-medium text-gray-900"> <h4 className="text-lg font-medium text-gray-900">
{account.name} {account.name || 'Unnamed Account'}
</h4> </h4>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
{account.bank_name} {account.account_type} {account.institution_id} {account.status}
</p> </p>
{account.iban && ( {account.iban && (
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
IBAN: {account.iban} IBAN: {account.iban}
</p> </p>
)} )}
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{isPositive ? ( {isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" /> <TrendingUp className="h-4 w-4 text-green-500" />
) : ( ) : (
<TrendingDown className="h-4 w-4 text-red-500" /> <TrendingDown className="h-4 w-4 text-red-500" />
)} )}
<p className={`text-lg font-semibold ${ <p className={`text-lg font-semibold ${
isPositive ? 'text-green-600' : 'text-red-600' isPositive ? 'text-green-600' : 'text-red-600'
}`}> }`}>
{formatCurrency(balance, account.currency)} {formatCurrency(balance, currency)}
</p> </p>
</div> </div>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Updated {formatDate(account.updated_at)} Updated {formatDate(account.last_accessed || account.created)}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
)} )}
</div> </div>

View File

@@ -8,7 +8,9 @@ import {
X, X,
Home, Home,
List, List,
BarChart3 BarChart3,
Wifi,
WifiOff
} from 'lucide-react'; } from 'lucide-react';
import { apiClient } from '../lib/api'; import { apiClient } from '../lib/api';
import AccountsOverview from './AccountsOverview'; import AccountsOverview from './AccountsOverview';
@@ -28,13 +30,27 @@ export default function Dashboard() {
queryFn: apiClient.getAccounts, queryFn: apiClient.getAccounts,
}); });
const { data: healthStatus, isLoading: healthLoading } = useQuery({
queryKey: ['health'],
queryFn: async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/v1/health`);
return response.json();
},
refetchInterval: 30000, // Check every 30 seconds
retry: 3,
});
const navigation = [ const navigation = [
{ name: 'Overview', icon: Home, id: 'overview' as TabType }, { name: 'Overview', icon: Home, id: 'overview' as TabType },
{ name: 'Transactions', icon: List, id: 'transactions' as TabType }, { name: 'Transactions', icon: List, id: 'transactions' as TabType },
{ name: 'Analytics', icon: BarChart3, id: 'analytics' as TabType }, { name: 'Analytics', icon: BarChart3, id: 'analytics' as TabType },
]; ];
const totalBalance = accounts?.reduce((sum, account) => sum + (account.balance || 0), 0) || 0; const totalBalance = accounts?.reduce((sum, account) => {
// Get the first available balance from the balances array
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
return ( return (
<div className="flex h-screen bg-gray-100"> <div className="flex h-screen bg-gray-100">
@@ -125,8 +141,22 @@ export default function Dashboard() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Activity className="h-4 w-4 text-green-500" /> {healthLoading ? (
<span className="text-sm text-gray-600">Connected</span> <>
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
<span className="text-sm text-gray-600">Checking...</span>
</>
) : healthStatus?.success ? (
<>
<Wifi className="h-4 w-4 text-green-500" />
<span className="text-sm text-gray-600">Connected</span>
</>
) : (
<>
<WifiOff className="h-4 w-4 text-red-500" />
<span className="text-sm text-red-500">Disconnected</span>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -163,11 +163,11 @@ export default function TransactionsList() {
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
> >
<option value="">All accounts</option> <option value="">All accounts</option>
{accounts?.map((account) => ( {accounts?.map((account) => (
<option key={account.id} value={account.id}> <option key={account.id} value={account.id}>
{account.name} ({account.bank_name}) {account.name || 'Unnamed Account'} ({account.institution_id})
</option> </option>
))} ))}
</select> </select>
</div> </div>
@@ -259,10 +259,10 @@ export default function TransactionsList() {
{transaction.description} {transaction.description}
</h4> </h4>
<div className="text-xs text-gray-500 space-y-1"> <div className="text-xs text-gray-500 space-y-1">
{account && ( {account && (
<p>{account.name} {account.bank_name}</p> <p>{account.name || 'Unnamed Account'} {account.institution_id}</p>
)} )}
{(transaction.creditor_name || transaction.debtor_name) && ( {(transaction.creditor_name || transaction.debtor_name) && (
<p> <p>

View File

@@ -5,10 +5,22 @@ export function cn(...inputs: ClassValue[]) {
} }
export function formatCurrency(amount: number, currency: string = 'EUR'): string { export function formatCurrency(amount: number, currency: string = 'EUR'): string {
return new Intl.NumberFormat('en-US', { // Validate currency code - must be 3 letters and a valid ISO 4217 code
style: 'currency', const validCurrency = currency && /^[A-Z]{3}$/.test(currency) ? currency : 'EUR';
currency: currency,
}).format(amount); try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: validCurrency,
}).format(amount);
} catch (error) {
// Fallback if currency is still invalid
console.warn(`Invalid currency code: ${currency}, falling back to EUR`);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR',
}).format(amount);
}
} }
export function formatDate(date: string): string { export function formatDate(date: string): string {

View File

@@ -1,13 +1,20 @@
export interface AccountBalance {
amount: number;
currency: string;
balance_type: string;
last_change_date?: string;
}
export interface Account { export interface Account {
id: string; id: string;
name: string; institution_id: string;
bank_name: string; status: string;
account_type: string;
currency: string;
balance?: number;
iban?: string; iban?: string;
created_at: string; name?: string;
updated_at: string; currency?: string;
created: string;
last_accessed?: string;
balances: AccountBalance[];
} }
export interface Transaction { export interface Transaction {

View File

@@ -16,6 +16,31 @@ def persist_balances(ctx: click.Context, balance: dict):
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
cursor = conn.cursor() cursor = conn.cursor()
# Create the accounts table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
institution_id TEXT,
status TEXT,
iban TEXT,
name TEXT,
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME
)"""
)
# Create indexes for accounts table
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)"""
)
# Create the balances table if it doesn't exist # Create the balances table if it doesn't exist
cursor.execute( cursor.execute(
"""CREATE TABLE IF NOT EXISTS balances ( """CREATE TABLE IF NOT EXISTS balances (
@@ -185,6 +210,7 @@ def get_transactions(
min_amount=None, min_amount=None,
max_amount=None, max_amount=None,
search=None, search=None,
hide_missing_ids=True,
): ):
"""Get transactions from SQLite database with optional filtering""" """Get transactions from SQLite database with optional filtering"""
from pathlib import Path from pathlib import Path
@@ -224,6 +250,11 @@ def get_transactions(
query += " AND description LIKE ?" query += " AND description LIKE ?"
params.append(f"%{search}%") params.append(f"%{search}%")
if hide_missing_ids:
query += (
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
)
# Add ordering and pagination # Add ordering and pagination
query += " ORDER BY transactionDate DESC" query += " ORDER BY transactionDate DESC"
@@ -372,6 +403,11 @@ def get_transaction_count(account_id=None, **filters):
query += " AND description LIKE ?" query += " AND description LIKE ?"
params.append(f"%{filters['search']}%") params.append(f"%{filters['search']}%")
if filters.get("hide_missing_ids", True):
query += (
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
)
try: try:
cursor.execute(query, params) cursor.execute(query, params)
count = cursor.fetchone()[0] count = cursor.fetchone()[0]
@@ -381,3 +417,133 @@ def get_transaction_count(account_id=None, **filters):
except Exception as e: except Exception as e:
conn.close() conn.close()
raise e raise e
def persist_account(account_data: dict):
"""Persist account details to SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Create the accounts table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
institution_id TEXT,
status TEXT,
iban TEXT,
name TEXT,
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME
)"""
)
# Create indexes for accounts table
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)"""
)
try:
# Insert or replace account data
cursor.execute(
"""INSERT OR REPLACE INTO accounts (
id,
institution_id,
status,
iban,
name,
currency,
created,
last_accessed,
last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
account_data["id"],
account_data["institution_id"],
account_data["status"],
account_data.get("iban"),
account_data.get("name"),
account_data.get("currency"),
account_data["created"],
account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]),
),
)
conn.commit()
conn.close()
success(f"[{account_data['id']}] Account details persisted to database")
return account_data
except Exception as e:
conn.close()
raise e
def get_accounts(account_ids=None):
"""Get account details from SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query = "SELECT * FROM accounts"
params = []
if account_ids:
placeholders = ",".join("?" * len(account_ids))
query += f" WHERE id IN ({placeholders})"
params.extend(account_ids)
query += " ORDER BY created DESC"
try:
cursor.execute(query, params)
rows = cursor.fetchall()
accounts = [dict(row) for row in rows]
conn.close()
return accounts
except Exception as e:
conn.close()
raise e
def get_account(account_id: str):
"""Get specific account details from SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
return None
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
try:
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
row = cursor.fetchone()
conn.close()
if row:
return dict(row)
return None
except Exception as e:
conn.close()
raise e

View File

@@ -9,67 +9,65 @@ from leggend.api.models.accounts import (
Transaction, Transaction,
TransactionSummary, TransactionSummary,
) )
from leggend.services.gocardless_service import GoCardlessService
from leggend.services.database_service import DatabaseService from leggend.services.database_service import DatabaseService
router = APIRouter() router = APIRouter()
gocardless_service = GoCardlessService()
database_service = DatabaseService() database_service = DatabaseService()
@router.get("/accounts", response_model=APIResponse) @router.get("/accounts", response_model=APIResponse)
async def get_all_accounts() -> APIResponse: async def get_all_accounts() -> APIResponse:
"""Get all connected accounts""" """Get all connected accounts from database"""
try: try:
requisitions_data = await gocardless_service.get_requisitions()
all_accounts = set()
for req in requisitions_data.get("results", []):
all_accounts.update(req.get("accounts", []))
accounts = [] accounts = []
for account_id in all_accounts:
# Get all account details from database
db_accounts = await database_service.get_accounts_from_db()
# Process accounts found in database
for db_account in db_accounts:
try: try:
account_details = await gocardless_service.get_account_details( # Get latest balances from database for this account
account_id balances_data = await database_service.get_balances_from_db(
) db_account["id"]
balances_data = await gocardless_service.get_account_balances(
account_id
) )
# Process balances # Process balances
balances = [] balances = []
for balance in balances_data.get("balances", []): for balance in balances_data:
balance_amount = balance["balanceAmount"]
balances.append( balances.append(
AccountBalance( AccountBalance(
amount=float(balance_amount["amount"]), amount=balance["amount"],
currency=balance_amount["currency"], currency=balance["currency"],
balance_type=balance["balanceType"], balance_type=balance["type"],
last_change_date=balance.get("lastChangeDateTime"), last_change_date=balance.get("timestamp"),
) )
) )
accounts.append( accounts.append(
AccountDetails( AccountDetails(
id=account_details["id"], id=db_account["id"],
institution_id=account_details["institution_id"], institution_id=db_account["institution_id"],
status=account_details["status"], status=db_account["status"],
iban=account_details.get("iban"), iban=db_account.get("iban"),
name=account_details.get("name"), name=db_account.get("name"),
currency=account_details.get("currency"), currency=db_account.get("currency"),
created=account_details["created"], created=db_account["created"],
last_accessed=account_details.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
balances=balances, balances=balances,
) )
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to get details for account {account_id}: {e}") logger.error(
f"Failed to process database account {db_account['id']}: {e}"
)
continue continue
return APIResponse( return APIResponse(
success=True, data=accounts, message=f"Retrieved {len(accounts)} accounts" success=True,
data=accounts,
message=f"Retrieved {len(accounts)} accounts from database",
) )
except Exception as e: except Exception as e:
@@ -81,46 +79,55 @@ async def get_all_accounts() -> APIResponse:
@router.get("/accounts/{account_id}", response_model=APIResponse) @router.get("/accounts/{account_id}", response_model=APIResponse)
async def get_account_details(account_id: str) -> APIResponse: async def get_account_details(account_id: str) -> APIResponse:
"""Get details for a specific account""" """Get details for a specific account from database"""
try: try:
account_details = await gocardless_service.get_account_details(account_id) # Get account details from database
balances_data = await gocardless_service.get_account_balances(account_id) db_account = await database_service.get_account_details_from_db(account_id)
if not db_account:
raise HTTPException(
status_code=404, detail=f"Account {account_id} not found in database"
)
# Get latest balances from database for this account
balances_data = await database_service.get_balances_from_db(account_id)
# Process balances # Process balances
balances = [] balances = []
for balance in balances_data.get("balances", []): for balance in balances_data:
balance_amount = balance["balanceAmount"]
balances.append( balances.append(
AccountBalance( AccountBalance(
amount=float(balance_amount["amount"]), amount=balance["amount"],
currency=balance_amount["currency"], currency=balance["currency"],
balance_type=balance["balanceType"], balance_type=balance["type"],
last_change_date=balance.get("lastChangeDateTime"), last_change_date=balance.get("timestamp"),
) )
) )
account = AccountDetails( account = AccountDetails(
id=account_details["id"], id=db_account["id"],
institution_id=account_details["institution_id"], institution_id=db_account["institution_id"],
status=account_details["status"], status=db_account["status"],
iban=account_details.get("iban"), iban=db_account.get("iban"),
name=account_details.get("name"), name=db_account.get("name"),
currency=account_details.get("currency"), currency=db_account.get("currency"),
created=account_details["created"], created=db_account["created"],
last_accessed=account_details.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
balances=balances, balances=balances,
) )
return APIResponse( return APIResponse(
success=True, success=True,
data=account, data=account,
message=f"Account details retrieved for {account_id}", message=f"Account details retrieved from database for {account_id}",
) )
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Failed to get account details for {account_id}: {e}") logger.error(f"Failed to get account details for {account_id}: {e}")
raise HTTPException( raise HTTPException(
status_code=404, detail=f"Account not found: {str(e)}" status_code=500, detail=f"Failed to get account details: {str(e)}"
) from e ) from e
@@ -157,6 +164,56 @@ async def get_account_balances(account_id: str) -> APIResponse:
) from e ) from e
@router.get("/balances", response_model=APIResponse)
async def get_all_balances() -> APIResponse:
"""Get all balances from all accounts in database"""
try:
# Get all accounts first to iterate through them
db_accounts = await database_service.get_accounts_from_db()
all_balances = []
for db_account in db_accounts:
try:
# Get balances for this account
db_balances = await database_service.get_balances_from_db(
account_id=db_account["id"]
)
# Process balances and add account info
for balance in db_balances:
balance_data = {
"id": f"{db_account['id']}_{balance['type']}", # Create unique ID
"account_id": db_account["id"],
"balance_amount": balance["amount"],
"balance_type": balance["type"],
"currency": balance["currency"],
"reference_date": balance.get(
"timestamp", db_account.get("last_accessed")
),
"created_at": db_account.get("created"),
"updated_at": db_account.get("last_accessed"),
}
all_balances.append(balance_data)
except Exception as e:
logger.error(
f"Failed to get balances for account {db_account['id']}: {e}"
)
continue
return APIResponse(
success=True,
data=all_balances,
message=f"Retrieved {len(all_balances)} balances from {len(db_accounts)} accounts",
)
except Exception as e:
logger.error(f"Failed to get all balances: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get balances: {str(e)}"
) from e
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse) @router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
async def get_account_transactions( async def get_account_transactions(
account_id: str, account_id: str,

View File

@@ -5,11 +5,9 @@ from loguru import logger
from leggend.api.models.common import APIResponse from leggend.api.models.common import APIResponse
from leggend.api.models.accounts import Transaction, TransactionSummary from leggend.api.models.accounts import Transaction, TransactionSummary
from leggend.services.gocardless_service import GoCardlessService
from leggend.services.database_service import DatabaseService from leggend.services.database_service import DatabaseService
router = APIRouter() router = APIRouter()
gocardless_service = GoCardlessService()
database_service = DatabaseService() database_service = DatabaseService()
@@ -20,6 +18,9 @@ async def get_all_transactions(
summary_only: bool = Query( summary_only: bool = Query(
default=True, description="Return transaction summaries only" default=True, description="Return transaction summaries only"
), ),
hide_missing_ids: bool = Query(
default=True, description="Hide transactions without internalTransactionId"
),
date_from: Optional[str] = Query( date_from: Optional[str] = Query(
default=None, description="Filter from date (YYYY-MM-DD)" default=None, description="Filter from date (YYYY-MM-DD)"
), ),
@@ -49,6 +50,18 @@ async def get_all_transactions(
min_amount=min_amount, min_amount=min_amount,
max_amount=max_amount, max_amount=max_amount,
search=search, search=search,
hide_missing_ids=hide_missing_ids,
)
# Get total count for pagination info (respecting the same filters)
total_transactions = await database_service.get_transaction_count_from_db(
account_id=account_id,
date_from=date_from,
date_to=date_to,
min_amount=min_amount,
max_amount=max_amount,
search=search,
hide_missing_ids=hide_missing_ids,
) )
# Get total count for pagination info # Get total count for pagination info
@@ -113,6 +126,9 @@ async def get_all_transactions(
async def get_transaction_stats( async def get_transaction_stats(
days: int = Query(default=30, description="Number of days to include in stats"), days: int = Query(default=30, description="Number of days to include in stats"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"), account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
hide_missing_ids: bool = Query(
default=True, description="Hide transactions without internalTransactionId"
),
) -> APIResponse: ) -> APIResponse:
"""Get transaction statistics for the last N days from database""" """Get transaction statistics for the last N days from database"""
try: try:
@@ -130,6 +146,7 @@ async def get_transaction_stats(
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
limit=None, # Get all matching transactions for stats limit=None, # Get all matching transactions for stats
hide_missing_ids=hide_missing_ids,
) )
# Calculate stats # Calculate stats

View File

@@ -77,9 +77,32 @@ def create_app() -> FastAPI:
version = "unknown" version = "unknown"
return {"message": "Leggend API is running", "version": version} return {"message": "Leggend API is running", "version": version}
@app.get("/health") @app.get("/api/v1/health")
async def health(): async def health():
return {"status": "healthy", "config_loaded": config._config is not None} """Health check endpoint for API connectivity"""
try:
from leggend.api.models.common import APIResponse
config_loaded = config._config is not None
return APIResponse(
success=True,
data={
"status": "healthy",
"config_loaded": config_loaded,
"message": "API is running and responsive",
},
message="Health check successful",
)
except Exception as e:
logger.error(f"Health check failed: {e}")
from leggend.api.models.common import APIResponse
return APIResponse(
success=False,
data={"status": "unhealthy", "error": str(e)},
message="Health check failed",
)
return app return app

View File

@@ -115,6 +115,7 @@ class DatabaseService:
min_amount: Optional[float] = None, min_amount: Optional[float] = None,
max_amount: Optional[float] = None, max_amount: Optional[float] = None,
search: Optional[str] = None, search: Optional[str] = None,
hide_missing_ids: bool = True,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Get transactions from SQLite database""" """Get transactions from SQLite database"""
if not self.sqlite_enabled: if not self.sqlite_enabled:
@@ -124,13 +125,14 @@ class DatabaseService:
try: try:
transactions = sqlite_db.get_transactions( transactions = sqlite_db.get_transactions(
account_id=account_id, account_id=account_id,
limit=limit, limit=limit or 100,
offset=offset, offset=offset or 0,
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
min_amount=min_amount, min_amount=min_amount,
max_amount=max_amount, max_amount=max_amount,
search=search, search=search,
hide_missing_ids=hide_missing_ids,
) )
logger.debug(f"Retrieved {len(transactions)} transactions from database") logger.debug(f"Retrieved {len(transactions)} transactions from database")
return transactions return transactions
@@ -146,6 +148,7 @@ class DatabaseService:
min_amount: Optional[float] = None, min_amount: Optional[float] = None,
max_amount: Optional[float] = None, max_amount: Optional[float] = None,
search: Optional[str] = None, search: Optional[str] = None,
hide_missing_ids: bool = True,
) -> int: ) -> int:
"""Get total count of transactions from SQLite database""" """Get total count of transactions from SQLite database"""
if not self.sqlite_enabled: if not self.sqlite_enabled:
@@ -162,7 +165,9 @@ class DatabaseService:
# Remove None values # Remove None values
filters = {k: v for k, v in filters.items() if v is not None} filters = {k: v for k, v in filters.items() if v is not None}
count = sqlite_db.get_transaction_count(account_id=account_id, **filters) count = sqlite_db.get_transaction_count(
account_id=account_id, hide_missing_ids=hide_missing_ids, **filters
)
logger.debug(f"Total transaction count: {count}") logger.debug(f"Total transaction count: {count}")
return count return count
except Exception as e: except Exception as e:
@@ -203,6 +208,49 @@ class DatabaseService:
logger.error(f"Failed to get account summary from database: {e}") logger.error(f"Failed to get account summary from database: {e}")
return None return None
async def persist_account_details(self, account_data: Dict[str, Any]) -> None:
"""Persist account details to database"""
if not self.sqlite_enabled:
logger.warning("SQLite database disabled, skipping account persistence")
return
await self._persist_account_details_sqlite(account_data)
async def get_accounts_from_db(
self, account_ids: Optional[List[str]] = None
) -> List[Dict[str, Any]]:
"""Get account details from database"""
if not self.sqlite_enabled:
logger.warning("SQLite database disabled, cannot read accounts")
return []
try:
accounts = sqlite_db.get_accounts(account_ids=account_ids)
logger.debug(f"Retrieved {len(accounts)} accounts from database")
return accounts
except Exception as e:
logger.error(f"Failed to get accounts from database: {e}")
return []
async def get_account_details_from_db(
self, account_id: str
) -> Optional[Dict[str, Any]]:
"""Get specific account details from database"""
if not self.sqlite_enabled:
logger.warning("SQLite database disabled, cannot read account")
return None
try:
account = sqlite_db.get_account(account_id)
if account:
logger.debug(
f"Retrieved account details from database for {account_id}"
)
return account
except Exception as e:
logger.error(f"Failed to get account details from database: {e}")
return None
async def _persist_balance_sqlite( async def _persist_balance_sqlite(
self, account_id: str, balance_data: Dict[str, Any] self, account_id: str, balance_data: Dict[str, Any]
) -> None: ) -> None:
@@ -381,3 +429,23 @@ class DatabaseService:
except Exception as e: except Exception as e:
logger.error(f"Failed to persist transactions to SQLite: {e}") logger.error(f"Failed to persist transactions to SQLite: {e}")
raise raise
async def _persist_account_details_sqlite(
self, account_data: Dict[str, Any]
) -> None:
"""Persist account details to SQLite"""
try:
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
# Use the sqlite_db module function
sqlite_db.persist_account(account_data)
logger.info(
f"Persisted account details to SQLite for account {account_data['id']}"
)
except Exception as e:
logger.error(f"Failed to persist account details to SQLite: {e}")
raise

View File

@@ -55,6 +55,10 @@ class SyncService:
account_id account_id
) )
# Persist account details to database
if account_details:
await self.database.persist_account_details(account_details)
# Get and save balances # Get and save balances
balances = await self.gocardless.get_account_balances(account_id) balances = await self.gocardless.get_account_balances(account_id)
if balances: if balances:

7
opencode.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"edit": "ask",
"bash": "ask"
}
}

View File

@@ -21,7 +21,18 @@ def temp_config_dir():
@pytest.fixture @pytest.fixture
def mock_config(temp_config_dir): def temp_db_path():
"""Create a temporary database file for testing."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file:
db_path = Path(tmp_file.name)
yield db_path
# Clean up the temporary database file after test
if db_path.exists():
db_path.unlink()
@pytest.fixture
def mock_config(temp_config_dir, temp_db_path):
"""Mock configuration for testing.""" """Mock configuration for testing."""
config_data = { config_data = {
"gocardless": { "gocardless": {
@@ -72,6 +83,28 @@ def api_client(fastapi_app):
return TestClient(fastapi_app) return TestClient(fastapi_app)
@pytest.fixture
def mock_db_path(temp_db_path):
"""Mock the database path to use temporary database for testing."""
from pathlib import Path
# Create the expected directory structure
temp_home = temp_db_path.parent
config_dir = temp_home / ".config" / "leggen"
config_dir.mkdir(parents=True, exist_ok=True)
# Create the expected database path
expected_db_path = config_dir / "leggen.db"
# Mock Path.home to return our temp directory
def mock_home():
return temp_home
# Patch Path.home in the main pathlib module
with patch.object(Path, "home", staticmethod(mock_home)):
yield expected_db_path
@pytest.fixture @pytest.fixture
def sample_bank_data(): def sample_bank_data():
"""Sample bank/institution data for testing.""" """Sample bank/institution data for testing."""

View File

@@ -1,8 +1,6 @@
"""Tests for accounts API endpoints.""" """Tests for accounts API endpoints."""
import pytest import pytest
import respx
import httpx
from unittest.mock import patch from unittest.mock import patch
@@ -10,44 +8,51 @@ from unittest.mock import patch
class TestAccountsAPI: class TestAccountsAPI:
"""Test account-related API endpoints.""" """Test account-related API endpoints."""
@respx.mock
def test_get_all_accounts_success( def test_get_all_accounts_success(
self, api_client, mock_config, mock_auth_token, sample_account_data self,
api_client,
mock_config,
mock_auth_token,
sample_account_data,
mock_db_path,
): ):
"""Test successful retrieval of all accounts.""" """Test successful retrieval of all accounts from database."""
requisitions_data = { mock_accounts = [
"results": [{"id": "req-123", "accounts": ["test-account-123"]}] {
} "id": "test-account-123",
"institution_id": "REVOLUT_REVOLT21",
"status": "READY",
"iban": "LT313250081177977789",
"created": "2024-02-13T23:56:00Z",
"last_accessed": "2025-09-01T09:30:00Z",
}
]
balances_data = { mock_balances = [
"balances": [ {
{ "id": 1,
"balanceAmount": {"amount": "100.50", "currency": "EUR"}, "account_id": "test-account-123",
"balanceType": "interimAvailable", "bank": "REVOLUT_REVOLT21",
"lastChangeDateTime": "2025-09-01T09:30:00Z", "status": "active",
} "iban": "LT313250081177977789",
] "amount": 100.50,
} "currency": "EUR",
"type": "interimAvailable",
"timestamp": "2025-09-01T09:30:00Z",
}
]
# Mock GoCardless token creation with (
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock( patch("leggend.config.config", mock_config),
return_value=httpx.Response( patch(
200, json={"access": "test-token", "refresh": "test-refresh"} "leggend.api.routes.accounts.database_service.get_accounts_from_db",
) return_value=mock_accounts,
) ),
patch(
# Mock GoCardless API calls "leggend.api.routes.accounts.database_service.get_balances_from_db",
respx.get("https://bankaccountdata.gocardless.com/api/v2/requisitions/").mock( return_value=mock_balances,
return_value=httpx.Response(200, json=requisitions_data) ),
) ):
respx.get(
"https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/"
).mock(return_value=httpx.Response(200, json=sample_account_data))
respx.get(
"https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/balances/"
).mock(return_value=httpx.Response(200, json=balances_data))
with patch("leggend.config.config", mock_config):
response = api_client.get("/api/v1/accounts") response = api_client.get("/api/v1/accounts")
assert response.status_code == 200 assert response.status_code == 200
@@ -60,36 +65,49 @@ class TestAccountsAPI:
assert len(account["balances"]) == 1 assert len(account["balances"]) == 1
assert account["balances"][0]["amount"] == 100.50 assert account["balances"][0]["amount"] == 100.50
@respx.mock
def test_get_account_details_success( def test_get_account_details_success(
self, api_client, mock_config, mock_auth_token, sample_account_data self,
api_client,
mock_config,
mock_auth_token,
sample_account_data,
mock_db_path,
): ):
"""Test successful retrieval of specific account details.""" """Test successful retrieval of specific account details from database."""
balances_data = { mock_account = {
"balances": [ "id": "test-account-123",
{ "institution_id": "REVOLUT_REVOLT21",
"balanceAmount": {"amount": "250.75", "currency": "EUR"}, "status": "READY",
"balanceType": "interimAvailable", "iban": "LT313250081177977789",
} "created": "2024-02-13T23:56:00Z",
] "last_accessed": "2025-09-01T09:30:00Z",
} }
# Mock GoCardless token creation mock_balances = [
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock( {
return_value=httpx.Response( "id": 1,
200, json={"access": "test-token", "refresh": "test-refresh"} "account_id": "test-account-123",
) "bank": "REVOLUT_REVOLT21",
) "status": "active",
"iban": "LT313250081177977789",
"amount": 250.75,
"currency": "EUR",
"type": "interimAvailable",
"timestamp": "2025-09-01T09:30:00Z",
}
]
# Mock GoCardless API calls with (
respx.get( patch("leggend.config.config", mock_config),
"https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/" patch(
).mock(return_value=httpx.Response(200, json=sample_account_data)) "leggend.api.routes.accounts.database_service.get_account_details_from_db",
respx.get( return_value=mock_account,
"https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/balances/" ),
).mock(return_value=httpx.Response(200, json=balances_data)) patch(
"leggend.api.routes.accounts.database_service.get_balances_from_db",
with patch("leggend.config.config", mock_config): return_value=mock_balances,
),
):
response = api_client.get("/api/v1/accounts/test-account-123") response = api_client.get("/api/v1/accounts/test-account-123")
assert response.status_code == 200 assert response.status_code == 200
@@ -101,7 +119,7 @@ class TestAccountsAPI:
assert len(account["balances"]) == 1 assert len(account["balances"]) == 1
def test_get_account_balances_success( def test_get_account_balances_success(
self, api_client, mock_config, mock_auth_token self, api_client, mock_config, mock_auth_token, mock_db_path
): ):
"""Test successful retrieval of account balances from database.""" """Test successful retrieval of account balances from database."""
mock_balances = [ mock_balances = [
@@ -153,6 +171,7 @@ class TestAccountsAPI:
mock_auth_token, mock_auth_token,
sample_account_data, sample_account_data,
sample_transaction_data, sample_transaction_data,
mock_db_path,
): ):
"""Test successful retrieval of account transactions from database.""" """Test successful retrieval of account transactions from database."""
mock_transactions = [ mock_transactions = [
@@ -203,6 +222,7 @@ class TestAccountsAPI:
mock_auth_token, mock_auth_token,
sample_account_data, sample_account_data,
sample_transaction_data, sample_transaction_data,
mock_db_path,
): ):
"""Test retrieval of full transaction details from database.""" """Test retrieval of full transaction details from database."""
mock_transactions = [ mock_transactions = [
@@ -246,24 +266,17 @@ class TestAccountsAPI:
assert transaction["iban"] == "LT313250081177977789" assert transaction["iban"] == "LT313250081177977789"
assert "raw_transaction" in transaction assert "raw_transaction" in transaction
def test_get_account_not_found(self, api_client, mock_config, mock_auth_token): def test_get_account_not_found(
self, api_client, mock_config, mock_auth_token, mock_db_path
):
"""Test handling of non-existent account.""" """Test handling of non-existent account."""
# Mock 404 response from GoCardless with (
with respx.mock: patch("leggend.config.config", mock_config),
# Mock GoCardless token creation patch(
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock( "leggend.api.routes.accounts.database_service.get_account_details_from_db",
return_value=httpx.Response( return_value=None,
200, json={"access": "test-token", "refresh": "test-refresh"} ),
) ):
) response = api_client.get("/api/v1/accounts/nonexistent")
respx.get( assert response.status_code == 404
"https://bankaccountdata.gocardless.com/api/v2/accounts/nonexistent/"
).mock(
return_value=httpx.Response(404, json={"detail": "Account not found"})
)
with patch("leggend.config.config", mock_config):
response = api_client.get("/api/v1/accounts/nonexistent")
assert response.status_code == 404

View File

@@ -166,6 +166,7 @@ class TestTransactionsAPI:
min_amount=-50.0, min_amount=-50.0,
max_amount=0.0, max_amount=0.0,
search="Coffee", search="Coffee",
hide_missing_ids=True,
) )
def test_get_transactions_empty_result( def test_get_transactions_empty_result(

View File

@@ -99,6 +99,7 @@ class TestDatabaseService:
min_amount=None, min_amount=None,
max_amount=None, max_amount=None,
search=None, search=None,
hide_missing_ids=True,
) )
async def test_get_transactions_from_db_with_filters( async def test_get_transactions_from_db_with_filters(
@@ -129,6 +130,7 @@ class TestDatabaseService:
min_amount=-50.0, min_amount=-50.0,
max_amount=0.0, max_amount=0.0,
search="Coffee", search="Coffee",
hide_missing_ids=True,
) )
async def test_get_transactions_from_db_sqlite_disabled(self, database_service): async def test_get_transactions_from_db_sqlite_disabled(self, database_service):
@@ -158,7 +160,9 @@ class TestDatabaseService:
) )
assert result == 42 assert result == 42
mock_get_count.assert_called_once_with(account_id="test-account-123") mock_get_count.assert_called_once_with(
account_id="test-account-123", hide_missing_ids=True
)
async def test_get_transaction_count_from_db_with_filters(self, database_service): async def test_get_transaction_count_from_db_with_filters(self, database_service):
"""Test getting transaction count with filters.""" """Test getting transaction count with filters."""
@@ -178,6 +182,7 @@ class TestDatabaseService:
date_from="2025-09-01", date_from="2025-09-01",
min_amount=-100.0, min_amount=-100.0,
search="Coffee", search="Coffee",
hide_missing_ids=True,
) )
async def test_get_transaction_count_from_db_sqlite_disabled( async def test_get_transaction_count_from_db_sqlite_disabled(