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

View File

@@ -0,0 +1,200 @@
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggend.api.models.common import APIResponse
from leggend.api.models.accounts import AccountDetails, AccountBalance, Transaction, TransactionSummary
from leggend.services.gocardless_service import GoCardlessService
from leggend.services.database_service import DatabaseService
router = APIRouter()
gocardless_service = GoCardlessService()
database_service = DatabaseService()
@router.get("/accounts", response_model=APIResponse)
async def get_all_accounts() -> APIResponse:
"""Get all connected accounts"""
try:
requisitions_data = await gocardless_service.get_requisitions()
all_accounts = set()
for req in requisitions_data.get("results", []):
all_accounts.update(req.get("accounts", []))
accounts = []
for account_id in all_accounts:
try:
account_details = await gocardless_service.get_account_details(account_id)
balances_data = await gocardless_service.get_account_balances(account_id)
# Process balances
balances = []
for balance in balances_data.get("balances", []):
balance_amount = balance["balanceAmount"]
balances.append(AccountBalance(
amount=float(balance_amount["amount"]),
currency=balance_amount["currency"],
balance_type=balance["balanceType"],
last_change_date=balance.get("lastChangeDateTime")
))
accounts.append(AccountDetails(
id=account_details["id"],
institution_id=account_details["institution_id"],
status=account_details["status"],
iban=account_details.get("iban"),
name=account_details.get("name"),
currency=account_details.get("currency"),
created=account_details["created"],
last_accessed=account_details.get("last_accessed"),
balances=balances
))
except Exception as e:
logger.error(f"Failed to get details for account {account_id}: {e}")
continue
return APIResponse(
success=True,
data=accounts,
message=f"Retrieved {len(accounts)} accounts"
)
except Exception as e:
logger.error(f"Failed to get accounts: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get accounts: {str(e)}")
@router.get("/accounts/{account_id}", response_model=APIResponse)
async def get_account_details(account_id: str) -> APIResponse:
"""Get details for a specific account"""
try:
account_details = await gocardless_service.get_account_details(account_id)
balances_data = await gocardless_service.get_account_balances(account_id)
# Process balances
balances = []
for balance in balances_data.get("balances", []):
balance_amount = balance["balanceAmount"]
balances.append(AccountBalance(
amount=float(balance_amount["amount"]),
currency=balance_amount["currency"],
balance_type=balance["balanceType"],
last_change_date=balance.get("lastChangeDateTime")
))
account = AccountDetails(
id=account_details["id"],
institution_id=account_details["institution_id"],
status=account_details["status"],
iban=account_details.get("iban"),
name=account_details.get("name"),
currency=account_details.get("currency"),
created=account_details["created"],
last_accessed=account_details.get("last_accessed"),
balances=balances
)
return APIResponse(
success=True,
data=account,
message=f"Account details retrieved for {account_id}"
)
except Exception as e:
logger.error(f"Failed to get account details for {account_id}: {e}")
raise HTTPException(status_code=404, detail=f"Account not found: {str(e)}")
@router.get("/accounts/{account_id}/balances", response_model=APIResponse)
async def get_account_balances(account_id: str) -> APIResponse:
"""Get balances for a specific account"""
try:
balances_data = await gocardless_service.get_account_balances(account_id)
balances = []
for balance in balances_data.get("balances", []):
balance_amount = balance["balanceAmount"]
balances.append(AccountBalance(
amount=float(balance_amount["amount"]),
currency=balance_amount["currency"],
balance_type=balance["balanceType"],
last_change_date=balance.get("lastChangeDateTime")
))
return APIResponse(
success=True,
data=balances,
message=f"Retrieved {len(balances)} balances for account {account_id}"
)
except Exception as e:
logger.error(f"Failed to get balances for account {account_id}: {e}")
raise HTTPException(status_code=404, detail=f"Failed to get balances: {str(e)}")
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
async def get_account_transactions(
account_id: str,
limit: Optional[int] = Query(default=100, le=500),
offset: Optional[int] = Query(default=0, ge=0),
summary_only: bool = Query(default=False, description="Return transaction summaries only")
) -> APIResponse:
"""Get transactions for a specific account"""
try:
account_details = await gocardless_service.get_account_details(account_id)
transactions_data = await gocardless_service.get_account_transactions(account_id)
# Process transactions
processed_transactions = database_service.process_transactions(
account_id, account_details, transactions_data
)
# Apply pagination
total_transactions = len(processed_transactions)
paginated_transactions = processed_transactions[offset:offset + limit]
if summary_only:
# Return simplified transaction summaries
summaries = [
TransactionSummary(
internal_transaction_id=txn["internalTransactionId"],
date=txn["transactionDate"],
description=txn["description"],
amount=txn["transactionValue"],
currency=txn["transactionCurrency"],
status=txn["transactionStatus"],
account_id=txn["accountId"]
)
for txn in paginated_transactions
]
data = summaries
else:
# Return full transaction details
transactions = [
Transaction(
internal_transaction_id=txn["internalTransactionId"],
institution_id=txn["institutionId"],
iban=txn["iban"],
account_id=txn["accountId"],
transaction_date=txn["transactionDate"],
description=txn["description"],
transaction_value=txn["transactionValue"],
transaction_currency=txn["transactionCurrency"],
transaction_status=txn["transactionStatus"],
raw_transaction=txn["rawTransaction"]
)
for txn in paginated_transactions
]
data = transactions
return APIResponse(
success=True,
data=data,
message=f"Retrieved {len(data)} transactions (showing {offset + 1}-{offset + len(data)} of {total_transactions})"
)
except Exception as e:
logger.error(f"Failed to get transactions for account {account_id}: {e}")
raise HTTPException(status_code=404, detail=f"Failed to get transactions: {str(e)}")