#!/usr/bin/env python3 """ Project initialization script for Firefly III Analytics Creates the complete directory structure and scaffolding """ import os import sys from pathlib import Path def create_dir(path): """Create directory if it doesn't exist""" Path(path).mkdir(parents=True, exist_ok=True) print(f"āœ“ Created directory: {path}") def create_file(path, content): """Create file with content""" Path(path).parent.mkdir(parents=True, exist_ok=True) with open(path, 'w', encoding='utf-8') as f: f.write(content) print(f"āœ“ Created file: {path}") # Backend files backend_pyproject = '''[project] name = "firefly-analytics-backend" version = "0.1.0" description = "Firefly III Analytics & Reporting Backend" requires-python = ">=3.11" dependencies = [ "fastapi==0.104.1", "uvicorn[standard]==0.24.0", "httpx==0.25.2", "python-dotenv==1.0.0", "pydantic==2.5.0", "pydantic-settings==2.1.0", "sqlalchemy==2.0.23", "python-dateutil==2.8.2", "apscheduler==3.10.4", ] [project.optional-dependencies] dev = [ "pytest==7.4.3", "pytest-asyncio==0.21.1", "black==23.12.0", "ruff==0.1.8", ] ''' backend_dockerfile = '''FROM python:3.11-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \\ gcc \\ && rm -rf /var/lib/apt/lists/* COPY pyproject.toml pyproject.toml COPY app/ app/ RUN pip install --no-cache-dir -e . RUN mkdir -p /app/data EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ''' backend_config = '''""" Firefly III Analytics Backend - Configuration """ import os from pydantic_settings import BaseSettings class Settings(BaseSettings): """Application settings from environment variables""" # Firefly III FIREFLY_URL: str = os.getenv("FIREFLY_URL", "https://firefly.scsimedia.duckdns.org") FIREFLY_API_TOKEN: str = os.getenv("FIREFLY_API_TOKEN", "") # Database DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./data/app.db") # Sync SYNC_INTERVAL_MINUTES: int = int(os.getenv("SYNC_INTERVAL_MINUTES", "30")) # API API_TIMEOUT: int = 30 class Config: env_file = ".env" settings = Settings() ''' backend_models = '''""" Database models for Firefly III Analytics """ from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, Text, ForeignKey from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship from datetime import datetime Base = declarative_base() class Transaction(Base): """Transaction model""" __tablename__ = "transactions" id = Column(Integer, primary_key=True, index=True) firefly_id = Column(Integer, unique=True, index=True) date = Column(DateTime, index=True) amount = Column(Float) description = Column(String) type = Column(String) # withdrawal, deposit, transfer category_id = Column(Integer, ForeignKey("categories.id")) from_account_id = Column(Integer, ForeignKey("accounts.id")) to_account_id = Column(Integer, ForeignKey("accounts.id")) notes = Column(Text, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) category = relationship("Category", foreign_keys=[category_id]) from_account = relationship("Account", foreign_keys=[from_account_id]) to_account = relationship("Account", foreign_keys=[to_account_id]) class Category(Base): """Category model""" __tablename__ = "categories" id = Column(Integer, primary_key=True, index=True) firefly_id = Column(Integer, unique=True, index=True) name = Column(String, unique=True, index=True) type = Column(String) # expense, revenue, etc created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) transactions = relationship("Transaction", foreign_keys=[Transaction.category_id]) class Account(Base): """Account model""" __tablename__ = "accounts" id = Column(Integer, primary_key=True, index=True) firefly_id = Column(Integer, unique=True, index=True) name = Column(String, unique=True, index=True) type = Column(String) # asset, expense, revenue, liability, etc balance = Column(Float) currency_code = Column(String) active = Column(Boolean, default=True) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) class SyncLog(Base): """Track sync operations""" __tablename__ = "sync_logs" id = Column(Integer, primary_key=True, index=True) entity_type = Column(String) # transactions, categories, accounts last_sync = Column(DateTime) status = Column(String) # success, failure message = Column(String, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) ''' backend_database = '''""" Database initialization and connection """ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, Session from app.config import settings from app.models import Base engine = create_engine( settings.DATABASE_URL, connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {} ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) def init_db(): """Initialize database tables""" Base.metadata.create_all(bind=engine) def get_db() -> Session: """Get database session""" db = SessionLocal() try: yield db finally: db.close() ''' backend_firefly_client = '''""" Firefly III API Client """ import httpx from typing import Optional, List, Dict, Any from datetime import datetime from app.config import settings import logging logger = logging.getLogger(__name__) class FireflyClient: """Client for Firefly III API""" def __init__(self): self.base_url = settings.FIREFLY_URL.rstrip('/') self.headers = { "Authorization": f"Bearer {settings.FIREFLY_API_TOKEN}", "Accept": "application/vnd.api+json", "Content-Type": "application/json" } async def get_transactions( self, start_date: Optional[str] = None, end_date: Optional[str] = None, limit: int = 50 ) -> List[Dict[str, Any]]: """Fetch transactions from Firefly III""" async with httpx.AsyncClient() as client: params = {"limit": limit} if start_date: params["start"] = start_date if end_date: params["end"] = end_date try: response = await client.get( f"{self.base_url}/api/v1/transactions", headers=self.headers, params=params, timeout=settings.API_TIMEOUT ) response.raise_for_status() data = response.json() return data.get("data", []) except Exception as e: logger.error(f"Error fetching transactions: {e}") return [] async def get_categories(self) -> List[Dict[str, Any]]: """Fetch categories from Firefly III""" async with httpx.AsyncClient() as client: try: response = await client.get( f"{self.base_url}/api/v1/categories", headers=self.headers, timeout=settings.API_TIMEOUT ) response.raise_for_status() data = response.json() return data.get("data", []) except Exception as e: logger.error(f"Error fetching categories: {e}") return [] async def get_accounts(self) -> List[Dict[str, Any]]: """Fetch accounts from Firefly III""" async with httpx.AsyncClient() as client: try: response = await client.get( f"{self.base_url}/api/v1/accounts", headers=self.headers, timeout=settings.API_TIMEOUT ) response.raise_for_status() data = response.json() return data.get("data", []) except Exception as e: logger.error(f"Error fetching accounts: {e}") return [] firefly_client = FireflyClient() ''' backend_main = '''""" Firefly III Analytics Backend - Main Application """ import os from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from dotenv import load_dotenv from app.database import init_db from app.routers import transactions, categories, accounts, reports, summary from app.services.sync_service import start_sync_scheduler, stop_sync_scheduler load_dotenv() @asynccontextmanager async def lifespan(app: FastAPI): """Startup and shutdown events""" # Startup init_db() start_sync_scheduler() yield # Shutdown stop_sync_scheduler() app = FastAPI( title="Firefly III Analytics API", description="Analytics and reporting for Firefly III", version="0.1.0", lifespan=lifespan ) # CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "http://localhost"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Include routers app.include_router(transactions.router, prefix="/api/transactions", tags=["transactions"]) app.include_router(categories.router, prefix="/api/categories", tags=["categories"]) app.include_router(accounts.router, prefix="/api/accounts", tags=["accounts"]) app.include_router(reports.router, prefix="/api/reports", tags=["reports"]) app.include_router(summary.router, prefix="/api/summary", tags=["summary"]) @app.get("/") async def root(): """Health check""" return {"status": "ok", "message": "Firefly III Analytics API"} @app.get("/health") async def health(): """Health check endpoint""" return {"status": "healthy"} if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) ''' backend_routers_init = '''""" API routers """ ''' backend_routers_transactions = '''""" Transactions API endpoints """ from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from app.database import get_db from app.services.transaction_service import TransactionService from datetime import datetime, timedelta router = APIRouter() transaction_service = TransactionService() @router.get("/") async def get_transactions( start_date: str = Query(None), end_date: str = Query(None), category_id: int = Query(None), db: Session = Depends(get_db) ): """Get transactions with optional filtering""" filters = {} if start_date: filters["start_date"] = datetime.fromisoformat(start_date) if end_date: filters["end_date"] = datetime.fromisoformat(end_date) if category_id: filters["category_id"] = category_id transactions = await transaction_service.get_transactions(db, filters) return {"data": transactions, "count": len(transactions)} ''' backend_routers_categories = '''""" Categories API endpoints """ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.database import get_db from app.services.category_service import CategoryService router = APIRouter() category_service = CategoryService() @router.get("/") async def get_categories(db: Session = Depends(get_db)): """Get all categories""" categories = category_service.get_all_categories(db) return {"data": categories, "count": len(categories)} ''' backend_routers_accounts = '''""" Accounts API endpoints """ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.database import get_db from app.services.account_service import AccountService router = APIRouter() account_service = AccountService() @router.get("/") async def get_accounts(db: Session = Depends(get_db)): """Get all accounts""" accounts = account_service.get_all_accounts(db) return {"data": accounts, "count": len(accounts)} ''' backend_routers_reports = '''""" Reports API endpoints """ from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from app.database import get_db from app.services.report_service import ReportService from datetime import datetime router = APIRouter() report_service = ReportService() @router.get("/spending-by-category") async def spending_by_category( start_date: str = Query(...), end_date: str = Query(...), db: Session = Depends(get_db) ): """Get spending by category for date range""" start = datetime.fromisoformat(start_date) end = datetime.fromisoformat(end_date) data = await report_service.get_spending_by_category(db, start, end) return {"data": data} @router.get("/income-vs-expenses") async def income_vs_expenses( start_date: str = Query(...), end_date: str = Query(...), db: Session = Depends(get_db) ): """Get income vs expenses for date range""" start = datetime.fromisoformat(start_date) end = datetime.fromisoformat(end_date) data = await report_service.get_income_vs_expenses(db, start, end) return {"data": data} @router.get("/trends") async def trends( start_date: str = Query(...), end_date: str = Query(...), db: Session = Depends(get_db) ): """Get financial trends over time""" start = datetime.fromisoformat(start_date) end = datetime.fromisoformat(end_date) data = await report_service.get_trends(db, start, end) return {"data": data} @router.get("/budget-vs-actual") async def budget_vs_actual( start_date: str = Query(...), end_date: str = Query(...), db: Session = Depends(get_db) ): """Get budget vs actual spending""" start = datetime.fromisoformat(start_date) end = datetime.fromisoformat(end_date) data = await report_service.get_budget_vs_actual(db, start, end) return {"data": data} ''' backend_routers_summary = '''""" Summary API endpoints """ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.database import get_db from app.services.summary_service import SummaryService router = APIRouter() summary_service = SummaryService() @router.get("/") async def get_summary(db: Session = Depends(get_db)): """Get dashboard summary metrics""" summary = await summary_service.get_summary(db) return summary ''' backend_sync_service = '''""" Background sync service for Firefly III data """ from apscheduler.schedulers.background import BackgroundScheduler from app.config import settings from app.clients.firefly_client import firefly_client from app.database import SessionLocal from app.models import Transaction, Category, Account, SyncLog from datetime import datetime import logging import asyncio logger = logging.getLogger(__name__) scheduler = BackgroundScheduler() async def sync_data(): """Sync data from Firefly III""" db = SessionLocal() try: # Sync categories categories = await firefly_client.get_categories() for cat in categories: attrs = cat.get("attributes", {}) existing = db.query(Category).filter_by(firefly_id=cat["id"]).first() if existing: existing.name = attrs.get("name") existing.type = attrs.get("type") existing.updated_at = datetime.utcnow() else: db.add(Category( firefly_id=cat["id"], name=attrs.get("name"), type=attrs.get("type") )) # Sync accounts accounts = await firefly_client.get_accounts() for acc in accounts: attrs = acc.get("attributes", {}) existing = db.query(Account).filter_by(firefly_id=acc["id"]).first() if existing: existing.name = attrs.get("name") existing.type = attrs.get("type") existing.balance = float(attrs.get("current_balance", 0)) existing.currency_code = attrs.get("currency_code") existing.updated_at = datetime.utcnow() else: db.add(Account( firefly_id=acc["id"], name=attrs.get("name"), type=attrs.get("type"), balance=float(attrs.get("current_balance", 0)), currency_code=attrs.get("currency_code") )) # Sync transactions transactions = await firefly_client.get_transactions() for txn in transactions: attrs = txn.get("attributes", {}) existing = db.query(Transaction).filter_by(firefly_id=txn["id"]).first() if not existing: db.add(Transaction( firefly_id=txn["id"], date=datetime.fromisoformat(attrs.get("date")), amount=float(attrs.get("amount", 0)), description=attrs.get("description"), type=attrs.get("type"), notes=attrs.get("notes") )) db.commit() logger.info("Data sync completed successfully") except Exception as e: logger.error(f"Error syncing data: {e}") db.rollback() finally: db.close() def sync_job(): """Wrapper for async sync job""" asyncio.run(sync_data()) def start_sync_scheduler(): """Start background sync scheduler""" if not scheduler.running: scheduler.add_job(sync_job, 'interval', minutes=settings.SYNC_INTERVAL_MINUTES) scheduler.start() logger.info(f"Sync scheduler started (interval: {settings.SYNC_INTERVAL_MINUTES} minutes)") def stop_sync_scheduler(): """Stop background sync scheduler""" if scheduler.running: scheduler.shutdown() logger.info("Sync scheduler stopped") ''' backend_transaction_service = '''""" Transaction service business logic """ from sqlalchemy.orm import Session from sqlalchemy import func from app.models import Transaction from datetime import datetime class TransactionService: def get_transactions(self, db: Session, filters: dict): """Get transactions with filters""" query = db.query(Transaction) if "start_date" in filters: query = query.filter(Transaction.date >= filters["start_date"]) if "end_date" in filters: query = query.filter(Transaction.date <= filters["end_date"]) if "category_id" in filters: query = query.filter(Transaction.category_id == filters["category_id"]) return query.order_by(Transaction.date.desc()).all() ''' backend_category_service = '''""" Category service business logic """ from sqlalchemy.orm import Session from app.models import Category class CategoryService: def get_all_categories(self, db: Session): """Get all categories""" return db.query(Category).all() ''' backend_account_service = '''""" Account service business logic """ from sqlalchemy.orm import Session from app.models import Account class AccountService: def get_all_accounts(self, db: Session): """Get all accounts""" return db.query(Account).all() ''' backend_report_service = '''""" Report service for generating analytics """ from sqlalchemy.orm import Session from sqlalchemy import func from app.models import Transaction, Category, Account from datetime import datetime from typing import Dict, List, Any class ReportService: async def get_spending_by_category(self, db: Session, start: datetime, end: datetime) -> Dict[str, Any]: """Get spending breakdown by category""" results = db.query( Category.name, func.sum(Transaction.amount).label("total") ).join( Transaction ).filter( Transaction.date >= start, Transaction.date <= end, Transaction.type == "withdrawal" ).group_by(Category.name).all() return { "period": {"start": start.isoformat(), "end": end.isoformat()}, "data": [{"category": r[0], "amount": float(r[1]) if r[1] else 0} for r in results] } async def get_income_vs_expenses(self, db: Session, start: datetime, end: datetime) -> Dict[str, Any]: """Get income vs expenses""" income = db.query(func.sum(Transaction.amount)).filter( Transaction.date >= start, Transaction.date <= end, Transaction.type == "deposit" ).scalar() or 0 expenses = db.query(func.sum(Transaction.amount)).filter( Transaction.date >= start, Transaction.date <= end, Transaction.type == "withdrawal" ).scalar() or 0 return { "period": {"start": start.isoformat(), "end": end.isoformat()}, "income": float(income), "expenses": float(expenses), "net": float(income - expenses) } async def get_trends(self, db: Session, start: datetime, end: datetime) -> Dict[str, Any]: """Get financial trends over time""" results = db.query( func.date(Transaction.date).label("date"), func.sum(Transaction.amount).label("amount") ).filter( Transaction.date >= start, Transaction.date <= end ).group_by(func.date(Transaction.date)).order_by("date").all() return { "period": {"start": start.isoformat(), "end": end.isoformat()}, "data": [{"date": str(r[0]), "amount": float(r[1]) if r[1] else 0} for r in results] } async def get_budget_vs_actual(self, db: Session, start: datetime, end: datetime) -> Dict[str, Any]: """Get budget vs actual spending (requires budget data)""" return { "period": {"start": start.isoformat(), "end": end.isoformat()}, "data": [], "note": "Budget comparison requires budget data from Firefly III" } ''' backend_summary_service = '''""" Summary service for dashboard metrics """ from sqlalchemy.orm import Session from sqlalchemy import func from app.models import Transaction, Account from datetime import datetime, timedelta class SummaryService: async def get_summary(self, db: Session): """Get dashboard summary""" # Last 30 days thirty_days_ago = datetime.utcnow() - timedelta(days=30) total_assets = db.query(func.sum(Account.balance)).scalar() or 0 recent_transactions = db.query(func.count(Transaction.id)).filter( Transaction.date >= thirty_days_ago ).scalar() or 0 last_30_income = db.query(func.sum(Transaction.amount)).filter( Transaction.date >= thirty_days_ago, Transaction.type == "deposit" ).scalar() or 0 last_30_expenses = db.query(func.sum(Transaction.amount)).filter( Transaction.date >= thirty_days_ago, Transaction.type == "withdrawal" ).scalar() or 0 return { "total_assets": float(total_assets), "recent_transactions": recent_transactions, "last_30_days": { "income": float(last_30_income), "expenses": float(last_30_expenses), "net": float(last_30_income - last_30_expenses) } } ''' def main(): base_path = Path(os.getcwd()) # Create backend structure print("Setting up Firefly III Analytics Backend...") create_file("backend/pyproject.toml", backend_pyproject) create_file("backend/Dockerfile", backend_dockerfile) create_file("backend/app/__init__.py", "") create_file("backend/app/config.py", backend_config) create_file("backend/app/models.py", backend_models) create_file("backend/app/database.py", backend_database) create_file("backend/app/main.py", backend_main) # Routers create_file("backend/app/routers/__init__.py", backend_routers_init) create_file("backend/app/routers/transactions.py", backend_routers_transactions) create_file("backend/app/routers/categories.py", backend_routers_categories) create_file("backend/app/routers/accounts.py", backend_routers_accounts) create_file("backend/app/routers/reports.py", backend_routers_reports) create_file("backend/app/routers/summary.py", backend_routers_summary) # Clients create_file("backend/app/clients/__init__.py", "") create_file("backend/app/clients/firefly_client.py", backend_firefly_client) # Services create_file("backend/app/services/__init__.py", "") create_file("backend/app/services/sync_service.py", backend_sync_service) create_file("backend/app/services/transaction_service.py", backend_transaction_service) create_file("backend/app/services/category_service.py", backend_category_service) create_file("backend/app/services/account_service.py", backend_account_service) create_file("backend/app/services/report_service.py", backend_report_service) create_file("backend/app/services/summary_service.py", backend_summary_service) print("\nāœ“ Backend scaffolding complete!") print("\nNext steps:") print("1. Create .env file with your Firefly III API token") print("2. cd backend && python -m venv venv") print("3. source venv/bin/activate") print("4. pip install -e .") print("5. uvicorn app.main:app --reload") if __name__ == "__main__": main()