Compare commits

..

9 Commits

Author SHA1 Message Date
Elisiário Couto
dc3522220a chore(ci): Bump version to 2025.9.23 2025-09-25 00:34:45 +01:00
Elisiário Couto
1693b3a50d Resolve test issues. 2025-09-25 00:02:42 +01:00
Elisiário Couto
460c5af6ea fix: Correct sync trigger types from manual to scheduled/retry.
Fixed scheduled syncs being incorrectly saved as "manual" in database.
Now properly identifies scheduled syncs as "scheduled" and retry
attempts as "retry". Updated frontend to capitalize trigger type
badges for better display.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 23:58:43 +01:00
Elisiário Couto
5a8614e019 Small fixes. 2025-09-24 23:52:51 +01:00
Elisiário Couto
ae5d034d4b fix(cli): Fix API URL handling for subpaths and improve client robustness.
- Automatically append /api/v1 to base URL if not present
- Fix URL construction to handle subpaths correctly
- Update health check to parse new nested response format
- Refactor bank delete command to use API client instead of direct requests
- Remove redundant /api/v1 prefixes from endpoint calls

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 23:52:34 +01:00
copilot-swe-agent[bot]
d4edf69f2c feat(frontend): Add version-based cache invalidation for PWA updates
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-24 21:46:12 +01:00
Elisiário Couto
d3a1696d4d chore(ci): Bump version to 2025.9.22 2025-09-24 20:08:20 +01:00
Elisiário Couto
24792744f9 fix(api): Fix banks API test fixtures to match GoCardless response format.
Updated test fixtures to correctly mock GoCardless API response format
with "results" key for institutions data. Fixed API client test to use
processed institutions data instead of raw GoCardless format.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 20:04:44 +01:00
Elisiário Couto
b9ca74e7e6 feat(api): Add bank logo support and fix banks endpoint type errors.
Backend changes:
- Add logo field to AccountDetails model
- Update accounts API endpoints to include logo data
- Add database migration for logo column in accounts table
- Implement institution details fetching from GoCardless API
- Enrich account data with institution logos during sync
- Fix type errors in banks endpoint with proper response parsing

Frontend changes:
- Add failedImages state to track logo loading failures
- Implement conditional rendering to show bank logos when available
- Add proper error handling with fallback to Building2 icon
- Fix image sizing to w-6 h-6 sm:w-8 sm:h-8 for proper display
- Update Account interface to include optional logo field
- Remove unused useState import from System component

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 19:57:03 +01:00
27 changed files with 383 additions and 80 deletions

1
.gitignore vendored
View File

@@ -165,3 +165,4 @@ leggen.db
*.db
config.toml
.claude/
.playwright-mcp/

View File

@@ -7,10 +7,10 @@
"mcp"
]
},
"browsermcp": {
"playwright": {
"command": "npx",
"args": [
"@browsermcp/mcp@latest"
"@playwright/mcp@latest"
]
}
}

View File

