mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-29 03:59:07 +00:00
Compare commits
3 Commits
a8f704129b
...
d3a1696d4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a1696d4d | ||
|
|
24792744f9 | ||
|
|
b9ca74e7e6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -165,3 +165,4 @@ leggen.db
|
||||
*.db
|
||||
config.toml
|
||||
.claude/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
"mcp"
|
||||
]
|
||||
},
|
||||
"browsermcp": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@browsermcp/mcp@latest"
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,4 +1,46 @@
|
||||
|
||||
## 2025.9.22 (2025/09/24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api:** Add automatic token refresh on 401 errors in GoCardless service. ([36d698f7](https://github.com/elisiariocouto/leggen/commit/36d698f7ce05c7db0e4b07dd07979de2c70b053e))
|
||||
- **api:** Fix banks API test fixtures to match GoCardless response format. ([24792744](https://github.com/elisiariocouto/leggen/commit/24792744f9660063e1a3abb9ed8e925fea9a5e60))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add separate sync failure notifications. ([e4e3f885](https://github.com/elisiariocouto/leggen/commit/e4e3f885eab1d45b0e10465ca04eb3f74e9c5a4d))
|
||||
- **api:** Add bank logo support and fix banks endpoint type errors. ([b9ca74e7](https://github.com/elisiariocouto/leggen/commit/b9ca74e7e67c3877728b749a42f15f0c0d906561))
|
||||
- **frontend:** Improve System page and TransactionsTable UX. ([62cd55e4](https://github.com/elisiariocouto/leggen/commit/62cd55e48fff7c2f5db9dd8230a7bd500e8f6eed))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Add pre-commit instructions to AGENTS.md. ([a8f70412](https://github.com/elisiariocouto/leggen/commit/a8f704129b2453e604cf2ab776791ba1e91e6fc7))
|
||||
|
||||
|
||||
|
||||
## 2025.9.22 (2025/09/24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api:** Add automatic token refresh on 401 errors in GoCardless service. ([36d698f7](https://github.com/elisiariocouto/leggen/commit/36d698f7ce05c7db0e4b07dd07979de2c70b053e))
|
||||
- **api:** Fix banks API test fixtures to match GoCardless response format. ([24792744](https://github.com/elisiariocouto/leggen/commit/24792744f9660063e1a3abb9ed8e925fea9a5e60))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add separate sync failure notifications. ([e4e3f885](https://github.com/elisiariocouto/leggen/commit/e4e3f885eab1d45b0e10465ca04eb3f74e9c5a4d))
|
||||
- **api:** Add bank logo support and fix banks endpoint type errors. ([b9ca74e7](https://github.com/elisiariocouto/leggen/commit/b9ca74e7e67c3877728b749a42f15f0c0d906561))
|
||||
- **frontend:** Improve System page and TransactionsTable UX. ([62cd55e4](https://github.com/elisiariocouto/leggen/commit/62cd55e48fff7c2f5db9dd8230a7bd500e8f6eed))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Add pre-commit instructions to AGENTS.md. ([a8f70412](https://github.com/elisiariocouto/leggen/commit/a8f704129b2453e604cf2ab776791ba1e91e6fc7))
|
||||
|
||||
|
||||
|
||||
## 2025.9.21 (2025/09/22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -78,6 +78,7 @@ export default function AccountSettings() {
|
||||
|
||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -194,8 +195,20 @@ export default function AccountSettings() {
|
||||
{/* Mobile layout - stack vertically */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
|
||||
{account.logo && !failedImages.has(account.id) ? (
|
||||
<img
|
||||
src={account.logo}
|
||||
alt={`${account.institution_id} logo`}
|
||||
className="w-full h-full object-contain"
|
||||
onError={() => {
|
||||
console.warn(`Failed to load bank logo for ${account.institution_id}: ${account.logo}`);
|
||||
setFailedImages(prev => new Set([...prev, account.id]));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingAccountId === account.id ? (
|
||||
|
||||
@@ -79,6 +79,7 @@ const getStatusIndicator = (status: string) => {
|
||||
export default function Settings() {
|
||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -280,8 +281,20 @@ export default function Settings() {
|
||||
{/* Mobile layout - stack vertically */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
|
||||
{account.logo && !failedImages.has(account.id) ? (
|
||||
<img
|
||||
src={account.logo}
|
||||
alt={`${account.institution_id} logo`}
|
||||
className="w-6 h-6 sm:w-8 sm:h-8 object-contain"
|
||||
onError={() => {
|
||||
console.warn(`Failed to load bank logo for ${account.institution_id}: ${account.logo}`);
|
||||
setFailedImages(prev => new Set([...prev, account.id]));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingAccountId === account.id ? (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface Account {
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
currency?: string;
|
||||
logo?: string;
|
||||
created: string;
|
||||
last_accessed?: string;
|
||||
balances: AccountBalance[];
|
||||
|
||||
@@ -26,6 +26,7 @@ class AccountDetails(BaseModel):
|
||||
name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
logo: Optional[str] = None
|
||||
created: datetime
|
||||
last_accessed: Optional[datetime] = None
|
||||
balances: List[AccountBalance] = []
|
||||
|
||||
@@ -55,6 +55,7 @@ async def get_all_accounts() -> APIResponse:
|
||||
name=db_account.get("name"),
|
||||
display_name=db_account.get("display_name"),
|
||||
currency=db_account.get("currency"),
|
||||
logo=db_account.get("logo"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
balances=balances,
|
||||
@@ -115,6 +116,7 @@ async def get_account_details(account_id: str) -> APIResponse:
|
||||
name=db_account.get("name"),
|
||||
display_name=db_account.get("display_name"),
|
||||
currency=db_account.get("currency"),
|
||||
logo=db_account.get("logo"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
balances=balances,
|
||||
|
||||
@@ -21,18 +21,19 @@ async def get_bank_institutions(
|
||||
) -> APIResponse:
|
||||
"""Get available bank institutions for a country"""
|
||||
try:
|
||||
institutions_data = await gocardless_service.get_institutions(country)
|
||||
institutions_response = await gocardless_service.get_institutions(country)
|
||||
institutions_data = institutions_response.get("results", [])
|
||||
|
||||
institutions = [
|
||||
BankInstitution(
|
||||
id=inst["id"],
|
||||
name=inst["name"],
|
||||
bic=inst.get("bic"),
|
||||
transaction_total_days=inst["transaction_total_days"],
|
||||
transaction_total_days=int(inst["transaction_total_days"]),
|
||||
countries=inst["countries"],
|
||||
logo=inst.get("logo"),
|
||||
)
|
||||
for inst in institutions_data.get("results", [])
|
||||
for inst in institutions_data
|
||||
]
|
||||
|
||||
return APIResponse(
|
||||
|
||||
@@ -217,6 +217,7 @@ class DatabaseService:
|
||||
await self._migrate_to_composite_key_if_needed()
|
||||
await self._migrate_add_display_name_if_needed()
|
||||
await self._migrate_add_sync_operations_if_needed()
|
||||
await self._migrate_add_logo_if_needed()
|
||||
|
||||
async def _migrate_balance_timestamps_if_needed(self):
|
||||
"""Check and migrate balance timestamps if needed"""
|
||||
@@ -1133,7 +1134,8 @@ class DatabaseService:
|
||||
created DATETIME,
|
||||
last_accessed DATETIME,
|
||||
last_updated DATETIME,
|
||||
display_name TEXT
|
||||
display_name TEXT,
|
||||
logo TEXT
|
||||
)"""
|
||||
)
|
||||
|
||||
@@ -1170,8 +1172,9 @@ class DatabaseService:
|
||||
created,
|
||||
last_accessed,
|
||||
last_updated,
|
||||
display_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
display_name,
|
||||
logo
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
account_data["id"],
|
||||
account_data["institution_id"],
|
||||
@@ -1183,6 +1186,7 @@ class DatabaseService:
|
||||
account_data.get("last_accessed"),
|
||||
account_data.get("last_updated", account_data["created"]),
|
||||
display_name,
|
||||
account_data.get("logo"),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -1516,6 +1520,79 @@ class DatabaseService:
|
||||
logger.error(f"Sync operations table migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _migrate_add_logo_if_needed(self):
|
||||
"""Check and add logo column to accounts table if needed"""
|
||||
try:
|
||||
if await self._check_logo_migration_needed():
|
||||
logger.info("Logo column migration needed, starting...")
|
||||
await self._migrate_add_logo()
|
||||
logger.info("Logo column migration completed")
|
||||
else:
|
||||
logger.info("Logo column already exists")
|
||||
except Exception as e:
|
||||
logger.error(f"Logo column migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _check_logo_migration_needed(self) -> bool:
|
||||
"""Check if logo column needs to be added to accounts table"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if accounts table exists
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
# Check if logo column exists
|
||||
cursor.execute("PRAGMA table_info(accounts)")
|
||||
columns = cursor.fetchall()
|
||||
|
||||
# Check if logo column exists
|
||||
has_logo = any(col[1] == "logo" for col in columns)
|
||||
|
||||
conn.close()
|
||||
return not has_logo
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check logo migration status: {e}")
|
||||
return False
|
||||
|
||||
async def _migrate_add_logo(self):
|
||||
"""Add logo column to accounts table"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
logger.warning("Database file not found, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Adding logo column to accounts table...")
|
||||
|
||||
# Add the logo column
|
||||
cursor.execute("""
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN logo TEXT
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info("Logo column migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Logo column migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def persist_sync_operation(self, sync_operation: Dict[str, Any]) -> int:
|
||||
"""Persist sync operation to database and return the ID"""
|
||||
if not self.sqlite_enabled:
|
||||
|
||||
@@ -11,14 +11,13 @@ from leggen.utils.paths import path_manager
|
||||
|
||||
def _log_rate_limits(response):
|
||||
"""Log GoCardless API rate limit headers"""
|
||||
limit = response.headers.get("X-RateLimit-Limit")
|
||||
remaining = response.headers.get("X-RateLimit-Remaining")
|
||||
reset = response.headers.get("X-RateLimit-Reset")
|
||||
account_success_reset = response.headers.get("X-RateLimit-Account-Success-Reset")
|
||||
limit = response.headers.get("http_x_ratelimit_limit")
|
||||
remaining = response.headers.get("http_x_ratelimit_remaining")
|
||||
reset = response.headers.get("http_x_ratelimit_reset")
|
||||
|
||||
if limit or remaining or reset or account_success_reset:
|
||||
if limit or remaining or reset:
|
||||
logger.info(
|
||||
f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s, Account Success Reset: {account_success_reset}"
|
||||
f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s"
|
||||
)
|
||||
|
||||
|
||||
@@ -162,3 +161,9 @@ class GoCardlessService:
|
||||
return await self._make_authenticated_request(
|
||||
"GET", f"{self.base_url}/accounts/{account_id}/transactions/"
|
||||
)
|
||||
|
||||
async def get_institution_details(self, institution_id: str) -> Dict[str, Any]:
|
||||
"""Get institution details by ID"""
|
||||
return await self._make_authenticated_request(
|
||||
"GET", f"{self.base_url}/institutions/{institution_id}/"
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ class SyncService:
|
||||
self.database = DatabaseService()
|
||||
self.notifications = NotificationService()
|
||||
self._sync_status = SyncStatus(is_running=False)
|
||||
self._institution_logos = {} # Cache for institution logos
|
||||
|
||||
async def get_sync_status(self) -> SyncStatus:
|
||||
"""Get current sync status"""
|
||||
@@ -77,7 +78,7 @@ class SyncService:
|
||||
# Get balances to extract currency information
|
||||
balances = await self.gocardless.get_account_balances(account_id)
|
||||
|
||||
# Enrich account details with currency and persist
|
||||
# Enrich account details with currency and institution logo
|
||||
if account_details and balances:
|
||||
enriched_account_details = account_details.copy()
|
||||
|
||||
@@ -90,6 +91,26 @@ class SyncService:
|
||||
if currency:
|
||||
enriched_account_details["currency"] = currency
|
||||
|
||||
# Get institution details to fetch logo
|
||||
institution_id = enriched_account_details.get("institution_id")
|
||||
if institution_id:
|
||||
try:
|
||||
institution_details = (
|
||||
await self.gocardless.get_institution_details(
|
||||
institution_id
|
||||
)
|
||||
)
|
||||
enriched_account_details["logo"] = (
|
||||
institution_details.get("logo", "")
|
||||
)
|
||||
logger.info(
|
||||
f"Fetched logo for institution {institution_id}: {enriched_account_details.get('logo', 'No logo')}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to fetch institution details for {institution_id}: {e}"
|
||||
)
|
||||
|
||||
# Persist enriched account details to database
|
||||
await self.database.persist_account_details(
|
||||
enriched_account_details
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "leggen"
|
||||
version = "2025.9.21"
|
||||
version = "2025.9.22"
|
||||
description = "An Open Banking CLI"
|
||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||
requires-python = "~=3.13.0"
|
||||
|
||||
@@ -103,22 +103,24 @@ def mock_db_path(temp_db_path):
|
||||
@pytest.fixture
|
||||
def sample_bank_data():
|
||||
"""Sample bank/institution data for testing."""
|
||||
return [
|
||||
{
|
||||
"id": "REVOLUT_REVOLT21",
|
||||
"name": "Revolut",
|
||||
"bic": "REVOLT21",
|
||||
"transaction_total_days": 90,
|
||||
"countries": ["GB", "LT"],
|
||||
},
|
||||
{
|
||||
"id": "BANCOBPI_BBPIPTPL",
|
||||
"name": "Banco BPI",
|
||||
"bic": "BBPIPTPL",
|
||||
"transaction_total_days": 90,
|
||||
"countries": ["PT"],
|
||||
},
|
||||
]
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"id": "REVOLUT_REVOLT21",
|
||||
"name": "Revolut",
|
||||
"bic": "REVOLT21",
|
||||
"transaction_total_days": 90,
|
||||
"countries": ["GB", "LT"],
|
||||
},
|
||||
{
|
||||
"id": "BANCOBPI_BBPIPTPL",
|
||||
"name": "Banco BPI",
|
||||
"bic": "BBPIPTPL",
|
||||
"transaction_total_days": 90,
|
||||
"countries": ["PT"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -50,7 +50,7 @@ class TestBanksAPI:
|
||||
|
||||
# Mock empty institutions response for invalid country
|
||||
respx.get("https://bankaccountdata.gocardless.com/api/v2/institutions/").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
return_value=httpx.Response(200, json={"results": []})
|
||||
)
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
|
||||
@@ -37,9 +37,12 @@ class TestLeggenAPIClient:
|
||||
"""Test getting institutions via API client."""
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
# The API returns processed institutions, not raw GoCardless data
|
||||
processed_institutions = sample_bank_data["results"]
|
||||
|
||||
api_response = {
|
||||
"success": True,
|
||||
"data": sample_bank_data,
|
||||
"data": processed_institutions,
|
||||
"message": "Found 2 institutions for PT",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user