feat: Transform to web architecture with FastAPI backend

This major update transforms leggen from CLI-only to a web-ready
architecture while maintaining full CLI compatibility.

New Features:
- FastAPI backend service (leggend) with comprehensive REST API
- Background job scheduler with configurable cron (replaces Ofelia)
- All CLI commands refactored to use API endpoints
- Docker configuration updated for new services
- API client with health checks and error handling

API Endpoints:
- /api/v1/banks/* - Bank connections and institutions
- /api/v1/accounts/* - Account management and balances
- /api/v1/transactions/* - Transaction retrieval with filtering
- /api/v1/sync/* - Manual sync and scheduler configuration
- /api/v1/notifications/* - Notification settings management

CLI Enhancements:
- New --api-url option and LEGGEND_API_URL environment variable
- Enhanced sync command with --wait and --force options
- Improved transactions command with --full and --limit options
- Automatic fallback and health checking

Breaking Changes:
- compose.yml structure updated (leggend service added)
- Ofelia scheduler removed (internal scheduler used instead)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Elisiário Couto
2025-09-02 00:01:35 +01:00
committed by Elisiário Couto
parent 73d6bd32db
commit 91f53b35b1
39 changed files with 2810 additions and 347 deletions

157
leggen/api_client.py Normal file
View File

@@ -0,0 +1,157 @@
import os
import requests
from typing import Dict, Any, Optional, List
from urllib.parse import urljoin
from leggen.utils.text import error
class LeggendAPIClient:
"""Client for communicating with the leggend FastAPI service"""
def __init__(self, base_url: Optional[str] = None):
self.base_url = base_url or os.environ.get("LEGGEND_API_URL", "http://localhost:8000")
self.session = requests.Session()
self.session.headers.update({
"Content-Type": "application/json",
"Accept": "application/json"
})
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make HTTP request to the API"""
url = urljoin(self.base_url, endpoint)
try:
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
return response.json()
except requests.exceptions.ConnectionError:
error("Could not connect to leggend service. Is it running?")
error(f"Trying to connect to: {self.base_url}")
raise
except requests.exceptions.HTTPError as e:
error(f"API request failed: {e}")
if response.text:
try:
error_data = response.json()
error(f"Error details: {error_data.get('detail', 'Unknown error')}")
except:
error(f"Response: {response.text}")
raise
except Exception as e:
error(f"Unexpected error: {e}")
raise
def health_check(self) -> bool:
"""Check if the leggend service is healthy"""
try:
response = self._make_request("GET", "/health")
return response.get("status") == "healthy"
except:
return False
# Bank endpoints
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})
return response.get("data", [])
def connect_to_bank(self, institution_id: str, redirect_url: str = "http://localhost:8000/") -> Dict[str, Any]:
"""Connect to a bank"""
response = self._make_request(
"POST",
"/api/v1/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")
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")
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")
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}")
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")
return response.get("data", [])
def get_account_transactions(self, account_id: str, limit: int = 100, summary_only: bool = False) -> List[Dict[str, Any]]:
"""Get account transactions"""
response = self._make_request(
"GET",
f"/api/v1/accounts/{account_id}/transactions",
params={"limit": limit, "summary_only": summary_only}
)
return response.get("data", [])
# Transaction endpoints
def get_all_transactions(self, limit: int = 100, summary_only: bool = True, **filters) -> List[Dict[str, Any]]:
"""Get all transactions with optional filters"""
params = {"limit": limit, "summary_only": summary_only}
params.update(filters)
response = self._make_request("GET", "/api/v1/transactions", params=params)
return response.get("data", [])
def get_transaction_stats(self, days: int = 30, account_id: Optional[str] = None) -> Dict[str, Any]:
"""Get transaction statistics"""
params = {"days": days}
if account_id:
params["account_id"] = account_id
response = self._make_request("GET", "/api/v1/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")
return response.get("data", {})
def trigger_sync(self, account_ids: Optional[List[str]] = None, force: bool = False) -> Dict[str, Any]:
"""Trigger a sync"""
data = {"force": force}
if account_ids:
data["account_ids"] = account_ids
response = self._make_request("POST", "/api/v1/sync", json=data)
return response.get("data", {})
def sync_now(self, account_ids: Optional[List[str]] = None, force: bool = False) -> Dict[str, Any]:
"""Run sync synchronously"""
data = {"force": force}
if account_ids:
data["account_ids"] = account_ids
response = self._make_request("POST", "/api/v1/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")
return response.get("data", {})
def update_scheduler_config(self, enabled: bool = True, hour: int = 3, minute: int = 0, cron: Optional[str] = None) -> Dict[str, Any]:
"""Update scheduler configuration"""
data = {"enabled": enabled, "hour": hour, "minute": minute}
if cron:
data["cron"] = cron
response = self._make_request("PUT", "/api/v1/sync/scheduler", json=data)
return response.get("data", {})

View File

@@ -1,7 +1,7 @@
import click
from leggen.main import cli
from leggen.utils.network import get
from leggen.api_client import LeggendAPIClient
from leggen.utils.text import datefmt, print_table
@@ -11,36 +11,35 @@ def balances(ctx: click.Context):
"""
List balances of all connected accounts
"""
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available
if not api_client.health_check():
click.echo("Error: Cannot connect to leggend service. Please ensure it's running.")
return
res = get(ctx, "/requisitions/")
accounts = set()
for r in res.get("results", []):
accounts.update(r.get("accounts", []))
accounts = api_client.get_accounts()
all_balances = []
for account in accounts:
account_ballances = get(ctx, f"/accounts/{account}/balances/").get(
"balances", []
)
for balance in account_ballances:
balance_amount = balance["balanceAmount"]
amount = round(float(balance_amount["amount"]), 2)
for balance in account.get("balances", []):
amount = round(float(balance["amount"]), 2)
symbol = (
""
if balance_amount["currency"] == "EUR"
else f" {balance_amount['currency']}"
if balance["currency"] == "EUR"
else f" {balance['currency']}"
)
amount_str = f"{amount}{symbol}"
date = (
datefmt(balance.get("lastChangeDateTime"))
if balance.get("lastChangeDateTime")
datefmt(balance.get("last_change_date"))
if balance.get("last_change_date")
else ""
)
all_balances.append(
{
"Account": account,
"Account": account["id"],
"Amount": amount_str,
"Type": balance["balanceType"],
"Type": balance["balance_type"],
"Last change at": date,
}
)

View File

@@ -1,9 +1,9 @@
import click
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.utils.disk import save_file
from leggen.utils.network import get, post
from leggen.utils.text import info, print_table, warning
from leggen.utils.text import info, print_table, warning, success
@cli.command()
@@ -12,69 +12,64 @@ def add(ctx):
"""
Connect to a bank
"""
country = click.prompt(
"Bank Country",
type=click.Choice(
[
"AT",
"BE",
"BG",
"HR",
"CY",
"CZ",
"DK",
"EE",
"FI",
"FR",
"DE",
"GR",
"HU",
"IS",
"IE",
"IT",
"LV",
"LI",
"LT",
"LU",
"MT",
"NL",
"NO",
"PL",
"PT",
"RO",
"SK",
"SI",
"ES",
"SE",
"GB",
],
case_sensitive=True,
),
default="PT",
)
info(f"Getting bank list for country: {country}")
banks = get(ctx, "/institutions/", {"country": country})
filtered_banks = [
{
"id": bank["id"],
"name": bank["name"],
"max_transaction_days": bank["transaction_total_days"],
}
for bank in banks
]
print_table(filtered_banks)
allowed_ids = [str(bank["id"]) for bank in banks]
bank_id = click.prompt("Bank ID", type=click.Choice(allowed_ids))
click.confirm("Do you agree to connect to this bank?", abort=True)
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available
if not api_client.health_check():
click.echo("Error: Cannot connect to leggend service. Please ensure it's running.")
return
info(f"Connecting to bank with ID: {bank_id}")
try:
# Get supported countries
countries = api_client.get_supported_countries()
country_codes = [c["code"] for c in countries]
country = click.prompt(
"Bank Country",
type=click.Choice(country_codes, case_sensitive=True),
default="PT",
)
info(f"Getting bank list for country: {country}")
banks = api_client.get_institutions(country)
if not banks:
warning(f"No banks available for country {country}")
return
filtered_banks = [
{
"id": bank["id"],
"name": bank["name"],
"max_transaction_days": bank["transaction_total_days"],
}
for bank in banks
]
print_table(filtered_banks)
allowed_ids = [str(bank["id"]) for bank in banks]
bank_id = click.prompt("Bank ID", type=click.Choice(allowed_ids))
# Show bank details
selected_bank = next(bank for bank in banks if bank["id"] == bank_id)
info(f"Selected bank: {selected_bank['name']}")
click.confirm("Do you agree to connect to this bank?", abort=True)
res = post(
ctx,
"/requisitions/",
{"institution_id": bank_id, "redirect": "http://localhost:8000/"},
)
info(f"Connecting to bank with ID: {bank_id}")
save_file(f"req_{res['id']}.json", res)
# Connect to bank via API
result = api_client.connect_to_bank(bank_id, "http://localhost:8000/")
warning(f"Please open the following URL in your browser to accept: {res['link']}")
# Save requisition details
save_file(f"req_{result['id']}.json", result)
success("Bank connection request created successfully!")
warning(f"Please open the following URL in your browser to complete the authorization:")
click.echo(f"\n{result['link']}\n")
info(f"Requisition ID: {result['id']}")
info("After completing the authorization, you can check the connection status with 'leggen status'")
except Exception as e:
click.echo(f"Error: Failed to connect to bank: {str(e)}")

View File

@@ -1,8 +1,7 @@
import click
from leggen.main import cli
from leggen.utils.gocardless import REQUISITION_STATUS
from leggen.utils.network import get
from leggen.api_client import LeggendAPIClient
from leggen.utils.text import datefmt, echo, info, print_table
@@ -12,36 +11,42 @@ def status(ctx: click.Context):
"""
List all connected banks and their status
"""
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available
if not api_client.health_check():
click.echo("Error: Cannot connect to leggend service. Please ensure it's running.")
return
res = get(ctx, "/requisitions/")
# Get bank connection status
bank_connections = api_client.get_bank_status()
requisitions = []
accounts = set()
for r in res["results"]:
for conn in bank_connections:
requisitions.append(
{
"Bank": r["institution_id"],
"Status": REQUISITION_STATUS.get(r["status"], "UNKNOWN"),
"Created at": datefmt(r["created"]),
"Requisition ID": r["id"],
"Bank": conn["bank_id"],
"Status": conn["status_display"],
"Created at": datefmt(conn["created_at"]),
"Requisition ID": conn["requisition_id"],
}
)
accounts.update(r.get("accounts", []))
info("Banks")
print_table(requisitions)
# Get account details
accounts = api_client.get_accounts()
account_details = []
for account in accounts:
details = get(ctx, f"/accounts/{account}")
account_details.append(
{
"ID": details["id"],
"Bank": details["institution_id"],
"Status": details["status"],
"IBAN": details.get("iban", "N/A"),
"Created at": datefmt(details["created"]),
"ID": account["id"],
"Bank": account["institution_id"],
"Status": account["status"],
"IBAN": account.get("iban", "N/A"),
"Created at": datefmt(account["created"]),
"Last accessed at": (
datefmt(details["last_accessed"])
if details.get("last_accessed")
datefmt(account["last_accessed"])
if account.get("last_accessed")
else "N/A"
),
}

View File

@@ -1,79 +1,59 @@
import datetime
import click
from leggen.main import cli
from leggen.utils.database import persist_balance, save_transactions
from leggen.utils.gocardless import REQUISITION_STATUS
from leggen.utils.network import get
from leggen.utils.notifications import send_expire_notification, send_notification
from leggen.utils.text import error, info
from leggen.api_client import LeggendAPIClient
from leggen.utils.text import error, info, success
@cli.command()
@click.option('--wait', is_flag=True, help='Wait for sync to complete (synchronous)')
@click.option('--force', is_flag=True, help='Force sync even if already running')
@click.pass_context
def sync(ctx: click.Context):
def sync(ctx: click.Context, wait: bool, force: bool):
"""
Sync all transactions with database
"""
info("Getting accounts details")
res = get(ctx, "/requisitions/")
accounts = set()
for r in res.get("results", []):
accounts.update(r.get("accounts", []))
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available
if not api_client.health_check():
error("Cannot connect to leggend service. Please ensure it's running.")
return
for r in res.get("results", []):
account_status = REQUISITION_STATUS.get(r["status"], "UNKNOWN")
if account_status != "LINKED":
created_at = datetime.datetime.fromisoformat(r["created"])
now = datetime.datetime.now(tz=datetime.timezone.utc)
days_left = 90 - (now - created_at).days
if days_left <= 15:
n = {
"bank": r["institution_id"],
"status": REQUISITION_STATUS.get(r["status"], "UNKNOWN"),
"created_at": created_at.timestamp(),
"requisition_id": r["id"],
"days_left": days_left,
}
send_expire_notification(ctx, n)
info(f"Syncing balances for {len(accounts)} accounts")
for account in accounts:
try:
account_details = get(ctx, f"/accounts/{account}")
account_balances = get(ctx, f"/accounts/{account}/balances/").get(
"balances", []
)
for balance in account_balances:
balance_amount = balance["balanceAmount"]
amount = round(float(balance_amount["amount"]), 2)
balance_document = {
"account_id": account,
"bank": account_details["institution_id"],
"status": account_details["status"],
"iban": account_details.get("iban", "N/A"),
"amount": amount,
"currency": balance_amount["currency"],
"type": balance["balanceType"],
"timestamp": datetime.datetime.now().timestamp(),
}
persist_balance(ctx, account, balance_document)
except Exception as e:
error(f"[{account}] Error: Sync failed, skipping account, exception: {e}")
continue
info(f"Syncing transactions for {len(accounts)} accounts")
for account in accounts:
try:
new_transactions = save_transactions(ctx, account)
except Exception as e:
error(f"[{account}] Error: Sync failed, skipping account, exception: {e}")
continue
try:
send_notification(ctx, new_transactions)
except Exception as e:
error(f"[{account}] Error: Notification failed, exception: {e}")
continue
try:
if wait:
# Run sync synchronously and wait for completion
info("Starting synchronous sync...")
result = api_client.sync_now(force=force)
if result.get("success"):
success(f"Sync completed successfully!")
info(f"Accounts processed: {result.get('accounts_processed', 0)}")
info(f"Transactions added: {result.get('transactions_added', 0)}")
info(f"Balances updated: {result.get('balances_updated', 0)}")
if result.get('duration_seconds'):
info(f"Duration: {result['duration_seconds']:.2f} seconds")
if result.get('errors'):
error(f"Errors encountered: {len(result['errors'])}")
for err in result['errors']:
error(f" - {err}")
else:
error("Sync failed")
if result.get('errors'):
for err in result['errors']:
error(f" - {err}")
else:
# Trigger async sync
info("Starting background sync...")
result = api_client.trigger_sync(force=force)
if result.get("sync_started"):
success("Sync started successfully in the background")
info("Use 'leggen sync --wait' to run synchronously or check status with API")
else:
error("Failed to start sync")
except Exception as e:
error(f"Sync failed: {str(e)}")
return

View File

@@ -1,31 +1,16 @@
import click
from leggen.main import cli
from leggen.utils.network import get
from leggen.utils.text import info, print_table
def print_transactions(
ctx: click.Context, account_info: dict, account_transactions: dict
):
info(f"Bank: {account_info['institution_id']}")
info(f"IBAN: {account_info.get('iban', 'N/A')}")
all_transactions = []
for transaction in account_transactions.get("booked", []):
transaction["TYPE"] = "booked"
all_transactions.append(transaction)
for transaction in account_transactions.get("pending", []):
transaction["TYPE"] = "pending"
all_transactions.append(transaction)
print_table(all_transactions)
from leggen.api_client import LeggendAPIClient
from leggen.utils.text import datefmt, info, print_table
@cli.command()
@click.option("-a", "--account", type=str, help="Account ID")
@click.option("-l", "--limit", type=int, default=50, help="Number of transactions to show")
@click.option("--full", is_flag=True, help="Show full transaction details")
@click.pass_context
def transactions(ctx: click.Context, account: str):
def transactions(ctx: click.Context, account: str, limit: int, full: bool):
"""
List transactions
@@ -33,20 +18,61 @@ def transactions(ctx: click.Context, account: str):
If the --account option is used, it will only list transactions for that account.
"""
if account:
account_info = get(ctx, f"/accounts/{account}")
account_transactions = get(ctx, f"/accounts/{account}/transactions/").get(
"transactions", []
)
print_transactions(ctx, account_info, account_transactions)
else:
res = get(ctx, "/requisitions/")
accounts = set()
for r in res["results"]:
accounts.update(r.get("accounts", []))
for account in accounts:
account_details = get(ctx, f"/accounts/{account}")
account_transactions = get(ctx, f"/accounts/{account}/transactions/").get(
"transactions", []
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available
if not api_client.health_check():
click.echo("Error: Cannot connect to leggend service. Please ensure it's running.")
return
try:
if account:
# Get transactions for specific account
account_details = api_client.get_account_details(account)
transactions_data = api_client.get_account_transactions(
account, limit=limit, summary_only=not full
)
print_transactions(ctx, account_details, account_transactions)
info(f"Bank: {account_details['institution_id']}")
info(f"IBAN: {account_details.get('iban', 'N/A')}")
else:
# Get all transactions
transactions_data = api_client.get_all_transactions(
limit=limit,
summary_only=not full,
account_id=account
)
# Format transactions for display
if full:
# Full transaction details
formatted_transactions = []
for txn in transactions_data:
formatted_transactions.append({
"ID": txn["internal_transaction_id"][:12] + "...",
"Date": datefmt(txn["transaction_date"]),
"Description": txn["description"][:50] + "..." if len(txn["description"]) > 50 else txn["description"],
"Amount": f"{txn['transaction_value']:.2f} {txn['transaction_currency']}",
"Status": txn["transaction_status"].upper(),
"Account": txn["account_id"][:8] + "...",
})
else:
# Summary view
formatted_transactions = []
for txn in transactions_data:
formatted_transactions.append({
"Date": datefmt(txn["date"]),
"Description": txn["description"][:60] + "..." if len(txn["description"]) > 60 else txn["description"],
"Amount": f"{txn['amount']:.2f} {txn['currency']}",
"Status": txn["status"].upper(),
})
if formatted_transactions:
print_table(formatted_transactions)
info(f"Showing {len(formatted_transactions)} transactions")
else:
info("No transactions found")
except Exception as e:
click.echo(f"Error: Failed to get transactions: {str(e)}")

View File

@@ -87,13 +87,21 @@ class Group(click.Group):
show_envvar=True,
help="Path to TOML configuration file",
)
@click.option(
"--api-url",
type=str,
default=None,
envvar="LEGGEND_API_URL",
show_envvar=True,
help="URL of the leggend API service (default: http://localhost:8000)",
)
@click.group(
cls=Group,
context_settings={"help_option_names": ["-h", "--help"]},
)
@click.version_option(package_name="leggen")
@click.pass_context
def cli(ctx: click.Context):
def cli(ctx: click.Context, api_url: str):
"""
Leggen: An Open Banking CLI
"""
@@ -102,5 +110,15 @@ def cli(ctx: click.Context):
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
return
token = get_token(ctx)
ctx.obj["headers"] = {"Authorization": f"Bearer {token}"}
# Store API URL in context for commands to use
if api_url:
ctx.obj["api_url"] = api_url
# For backwards compatibility, still support direct GoCardless calls
# This will be used as fallback if leggend service is not available
try:
token = get_token(ctx)
ctx.obj["headers"] = {"Authorization": f"Bearer {token}"}
except Exception:
# If we can't get token, commands will rely on API service
pass