@@ -1,4 +1,74 @@
## 2025.9.23 (2025/09/24)
### Bug Fixes
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
### Features
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
## 2025.9.23 (2025/09/24)
### Bug Fixes
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
### Features
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
## 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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import {
RefreshCw,
AlertCircle,
@@ -211,7 +210,7 @@ export default function System() {
: "Sync Failed"}
</h4>
<Badge variant="outline" className="text-xs">
{operation.trigger_type}
{operation.trigger_type.charAt(0).toUpperCase() + operation.trigger_type.slice(1)}
</Badge>
</div>
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
@@ -273,7 +272,7 @@ export default function System() {
: "Sync Failed"}
</h4>
<Badge variant="outline" className="text-xs mt-1">
{operation.trigger_type}
{operation.trigger_type.charAt(0).toUpperCase() + operation.trigger_type.slice(1)}
</Badge>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
interface PWAUpdate {
updateAvailable: boolean;
updateSW: () => Promise<void>;
forceReload: () => Promise<void>;
}
export function usePWA(): PWAUpdate {
@@ -11,6 +12,33 @@ export function usePWA(): PWAUpdate {
() => async () => {},
);
const forceReload = async (): Promise<void> => {
try {
// Clear all caches
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
);
console.log("All caches cleared");
}
// Unregister service worker
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map(registration => registration.unregister()));
console.log("All service workers unregistered");
}
// Force reload
window.location.reload();
} catch (error) {
console.error("Error during force reload:", error);
// Fallback: just reload the page
window.location.reload();
}
};
useEffect(() => {
// Check if SW registration is available
if ("serviceWorker" in navigator) {
@@ -37,5 +65,6 @@ export function usePWA(): PWAUpdate {
return {
updateAvailable,
updateSW,
forceReload,
};
}

View File

@@ -0,0 +1,40 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
const VERSION_STORAGE_KEY = "leggen_app_version";
export function useVersionCheck(forceReload: () => Promise<void>) {
const {
data: healthStatus,
isSuccess: healthSuccess,
} = useQuery({
queryKey: ["health"],
queryFn: apiClient.getHealth,
refetchInterval: 30000,
retry: false,
staleTime: 0, // Always consider data stale to ensure fresh version checks
});
useEffect(() => {
if (healthSuccess && healthStatus?.version) {
const currentVersion = healthStatus.version;
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
if (storedVersion && storedVersion !== currentVersion) {
console.log(`Version mismatch detected: stored=${storedVersion}, current=${currentVersion}`);
console.log("Clearing cache and reloading...");
// Update stored version first
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
// Force reload to clear cache
forceReload();
} else if (!storedVersion) {
// First time loading, store the version
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
console.log(`Version stored: ${currentVersion}`);
}
}
}, [healthSuccess, healthStatus?.version, forceReload]);
}

View File

@@ -3,10 +3,14 @@ import { AppSidebar } from "../components/AppSidebar";
import { SiteHeader } from "../components/SiteHeader";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA";
import { useVersionCheck } from "../hooks/useVersionCheck";
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
function RootLayout() {
const { updateAvailable, updateSW } = usePWA();
const { updateAvailable, updateSW, forceReload } = usePWA();
// Check for version mismatches and force reload if needed
useVersionCheck(forceReload);
const handlePWAInstall = () => {
console.log("PWA installed successfully");

View File

@@ -13,6 +13,7 @@ export interface Account {
name?: string;
display_name?: string;
currency?: string;
logo?: string;
created: string;
last_accessed?: string;
balances: AccountBalance[];

View File

@@ -1,12 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
import { tanstackRouter } from "@tanstack/router-vite-plugin";
import { VitePWA } from "vite-plugin-pwa";
// https://vite.dev/config/
export default defineConfig({
plugins: [
TanStackRouterVite(),
tanstackRouter(),
react(),
VitePWA({
registerType: "autoUpdate",

View File

@@ -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] = []

View File

@@ -18,7 +18,7 @@ class SyncOperation(BaseModel):
duration_seconds: Optional[float] = None
errors: list[str] = []
logs: list[str] = []
trigger_type: str = "manual" # manual, scheduled, api
trigger_type: str = "manual" # manual, scheduled, retry, api
class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import os
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse
import requests
@@ -13,11 +13,22 @@ class LeggenAPIClient:
base_url: str
def __init__(self, base_url: Optional[str] = None):
self.base_url = (
raw_url = (
base_url
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
or "http://localhost:8000"
)
# Ensure base_url includes /api/v1 path if not already present
parsed = urlparse(raw_url)
if not parsed.path or parsed.path == "/":
# No path or just root, add /api/v1
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
elif not parsed.path.startswith("/api/v1"):
# Has a path but not /api/v1, add it
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
else:
# Already has /api/v1 path
self.base_url = raw_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update(
{"Content-Type": "application/json", "Accept": "application/json"}
@@ -25,7 +36,14 @@ class LeggenAPIClient:
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make HTTP request to the API"""
url = urljoin(self.base_url, endpoint)
# Construct URL by joining base_url with endpoint
# Handle both relative endpoints (starting with /) and paths
if endpoint.startswith("/"):
# Absolute endpoint path - append to base_url
url = f"{self.base_url}{endpoint}"
else:
# Relative endpoint, use urljoin
url = urljoin(f"{self.base_url}/", endpoint)
try:
response = self.session.request(method, url, **kwargs)
@@ -52,7 +70,9 @@ class LeggenAPIClient:
"""Check if the leggen server is healthy"""
try:
response = self._make_request("GET", "/health")
return response.get("status") == "healthy"
# The API now returns nested data structure
data = response.get("data", {})
return data.get("status") == "healthy"
except Exception:
return False
@@ -60,7 +80,7 @@ class LeggenAPIClient:
def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
"""Get bank institutions for a country"""
response = self._make_request(
"GET", "/api/v1/banks/institutions", params={"country": country}
"GET", "/banks/institutions", params={"country": country}
)
return response.get("data", [])
@@ -70,35 +90,35 @@ class LeggenAPIClient:
"""Connect to a bank"""
response = self._make_request(
"POST",
"/api/v1/banks/connect",
"/banks/connect",
json={"institution_id": institution_id, "redirect_url": redirect_url},
)
return response.get("data", {})
def get_bank_status(self) -> List[Dict[str, Any]]:
"""Get bank connection status"""
response = self._make_request("GET", "/api/v1/banks/status")
response = self._make_request("GET", "/banks/status")
return response.get("data", [])
def get_supported_countries(self) -> List[Dict[str, Any]]:
"""Get supported countries"""
response = self._make_request("GET", "/api/v1/banks/countries")
response = self._make_request("GET", "/banks/countries")
return response.get("data", [])
# Account endpoints
def get_accounts(self) -> List[Dict[str, Any]]:
"""Get all accounts"""
response = self._make_request("GET", "/api/v1/accounts")
response = self._make_request("GET", "/accounts")
return response.get("data", [])
def get_account_details(self, account_id: str) -> Dict[str, Any]:
"""Get account details"""
response = self._make_request("GET", f"/api/v1/accounts/{account_id}")
response = self._make_request("GET", f"/accounts/{account_id}")
return response.get("data", {})
def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]:
"""Get account balances"""
response = self._make_request("GET", f"/api/v1/accounts/{account_id}/balances")
response = self._make_request("GET", f"/accounts/{account_id}/balances")
return response.get("data", [])
def get_account_transactions(
@@ -107,7 +127,7 @@ class LeggenAPIClient:
"""Get account transactions"""
response = self._make_request(
"GET",
f"/api/v1/accounts/{account_id}/transactions",
f"/accounts/{account_id}/transactions",
params={"limit": limit, "summary_only": summary_only},
)
return response.get("data", [])
@@ -120,7 +140,7 @@ class LeggenAPIClient:
params = {"limit": limit, "summary_only": summary_only}
params.update(filters)
response = self._make_request("GET", "/api/v1/transactions", params=params)
response = self._make_request("GET", "/transactions", params=params)
return response.get("data", [])
def get_transaction_stats(
@@ -131,15 +151,13 @@ class LeggenAPIClient:
if account_id:
params["account_id"] = account_id
response = self._make_request(
"GET", "/api/v1/transactions/stats", params=params
)
response = self._make_request("GET", "/transactions/stats", params=params)
return response.get("data", {})
# Sync endpoints
def get_sync_status(self) -> Dict[str, Any]:
"""Get sync status"""
response = self._make_request("GET", "/api/v1/sync/status")
response = self._make_request("GET", "/sync/status")
return response.get("data", {})
def trigger_sync(
@@ -150,7 +168,7 @@ class LeggenAPIClient:
if account_ids:
data["account_ids"] = account_ids
response = self._make_request("POST", "/api/v1/sync", json=data)
response = self._make_request("POST", "/sync", json=data)
return response.get("data", {})
def sync_now(
@@ -161,12 +179,12 @@ class LeggenAPIClient:
if account_ids:
data["account_ids"] = account_ids
response = self._make_request("POST", "/api/v1/sync/now", json=data)
response = self._make_request("POST", "/sync/now", json=data)
return response.get("data", {})
def get_scheduler_config(self) -> Dict[str, Any]:
"""Get scheduler configuration"""
response = self._make_request("GET", "/api/v1/sync/scheduler")
response = self._make_request("GET", "/sync/scheduler")
return response.get("data", {})
def update_scheduler_config(
@@ -185,5 +203,5 @@ class LeggenAPIClient:
if cron:
data["cron"] = cron
response = self._make_request("PUT", "/api/v1/sync/scheduler", json=data)
response = self._make_request("PUT", "/sync/scheduler", json=data)
return response.get("data", {})

View File

@@ -102,12 +102,14 @@ class BackgroundScheduler:
async def _run_sync(self, retry_count: int = 0):
"""Run sync with enhanced error handling and retry logic"""
try:
logger.info("Starting scheduled sync job")
await self.sync_service.sync_all_accounts()
logger.info("Scheduled sync job completed successfully")
trigger_type = "retry" if retry_count > 0 else "scheduled"
logger.info(f"Starting {trigger_type} sync job")
await self.sync_service.sync_all_accounts(trigger_type=trigger_type)
logger.info(f"{trigger_type.capitalize()} sync job completed successfully")
except Exception as e:
trigger_type = "retry" if retry_count > 0 else "scheduled"
logger.error(
f"Scheduled sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
f"{trigger_type.capitalize()} sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
)
# Send notification about the failure

View File

@@ -1,5 +1,6 @@
import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli
from leggen.utils.text import info, success
@@ -15,12 +16,11 @@ def delete(ctx, requisition_id: str):
Check `leggen status` to get the REQUISITION_ID
"""
import requests
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
info(f"Deleting Bank Requisition: {requisition_id}")
api_url = ctx.obj.get("api_url", "http://localhost:8000")
res = requests.delete(f"{api_url}/requisitions/{requisition_id}")
res.raise_for_status()
# Use API client to make the delete request
api_client._make_request("DELETE", f"/requisitions/{requisition_id}")
success(f"Bank Requisition {requisition_id} deleted")

View File

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

View File

@@ -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}/"
)

View File

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

View File

@@ -39,13 +39,11 @@ class Config:
try:
with open(config_path, "rb") as f:
raw_config = tomllib.load(f)
logger.info(f"Configuration loaded from {config_path}")
# Validate configuration using Pydantic
try:
self._config_model = ConfigModel(**raw_config)
self._config = self._config_model.dict(by_alias=True, exclude_none=True)
logger.info("Configuration validation successful")
except ValidationError as e:
logger.error(f"Configuration validation failed: {e}")
raise ValueError(f"Invalid configuration: {e}") from e

View File

@@ -1,6 +1,6 @@
[project]
name = "leggen"
version = "2025.9.21"
version = "2025.9.23"
description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0"

View File

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

View File

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

View File

@@ -18,7 +18,10 @@ class TestLeggenAPIClient:
client = LeggenAPIClient("http://localhost:8000")
with requests_mock.Mocker() as m:
m.get("http://localhost:8000/health", json={"status": "healthy"})
m.get(
"http://localhost:8000/api/v1/health",
json={"data": {"status": "healthy"}},
)
result = client.health_check()
assert result is True
@@ -37,9 +40,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",
}
@@ -109,13 +115,13 @@ class TestLeggenAPIClient:
custom_url = "http://custom-host:9000"
client = LeggenAPIClient(custom_url)
assert client.base_url == custom_url
assert client.base_url == f"{custom_url}/api/v1"
def test_environment_variable_url(self):
"""Test using environment variable for API URL."""
with patch.dict("os.environ", {"LEGGEN_API_URL": "http://env-host:7000"}):
client = LeggenAPIClient()
assert client.base_url == "http://env-host:7000"
assert client.base_url == "http://env-host:7000/api/v1"
def test_sync_with_options(self):
"""Test sync with various options."""

2
uv.lock generated
View File

@@ -220,7 +220,7 @@ wheels = [
[[package]]
name = "leggen"
version = "2025.9.21"
version = "2025.9.23"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },