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

View File

@@ -8,7 +8,9 @@ import {
X,
Home,
List,
BarChart3
BarChart3,
Wifi,
WifiOff
} from 'lucide-react';
import { apiClient } from '../lib/api';
import AccountsOverview from './AccountsOverview';
@@ -28,13 +30,27 @@ export default function Dashboard() {
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 = [
{ name: 'Overview', icon: Home, id: 'overview' as TabType },
{ name: 'Transactions', icon: List, id: 'transactions' 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 (
<div className="flex h-screen bg-gray-100">
@@ -125,8 +141,22 @@ export default function Dashboard() {
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1">
<Activity className="h-4 w-4 text-green-500" />
<span className="text-sm text-gray-600">Connected</span>
{healthLoading ? (
<>
<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>

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"
>
<option value="">All accounts</option>
{accounts?.map((account) => (
<option key={account.id} value={account.id}>
{account.name} ({account.bank_name})
</option>
))}
{accounts?.map((account) => (
<option key={account.id} value={account.id}>
{account.name || 'Unnamed Account'} ({account.institution_id})
</option>
))}
</select>
</div>
@@ -259,10 +259,10 @@ export default function TransactionsList() {
{transaction.description}
</h4>
<div className="text-xs text-gray-500 space-y-1">
{account && (
<p>{account.name} {account.bank_name}</p>
)}
<div className="text-xs text-gray-500 space-y-1">
{account && (
<p>{account.name || 'Unnamed Account'} {account.institution_id}</p>
)}
{(transaction.creditor_name || transaction.debtor_name) && (
<p>

View File

@@ -5,10 +5,22 @@ export function cn(...inputs: ClassValue[]) {
}
export function formatCurrency(amount: number, currency: string = 'EUR'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount);
// Validate currency code - must be 3 letters and a valid ISO 4217 code
const validCurrency = currency && /^[A-Z]{3}$/.test(currency) ? currency : 'EUR';
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 {

View File

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

View File

@@ -16,6 +16,31 @@ def persist_balances(ctx: click.Context, balance: dict):
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)"""
)
# Create the balances table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS balances (
@@ -185,6 +210,7 @@ def get_transactions(
min_amount=None,
max_amount=None,
search=None,
hide_missing_ids=True,
):
"""Get transactions from SQLite database with optional filtering"""
from pathlib import Path
@@ -224,6 +250,11 @@ def get_transactions(
query += " AND description LIKE ?"
params.append(f"%{search}%")
if hide_missing_ids:
query += (
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
)
# Add ordering and pagination
query += " ORDER BY transactionDate DESC"
@@ -372,6 +403,11 @@ def get_transaction_count(account_id=None, **filters):
query += " AND description LIKE ?"
params.append(f"%{filters['search']}%")
if filters.get("hide_missing_ids", True):
query += (
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
)
try:
cursor.execute(query, params)
count = cursor.fetchone()[0]
@@ -381,3 +417,133 @@ def get_transaction_count(account_id=None, **filters):
except Exception as e:
conn.close()
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,
TransactionSummary,
)
from leggend.services.gocardless_service import GoCardlessService
from leggend.services.database_service import DatabaseService
router = APIRouter()
gocardless_service = GoCardlessService()
database_service = DatabaseService()
@router.get("/accounts", response_model=APIResponse)
async def get_all_accounts() -> APIResponse:
"""Get all connected accounts"""
"""Get all connected accounts from database"""
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 = []
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:
account_details = await gocardless_service.get_account_details(
account_id
)
balances_data = await gocardless_service.get_account_balances(
account_id
# Get latest balances from database for this account
balances_data = await database_service.get_balances_from_db(
db_account["id"]
)
# Process balances
balances = []
for balance in balances_data.get("balances", []):
balance_amount = balance["balanceAmount"]
for balance in balances_data:
balances.append(
AccountBalance(
amount=float(balance_amount["amount"]),
currency=balance_amount["currency"],
balance_type=balance["balanceType"],
last_change_date=balance.get("lastChangeDateTime"),
amount=balance["amount"],
currency=balance["currency"],
balance_type=balance["type"],
last_change_date=balance.get("timestamp"),
)
)
accounts.append(
AccountDetails(
id=account_details["id"],
institution_id=account_details["institution_id"],
status=account_details["status"],
iban=account_details.get("iban"),
name=account_details.get("name"),
currency=account_details.get("currency"),
created=account_details["created"],
last_accessed=account_details.get("last_accessed"),
id=db_account["id"],
institution_id=db_account["institution_id"],
status=db_account["status"],
iban=db_account.get("iban"),
name=db_account.get("name"),
currency=db_account.get("currency"),
created=db_account["created"],
last_accessed=db_account.get("last_accessed"),
balances=balances,
)
)
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
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:
@@ -81,46 +79,55 @@ async def get_all_accounts() -> APIResponse:
@router.get("/accounts/{account_id}", response_model=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:
account_details = await gocardless_service.get_account_details(account_id)
balances_data = await gocardless_service.get_account_balances(account_id)
# Get account details from database
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
balances = []
for balance in balances_data.get("balances", []):
balance_amount = balance["balanceAmount"]
for balance in balances_data:
balances.append(
AccountBalance(
amount=float(balance_amount["amount"]),
currency=balance_amount["currency"],
balance_type=balance["balanceType"],
last_change_date=balance.get("lastChangeDateTime"),
amount=balance["amount"],
currency=balance["currency"],
balance_type=balance["type"],
last_change_date=balance.get("timestamp"),
)
)
account = AccountDetails(
id=account_details["id"],
institution_id=account_details["institution_id"],
status=account_details["status"],
iban=account_details.get("iban"),
name=account_details.get("name"),
currency=account_details.get("currency"),
created=account_details["created"],
last_accessed=account_details.get("last_accessed"),
id=db_account["id"],
institution_id=db_account["institution_id"],
status=db_account["status"],
iban=db_account.get("iban"),
name=db_account.get("name"),
currency=db_account.get("currency"),
created=db_account["created"],
last_accessed=db_account.get("last_accessed"),
balances=balances,
)
return APIResponse(
success=True,
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:
logger.error(f"Failed to get account details for {account_id}: {e}")
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
@@ -157,6 +164,56 @@ async def get_account_balances(account_id: str) -> APIResponse:
) 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)
async def get_account_transactions(
account_id: str,

View File

@@ -5,11 +5,9 @@ from loguru import logger
from leggend.api.models.common import APIResponse
from leggend.api.models.accounts import Transaction, TransactionSummary
from leggend.services.gocardless_service import GoCardlessService
from leggend.services.database_service import DatabaseService
router = APIRouter()
gocardless_service = GoCardlessService()
database_service = DatabaseService()
@@ -20,6 +18,9 @@ async def get_all_transactions(
summary_only: bool = Query(
default=True, description="Return transaction summaries only"
),
hide_missing_ids: bool = Query(
default=True, description="Hide transactions without internalTransactionId"
),
date_from: Optional[str] = Query(
default=None, description="Filter from date (YYYY-MM-DD)"
),
@@ -49,6 +50,18 @@ async def get_all_transactions(
min_amount=min_amount,
max_amount=max_amount,
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
@@ -113,6 +126,9 @@ async def get_all_transactions(
async def get_transaction_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"),
hide_missing_ids: bool = Query(
default=True, description="Hide transactions without internalTransactionId"
),
) -> APIResponse:
"""Get transaction statistics for the last N days from database"""
try:
@@ -130,6 +146,7 @@ async def get_transaction_stats(
date_from=date_from,
date_to=date_to,
limit=None, # Get all matching transactions for stats
hide_missing_ids=hide_missing_ids,
)
# Calculate stats

View File

@@ -77,9 +77,32 @@ def create_app() -> FastAPI:
version = "unknown"
return {"message": "Leggend API is running", "version": version}
@app.get("/health")
@app.get("/api/v1/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

View File

@@ -115,6 +115,7 @@ class DatabaseService:
min_amount: Optional[float] = None,
max_amount: Optional[float] = None,
search: Optional[str] = None,
hide_missing_ids: bool = True,
) -> List[Dict[str, Any]]:
"""Get transactions from SQLite database"""
if not self.sqlite_enabled:
@@ -124,13 +125,14 @@ class DatabaseService:
try:
transactions = sqlite_db.get_transactions(
account_id=account_id,
limit=limit,
offset=offset,
limit=limit or 100,
offset=offset or 0,
date_from=date_from,
date_to=date_to,
min_amount=min_amount,
max_amount=max_amount,
search=search,
hide_missing_ids=hide_missing_ids,
)
logger.debug(f"Retrieved {len(transactions)} transactions from database")
return transactions
@@ -146,6 +148,7 @@ class DatabaseService:
min_amount: Optional[float] = None,
max_amount: Optional[float] = None,
search: Optional[str] = None,
hide_missing_ids: bool = True,
) -> int:
"""Get total count of transactions from SQLite database"""
if not self.sqlite_enabled:
@@ -162,7 +165,9 @@ class DatabaseService:
# Remove None values
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}")
return count
except Exception as e:
@@ -203,6 +208,49 @@ class DatabaseService:
logger.error(f"Failed to get account summary from database: {e}")
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(
self, account_id: str, balance_data: Dict[str, Any]
) -> None:
@@ -381,3 +429,23 @@ class DatabaseService:
except Exception as e:
logger.error(f"Failed to persist transactions to SQLite: {e}")
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
)
# Persist account details to database
if account_details:
await self.database.persist_account_details(account_details)
# Get and save balances
balances = await self.gocardless.get_account_balances(account_id)
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
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."""
config_data = {
"gocardless": {
@@ -72,6 +83,28 @@ def api_client(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
def sample_bank_data():
"""Sample bank/institution data for testing."""

View File

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

View File

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

View File

@@ -99,6 +99,7 @@ class TestDatabaseService:
min_amount=None,
max_amount=None,
search=None,
hide_missing_ids=True,
)
async def test_get_transactions_from_db_with_filters(
@@ -129,6 +130,7 @@ class TestDatabaseService:
min_amount=-50.0,
max_amount=0.0,
search="Coffee",
hide_missing_ids=True,
)
async def test_get_transactions_from_db_sqlite_disabled(self, database_service):
@@ -158,7 +160,9 @@ class TestDatabaseService:
)
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):
"""Test getting transaction count with filters."""
@@ -178,6 +182,7 @@ class TestDatabaseService:
date_from="2025-09-01",
min_amount=-100.0,
search="Coffee",
hide_missing_ids=True,
)
async def test_get_transaction_count_from_db_sqlite_disabled(