# COMPLETE SOURCE CODE FOR FIREFLY III ANALYTICS This file contains all source code for the application. Each section is clearly labeled with the file path. ## BACKEND FILES ### backend/pyproject.toml ```toml [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 ```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/app/__init__.py ```python # Firefly Analytics App ``` ### backend/app/config.py ```python import os from pydantic_settings import BaseSettings class Settings(BaseSettings): FIREFLY_URL: str = os.getenv("FIREFLY_URL", "https://firefly.scsimedia.duckdns.org") FIREFLY_API_TOKEN: str = os.getenv("FIREFLY_API_TOKEN", "") DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./data/app.db") SYNC_INTERVAL_MINUTES: int = int(os.getenv("SYNC_INTERVAL_MINUTES", "30")) API_TIMEOUT: int = 30 class Config: env_file = ".env" settings = Settings() ``` ### backend/app/models.py ```python 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): __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) category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) from_account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True) to_account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True) 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], back_populates="transactions") from_account = relationship("Account", foreign_keys=[from_account_id]) to_account = relationship("Account", foreign_keys=[to_account_id]) class Category(Base): __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) 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], back_populates="category") class Account(Base): __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) 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): __tablename__ = "sync_logs" id = Column(Integer, primary_key=True, index=True) entity_type = Column(String) last_sync = Column(DateTime) status = Column(String) message = Column(String, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) ``` ### backend/app/database.py ```python 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(): Base.metadata.create_all(bind=engine) def get_db() -> Session: db = SessionLocal() try: yield db finally: db.close() ``` ### backend/app/main.py ```python 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): init_db() start_sync_scheduler() yield stop_sync_scheduler() app = FastAPI( title="Firefly III Analytics API", description="Analytics and reporting for Firefly III", version="0.1.0", lifespan=lifespan ) app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "http://localhost"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) 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(): return {"status": "ok", "message": "Firefly III Analytics API"} @app.get("/health") async def health(): return {"status": "healthy"} if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) ``` ### backend/app/routers/__init__.py ```python # API routers ``` ### backend/app/routers/transactions.py ```python from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from app.database import get_db from datetime import datetime router = APIRouter() @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) ): from app.models import Transaction query = db.query(Transaction) if start_date: query = query.filter(Transaction.date >= datetime.fromisoformat(start_date)) if end_date: query = query.filter(Transaction.date <= datetime.fromisoformat(end_date)) if category_id: query = query.filter(Transaction.category_id == category_id) transactions = query.order_by(Transaction.date.desc()).all() return {"data": transactions, "count": len(transactions)} ``` ### backend/app/routers/categories.py ```python from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.database import get_db from app.models import Category router = APIRouter() @router.get("/") async def get_categories(db: Session = Depends(get_db)): categories = db.query(Category).all() return {"data": categories, "count": len(categories)} ``` ### backend/app/routers/accounts.py ```python from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.database import get_db from app.models import Account router = APIRouter() @router.get("/") async def get_accounts(db: Session = Depends(get_db)): accounts = db.query(Account).all() return {"data": accounts, "count": len(accounts)} ``` ### backend/app/routers/reports.py ```python from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from sqlalchemy import func from app.database import get_db from app.models import Transaction, Category from datetime import datetime router = APIRouter() @router.get("/spending-by-category") async def spending_by_category( start_date: str = Query(...), end_date: str = Query(...), db: Session = Depends(get_db) ): start = datetime.fromisoformat(start_date) end = datetime.fromisoformat(end_date) 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] } @router.get("/income-vs-expenses") async def income_vs_expenses( start_date: str = Query(...), end_date: str = Query(...), db: Session = Depends(get_db) ): start = datetime.fromisoformat(start_date) end = datetime.fromisoformat(end_date) 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) } @router.get("/trends") async def trends( start_date: str = Query(...), end_date: str = Query(...), db: Session = Depends(get_db) ): start = datetime.fromisoformat(start_date) end = datetime.fromisoformat(end_date) 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] } ``` ### backend/app/routers/summary.py ```python from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from sqlalchemy import func from app.database import get_db from app.models import Transaction, Account from datetime import datetime, timedelta router = APIRouter() @router.get("/") async def get_summary(db: Session = Depends(get_db)): 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 income = db.query(func.sum(Transaction.amount)).filter( Transaction.date >= thirty_days_ago, Transaction.type == "deposit" ).scalar() or 0 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(income), "expenses": float(expenses), "net": float(income - expenses) } } ``` ### backend/app/clients/__init__.py ```python # API clients ``` ### backend/app/clients/firefly_client.py ```python import httpx from typing import Optional, List, Dict, Any from app.config import settings import logging logger = logging.getLogger(__name__) class FireflyClient: 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]]: 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() return response.json().get("data", []) except Exception as e: logger.error(f"Error fetching transactions: {e}") return [] async def get_categories(self) -> List[Dict[str, Any]]: 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() return response.json().get("data", []) except Exception as e: logger.error(f"Error fetching categories: {e}") return [] async def get_accounts(self) -> List[Dict[str, Any]]: 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() return response.json().get("data", []) except Exception as e: logger.error(f"Error fetching accounts: {e}") return [] firefly_client = FireflyClient() ``` ### backend/app/services/__init__.py ```python # Services ``` ### backend/app/services/sync_service.py ```python 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(): 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.balance = float(attrs.get("current_balance", 0)) 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(): asyncio.run(sync_data()) def start_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(): if scheduler.running: scheduler.shutdown() logger.info("Sync scheduler stopped") ``` ## FRONTEND FILES ### frontend/package.json ```json { "name": "firefly-analytics-frontend", "version": "0.1.0", "private": true, "dependencies": { "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/node": "^20.10.6", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "axios": "^1.6.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-scripts": "5.0.1", "recharts": "^2.10.3", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, "eslintConfig": { "extends": [ "react-app" ] }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } ``` ### frontend/Dockerfile ```dockerfile FROM node:18-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/build /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 3000 CMD ["nginx", "-g", "daemon off;"] ``` ### frontend/nginx.conf ```nginx server { listen 3000; location / { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; } location /api { proxy_pass http://backend:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } ``` ### frontend/src/types.ts ```typescript export interface Transaction { id: number; firefly_id: number; date: string; amount: number; description: string; type: 'withdrawal' | 'deposit' | 'transfer'; category_id?: number; } export interface Category { id: number; firefly_id: number; name: string; type: string; } export interface Account { id: number; firefly_id: number; name: string; type: string; balance: number; currency_code: string; } export interface ReportData { period: { start: string; end: string; }; data: any[]; } export interface SummaryData { total_assets: number; recent_transactions: number; last_30_days: { income: number; expenses: number; net: number; }; } ``` ### frontend/src/services/api.ts ```typescript import axios from 'axios'; import { Transaction, Category, Account, ReportData, SummaryData } from '../types'; const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000'; const api = axios.create({ baseURL: API_URL, }); export const transactionService = { getTransactions: (startDate?: string, endDate?: string) => api.get<{ data: Transaction[] }>('/api/transactions', { params: { start_date: startDate, end_date: endDate }, }), }; export const categoryService = { getCategories: () => api.get<{ data: Category[] }>('/api/categories'), }; export const accountService = { getAccounts: () => api.get<{ data: Account[] }>('/api/accounts'), }; export const reportService = { getSpendingByCategory: (startDate: string, endDate: string) => api.get('/api/reports/spending-by-category', { params: { start_date: startDate, end_date: endDate }, }), getIncomeVsExpenses: (startDate: string, endDate: string) => api.get('/api/reports/income-vs-expenses', { params: { start_date: startDate, end_date: endDate }, }), getTrends: (startDate: string, endDate: string) => api.get('/api/reports/trends', { params: { start_date: startDate, end_date: endDate }, }), }; export const summaryService = { getSummary: () => api.get('/api/summary'), }; ``` ### frontend/src/components/DateRangePicker.tsx ```typescript import React from 'react'; interface DateRangePickerProps { startDate: string; endDate: string; onStartDateChange: (date: string) => void; onEndDateChange: (date: string) => void; onPreset: (days: number) => void; } const DateRangePicker: React.FC = ({ startDate, endDate, onStartDateChange, onEndDateChange, onPreset, }) => { return (
onStartDateChange(e.target.value)} placeholder="Start Date" /> onEndDateChange(e.target.value)} placeholder="End Date" />
); }; export default DateRangePicker; ``` ### frontend/src/components/SpendingChart.tsx ```typescript import React from 'react'; import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'; interface SpendingChartProps { data: Array<{ category: string; amount: number }>; } const COLORS = [ '#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1', '#d084d0', ]; const SpendingChart: React.FC = ({ data }) => { if (!data || data.length === 0) { return

