mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-28 19:39:16 +00:00
Compare commits
5 Commits
da456b4c80
...
0315ba4bc6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0315ba4bc6 | ||
|
|
476a28d1f4 | ||
|
|
95a5b27e04 | ||
|
|
2c7ecfb912 | ||
|
|
12052ff96f |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
7
opencode.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"permission": {
|
||||||
|
"edit": "ask",
|
||||||
|
"bash": "ask"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user