Initial commit
This commit is contained in:
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Firefly Analytics Backend
|
||||
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/models.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
0
backend/app/clients/__init__.py
Normal file
0
backend/app/clients/__init__.py
Normal file
BIN
backend/app/clients/__pycache__/firefly_client.cpython-314.pyc
Normal file
BIN
backend/app/clients/__pycache__/firefly_client.cpython-314.pyc
Normal file
Binary file not shown.
44
backend/app/clients/firefly_client.py
Normal file
44
backend/app/clients/firefly_client.py
Normal 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
12
backend/app/config.py
Normal 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
14
backend/app/database.py
Normal 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
31
backend/app/main.py
Normal 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
16
backend/app/models.py
Normal 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)
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
BIN
backend/app/routers/__pycache__/accounts.cpython-314.pyc
Normal file
BIN
backend/app/routers/__pycache__/accounts.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/categories.cpython-314.pyc
Normal file
BIN
backend/app/routers/__pycache__/categories.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/reports.cpython-314.pyc
Normal file
BIN
backend/app/routers/__pycache__/reports.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/summary.cpython-314.pyc
Normal file
BIN
backend/app/routers/__pycache__/summary.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/transactions.cpython-314.pyc
Normal file
BIN
backend/app/routers/__pycache__/transactions.cpython-314.pyc
Normal file
Binary file not shown.
9
backend/app/routers/accounts.py
Normal file
9
backend/app/routers/accounts.py
Normal 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}
|
||||
9
backend/app/routers/categories.py
Normal file
9
backend/app/routers/categories.py
Normal 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}
|
||||
44
backend/app/routers/reports.py
Normal file
44
backend/app/routers/reports.py
Normal 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")
|
||||
49
backend/app/routers/summary.py
Normal file
49
backend/app/routers/summary.py
Normal 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,
|
||||
}
|
||||
17
backend/app/routers/transactions.py
Normal file
17
backend/app/routers/transactions.py
Normal 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}
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
8
backend/app/services/sync_service.py
Normal file
8
backend/app/services/sync_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user