No data available

; } return ( `${category}: $${amount.toFixed(2)}`} outerRadius={120} fill="#8884d8" dataKey="amount" > {data.map((entry, index) => ( ))} `$${value.toFixed(2)}`} /> ); }; export default SpendingChart; ``` ### frontend/src/components/TrendChart.tsx ```typescript import React from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; interface TrendChartProps { data: Array<{ date: string; amount: number }>; } const TrendChart: React.FC = ({ data }) => { if (!data || data.length === 0) { return

No data available

; } return ( `$${value.toFixed(2)}`} /> ); }; export default TrendChart; ``` ### frontend/src/pages/Dashboard.tsx ```typescript import React, { useState, useEffect } from 'react'; import { summaryService } from '../services/api'; import { SummaryData } from '../types'; const Dashboard: React.FC = () => { const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const fetchSummary = async () => { try { const response = await summaryService.getSummary(); setSummary(response.data); } catch (error) { console.error('Error fetching summary:', error); } finally { setLoading(false); } }; fetchSummary(); }, []); if (loading) return

Loading...

; if (!summary) return

Error loading data

; return (

Financial Dashboard

Total Assets

${summary.total_assets.toFixed(2)}

Last 30 Days Income

${summary.last_30_days.income.toFixed(2)}

Last 30 Days Expenses

${summary.last_30_days.expenses.toFixed(2)}

); }; export default Dashboard; ``` ### frontend/src/pages/ReportsPage.tsx ```typescript import React, { useState, useEffect } from 'react'; import DateRangePicker from '../components/DateRangePicker'; import SpendingChart from '../components/SpendingChart'; import TrendChart from '../components/TrendChart'; import { reportService } from '../services/api'; import { ReportData } from '../types'; const ReportsPage: React.FC = () => { const [reportType, setReportType] = useState('spending-by-category'); const [startDate, setStartDate] = useState( new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] ); const [endDate, setEndDate] = useState(new Date().toISOString().split('T')[0]); const [reportData, setReportData] = useState(null); const [loading, setLoading] = useState(false); const handlePreset = (days: number) => { const end = new Date(); const start = new Date(Date.now() - days * 24 * 60 * 60 * 1000); setStartDate(start.toISOString().split('T')[0]); setEndDate(end.toISOString().split('T')[0]); }; useEffect(() => { const fetchReport = async () => { setLoading(true); try { let response; if (reportType === 'spending-by-category') { response = await reportService.getSpendingByCategory(startDate, endDate); } else if (reportType === 'income-vs-expenses') { response = await reportService.getIncomeVsExpenses(startDate, endDate); } else if (reportType === 'trends') { response = await reportService.getTrends(startDate, endDate); } setReportData(response.data); } catch (error) { console.error('Error fetching report:', error); } finally { setLoading(false); } }; fetchReport(); }, [reportType, startDate, endDate]); return (

Reports

{loading ? (

Loading report...

) : reportData ? ( <> {reportType === 'spending-by-category' && } {reportType === 'income-vs-expenses' && (

Income: ${reportData.income.toFixed(2)}

Expenses: ${reportData.expenses.toFixed(2)}

Net: ${reportData.net.toFixed(2)}

)} {reportType === 'trends' && } ) : (

No data available

)}
); }; export default ReportsPage; ``` ### frontend/src/App.tsx ```typescript import React, { useState } from 'react'; import Dashboard from './pages/Dashboard'; import ReportsPage from './pages/ReportsPage'; const App: React.FC = () => { const [currentPage, setCurrentPage] = useState('dashboard'); return (
{currentPage === 'dashboard' && } {currentPage === 'reports' && }
); }; export default App; ``` ### frontend/src/index.tsx ```typescript import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); root.render( ); ``` ### frontend/public/index.html ```html Firefly III Analytics
``` --- ## Summary All source code is provided above. To implement: 1. Copy each section to the corresponding file path 2. Run `backend/pyproject.toml` setup 3. Run `npm install` in frontend 4. Use `docker-compose up` to deploy Refer to INSTALLATION.md for detailed setup instructions.