Initial commit

This commit is contained in:
nabeel
2026-05-27 13:58:05 +10:00
commit c2202ea0bb
89 changed files with 28855 additions and 0 deletions

13
backend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# Backend Dockerfile for FastAPI service
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/__init__.py Normal file
View File

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Firefly Analytics Backend

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

View File

@@ -0,0 +1,44 @@
import logging
import httpx
from ..config import settings
logger = logging.getLogger("firefly_client")
class FireflyClient:
def __init__(self):
self.base_url = settings.firefly_url.rstrip("/")
self.headers = {"Authorization": f"Bearer {settings.firefly_api_token}"}
async def _get(self, path, params=None):
url = f"{self.base_url}{path}"
logger.info("Firefly GET %s params=%s", url, params)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=self.headers, params=params)
logger.info("Firefly response %s %s", response.status_code, url)
response.raise_for_status()
data = response.json().get("data", [])
logger.info("Firefly returned %d items for %s", len(data) if hasattr(data, '__len__') else 0, path)
return data
except Exception as e:
logger.exception("Error fetching from Firefly %s: %s", url, e)
# re-raise so callers can handle/fail loudly
raise
async def get_transactions(self, start_date=None, end_date=None):
params = {}
if start_date:
params["start_date"] = start_date
if end_date:
params["end_date"] = end_date
return await self._get("/api/v1/transactions", params=params)
async def get_categories(self):
return await self._get("/api/v1/categories")
async def get_accounts(self):
return await self._get("/api/v1/accounts")
firefly_client = FireflyClient()

12
backend/app/config.py Normal file
View File

@@ -0,0 +1,12 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
firefly_url: str = "https://firefly.scsimedia.duckdns.org"
firefly_api_token: str = ""
database_url: str = "sqlite:///./data/app.db"
sync_interval_minutes: int = 30
class Config:
env_file = ".env"
settings = Settings()

14
backend/app/database.py Normal file
View File

@@ -0,0 +1,14 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from .config import settings
from .models import Base
engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

31
backend/app/main.py Normal file
View File

@@ -0,0 +1,31 @@
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine
from .models import *
from .routers import accounts, categories, reports, summary, transactions
app = FastAPI(title="Firefly III Analytics")
# Enable INFO level logging for backend services so Firefly client logs appear
logging.basicConfig(level=logging.INFO)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(summary.router, prefix="/api/summary", tags=["summary"])
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
app.include_router(accounts.router, prefix="/api/accounts", tags=["accounts"])
app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
app.include_router(transactions.router, prefix="/api/transactions", tags=["transactions"])
Base.metadata.create_all(bind=engine)
@app.get("/health")
async def health_check():
return {"status": "healthy"}

16
backend/app/models.py Normal file
View File

@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, Float, DateTime
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
Base = declarative_base()
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=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, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)

View File

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
from ..clients.firefly_client import firefly_client
router = APIRouter()
@router.get("")
async def list_accounts():
accounts = await firefly_client.get_accounts()
return {"data": accounts}

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
from ..clients.firefly_client import firefly_client
router = APIRouter()
@router.get("")
async def list_categories():
categories = await firefly_client.get_categories()
return {"data": categories}

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter
import logging
from ..clients.firefly_client import firefly_client
logger = logging.getLogger("reports_router")
router = APIRouter()
@router.get("/spending-by-category")
async def spending_by_category():
try:
transactions = await firefly_client.get_transactions()
logger.info("Fetched %d transactions from Firefly for reports", len(transactions))
category_totals = {}
for transaction in transactions:
attrs = transaction.get("attributes", {})
legs = attrs.get("transactions", []) or []
for leg in legs:
try:
leg_amount = float(leg.get("amount", 0))
except Exception:
leg_amount = 0.0
leg_type = (leg.get("type") or "").lower()
# we consider 'withdrawal' legs as spending
if leg_type == "withdrawal" or (leg.get("destination_type") or "").lower().startswith("expense"):
category_name = leg.get("category_name") or leg.get("category") or attrs.get("category_name") or attrs.get("category_id") or "Uncategorized"
category_totals[category_name] = category_totals.get(category_name, 0) + abs(leg_amount)
data = [
{"name": name, "amount": amount}
for name, amount in sorted(category_totals.items(), key=lambda item: item[1], reverse=True)
]
return {"data": data}
except Exception as e:
logger.exception("Error computing spending-by-category: %s", e)
# Return 500 with message for frontend debugging
from fastapi import HTTPException
raise HTTPException(status_code=500, detail="Error fetching report data")

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter
from ..clients.firefly_client import firefly_client
router = APIRouter()
@router.get("/dashboard")
async def dashboard_summary():
transactions = await firefly_client.get_transactions()
total_transactions = len(transactions)
total_expenses = 0.0
total_income = 0.0
category_totals = {}
for transaction in transactions:
attrs = transaction.get("attributes", {})
legs = attrs.get("transactions", []) or []
# Each transaction may contain one or more legs; sum per-leg amounts
for leg in legs:
# amount is typically a string like "4.870000000000"
try:
leg_amount = float(leg.get("amount", 0))
except Exception:
leg_amount = 0.0
leg_type = (leg.get("type") or "").lower()
# classify by leg type: 'withdrawal' -> expense, 'deposit' -> income
if leg_type == "withdrawal" or (leg.get("destination_type") or "").lower().startswith("expense"):
total_expenses += abs(leg_amount)
category_name = leg.get("category_name") or leg.get("category") or attrs.get("category_name") or attrs.get("category_id") or "Uncategorized"
category_totals[category_name] = category_totals.get(category_name, 0) + abs(leg_amount)
elif leg_type == "deposit" or (leg.get("destination_type") or "").lower().startswith("asset"):
total_income += leg_amount
else:
# fallback: if leg_amount > 0 treat as income
if leg_amount > 0:
total_income += leg_amount
top_categories = [
{"name": name, "amount": amount}
for name, amount in sorted(category_totals.items(), key=lambda item: item[1], reverse=True)
][:5]
return {
"total_transactions": total_transactions,
"total_expenses": total_expenses,
"total_income": total_income,
"top_categories": top_categories,
}

View File

@@ -0,0 +1,17 @@
from fastapi import APIRouter
from ..clients.firefly_client import firefly_client
router = APIRouter()
@router.get("")
async def list_transactions():
transactions = await firefly_client.get_transactions()
return {"data": transactions}
@router.get("/sample")
async def transaction_sample():
transactions = await firefly_client.get_transactions()
if transactions:
return {"sample": transactions[0]}
return {"sample": None}

View File

View File

@@ -0,0 +1,8 @@
from apscheduler.schedulers.background import BackgroundScheduler
from datetime import datetime
scheduler = BackgroundScheduler()
def start_sync_scheduler():
if not scheduler.running:
scheduler.start()

8
backend/requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi>=0.100.0
uvicorn>=0.20.0
sqlalchemy>=2.0.0
httpx>=0.24.0
apscheduler>=3.10.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0