Initial commit: MyMidas personal finance tracker
Full-stack self-hosted finance app with FastAPI backend and React frontend. Features: - Accounts, transactions, budgets, investments with GBP base currency - CSV import with auto-detection for 10 UK bank formats - ML predictions: spending forecast, net worth projection, Monte Carlo - 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger) - Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF) - AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log - Encrypted nightly backups + key rotation script - Mobile-responsive layout with bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
61a7884ee5
127 changed files with 13323 additions and 0 deletions
356
backend/app/services/report_service.py
Normal file
356
backend/app/services/report_service.py
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from sqlalchemy import and_, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models.account import Account
|
||||
from app.db.models.budget import Budget
|
||||
from app.db.models.category import Category
|
||||
from app.db.models.net_worth_snapshot import NetWorthSnapshot
|
||||
from app.db.models.transaction import Transaction
|
||||
from app.schemas.report import (
|
||||
BudgetVsActualItem,
|
||||
BudgetVsActualReport,
|
||||
CashFlowPoint,
|
||||
CashFlowReport,
|
||||
CategoryBreakdownItem,
|
||||
CategoryBreakdownReport,
|
||||
IncomeExpensePoint,
|
||||
IncomeExpenseReport,
|
||||
NetWorthPoint,
|
||||
NetWorthReport,
|
||||
SpendingTrendPoint,
|
||||
SpendingTrendsReport,
|
||||
)
|
||||
|
||||
LIABILITY_TYPES = {"credit_card", "loan", "mortgage"}
|
||||
|
||||
|
||||
async def _current_net_worth(db: AsyncSession, user_id: uuid.UUID) -> tuple[Decimal, Decimal]:
|
||||
result = await db.execute(
|
||||
select(Account).where(
|
||||
Account.user_id == user_id,
|
||||
Account.include_in_net_worth == True, # noqa: E712
|
||||
Account.is_active == True, # noqa: E712
|
||||
Account.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
accounts = result.scalars().all()
|
||||
assets = Decimal("0")
|
||||
liabilities = Decimal("0")
|
||||
for acc in accounts:
|
||||
bal = acc.current_balance or Decimal("0")
|
||||
if acc.type in LIABILITY_TYPES:
|
||||
liabilities += bal
|
||||
else:
|
||||
assets += bal
|
||||
return assets, liabilities
|
||||
|
||||
|
||||
async def get_net_worth_report(
|
||||
db: AsyncSession, user_id: uuid.UUID, base_currency: str, months: int = 12
|
||||
) -> NetWorthReport:
|
||||
cutoff = date.today() - relativedelta(months=months)
|
||||
result = await db.execute(
|
||||
select(NetWorthSnapshot)
|
||||
.where(NetWorthSnapshot.user_id == user_id, NetWorthSnapshot.date >= cutoff)
|
||||
.order_by(NetWorthSnapshot.date.asc())
|
||||
)
|
||||
snapshots = result.scalars().all()
|
||||
|
||||
points = [
|
||||
NetWorthPoint(
|
||||
date=s.date,
|
||||
total_assets=s.total_assets,
|
||||
total_liabilities=s.total_liabilities,
|
||||
net_worth=s.net_worth,
|
||||
base_currency=s.base_currency,
|
||||
)
|
||||
for s in snapshots
|
||||
]
|
||||
|
||||
assets, liabilities = await _current_net_worth(db, user_id)
|
||||
current_nw = assets - liabilities
|
||||
|
||||
change_30d = Decimal("0")
|
||||
change_30d_pct = Decimal("0")
|
||||
if points:
|
||||
past_nw = points[0].net_worth
|
||||
change_30d = current_nw - past_nw
|
||||
if past_nw != 0:
|
||||
change_30d_pct = (change_30d / abs(past_nw) * 100).quantize(Decimal("0.01"))
|
||||
|
||||
return NetWorthReport(
|
||||
points=points,
|
||||
current_net_worth=current_nw,
|
||||
change_30d=change_30d,
|
||||
change_30d_pct=change_30d_pct,
|
||||
base_currency=base_currency,
|
||||
)
|
||||
|
||||
|
||||
async def get_income_expense_report(
|
||||
db: AsyncSession, user_id: uuid.UUID, months: int = 12
|
||||
) -> IncomeExpenseReport:
|
||||
cutoff = (date.today().replace(day=1) - relativedelta(months=months - 1))
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
TO_CHAR(date, 'YYYY-MM') AS month,
|
||||
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) AS income,
|
||||
SUM(CASE WHEN type = 'expense' THEN ABS(amount) ELSE 0 END) AS expenses
|
||||
FROM transactions
|
||||
WHERE user_id = CAST(:uid AS uuid)
|
||||
AND status != 'void'
|
||||
AND deleted_at IS NULL
|
||||
AND date >= :cutoff
|
||||
GROUP BY TO_CHAR(date, 'YYYY-MM')
|
||||
ORDER BY month ASC
|
||||
""").bindparams(uid=str(user_id), cutoff=cutoff)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
points = []
|
||||
total_income = Decimal("0")
|
||||
total_expenses = Decimal("0")
|
||||
for row in rows:
|
||||
inc = Decimal(str(row.income or 0))
|
||||
exp = Decimal(str(row.expenses or 0))
|
||||
points.append(IncomeExpensePoint(month=row.month, income=inc, expenses=exp, net=inc - exp))
|
||||
total_income += inc
|
||||
total_expenses += exp
|
||||
|
||||
n = len(points) or 1
|
||||
return IncomeExpenseReport(
|
||||
points=points,
|
||||
total_income=total_income,
|
||||
total_expenses=total_expenses,
|
||||
avg_monthly_income=(total_income / n).quantize(Decimal("0.01")),
|
||||
avg_monthly_expenses=(total_expenses / n).quantize(Decimal("0.01")),
|
||||
currency="GBP",
|
||||
)
|
||||
|
||||
|
||||
async def get_cash_flow_report(
|
||||
db: AsyncSession, user_id: uuid.UUID, date_from: date, date_to: date
|
||||
) -> CashFlowReport:
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
date,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS inflow,
|
||||
SUM(CASE WHEN amount < 0 THEN ABS(amount) ELSE 0 END) AS outflow
|
||||
FROM transactions
|
||||
WHERE user_id = CAST(:uid AS uuid)
|
||||
AND status != 'void'
|
||||
AND deleted_at IS NULL
|
||||
AND date BETWEEN :df AND :dt
|
||||
AND type IN ('income', 'expense')
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
""").bindparams(uid=str(user_id), df=date_from, dt=date_to)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
points = []
|
||||
running = Decimal("0")
|
||||
total_inflow = Decimal("0")
|
||||
total_outflow = Decimal("0")
|
||||
for row in rows:
|
||||
inflow = Decimal(str(row.inflow or 0))
|
||||
outflow = Decimal(str(row.outflow or 0))
|
||||
running += inflow - outflow
|
||||
total_inflow += inflow
|
||||
total_outflow += outflow
|
||||
points.append(
|
||||
CashFlowPoint(
|
||||
date=row.date,
|
||||
inflow=inflow,
|
||||
outflow=outflow,
|
||||
net=inflow - outflow,
|
||||
running_balance=running,
|
||||
)
|
||||
)
|
||||
|
||||
return CashFlowReport(
|
||||
points=points,
|
||||
total_inflow=total_inflow,
|
||||
total_outflow=total_outflow,
|
||||
currency="GBP",
|
||||
)
|
||||
|
||||
|
||||
async def get_category_breakdown(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
txn_type: str = "expense",
|
||||
) -> CategoryBreakdownReport:
|
||||
result = await db.execute(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
func.sum(func.abs(Transaction.amount)).label("total"),
|
||||
func.count(Transaction.id).label("cnt"),
|
||||
)
|
||||
.where(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.type == txn_type,
|
||||
Transaction.status != "void",
|
||||
Transaction.date >= date_from,
|
||||
Transaction.date <= date_to,
|
||||
Transaction.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(Transaction.category_id)
|
||||
.order_by(func.sum(func.abs(Transaction.amount)).desc())
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
grand_total = Decimal("0")
|
||||
raw = []
|
||||
for row in rows:
|
||||
amt = Decimal(str(row.total or 0))
|
||||
grand_total += amt
|
||||
if row.category_id:
|
||||
cat_result = await db.execute(select(Category).where(Category.id == row.category_id))
|
||||
category = cat_result.scalar_one_or_none()
|
||||
cat_name = category.name if category else "Uncategorised"
|
||||
else:
|
||||
cat_name = "Uncategorised"
|
||||
raw.append((row.category_id, cat_name, amt, row.cnt))
|
||||
|
||||
items = [
|
||||
CategoryBreakdownItem(
|
||||
category_id=str(cat_id) if cat_id else None,
|
||||
category_name=name,
|
||||
amount=amt,
|
||||
percent=(amt / grand_total * 100).quantize(Decimal("0.01")) if grand_total > 0 else Decimal("0"),
|
||||
transaction_count=cnt,
|
||||
)
|
||||
for cat_id, name, amt, cnt in raw
|
||||
]
|
||||
|
||||
return CategoryBreakdownReport(
|
||||
items=items,
|
||||
total=grand_total,
|
||||
currency="GBP",
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
|
||||
async def get_budget_vs_actual(db: AsyncSession, user_id: uuid.UUID) -> BudgetVsActualReport:
|
||||
from app.services.budget_service import list_budgets, _period_bounds
|
||||
today = date.today()
|
||||
budgets = await list_budgets(db, user_id, active_only=True)
|
||||
|
||||
items = []
|
||||
total_budgeted = Decimal("0")
|
||||
total_actual = Decimal("0")
|
||||
|
||||
for budget in budgets:
|
||||
period_start, period_end = _period_bounds(budget.period, today)
|
||||
cat_result = await db.execute(select(Category).where(Category.id == budget.category_id))
|
||||
category = cat_result.scalar_one_or_none()
|
||||
cat_name = category.name if category else "Unknown"
|
||||
|
||||
spent_result = await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount)), Decimal("0")))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.category_id == budget.category_id,
|
||||
Transaction.type == "expense",
|
||||
Transaction.status != "void",
|
||||
Transaction.date >= period_start,
|
||||
Transaction.date <= period_end,
|
||||
Transaction.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
actual = Decimal(str(spent_result.scalar() or 0))
|
||||
variance = budget.amount - actual
|
||||
pct = (actual / budget.amount * 100).quantize(Decimal("0.01")) if budget.amount > 0 else Decimal("0")
|
||||
|
||||
items.append(
|
||||
BudgetVsActualItem(
|
||||
budget_id=str(budget.id),
|
||||
budget_name=budget.name,
|
||||
category_name=cat_name,
|
||||
budgeted=budget.amount,
|
||||
actual=actual,
|
||||
variance=variance,
|
||||
percent_used=pct,
|
||||
)
|
||||
)
|
||||
total_budgeted += budget.amount
|
||||
total_actual += actual
|
||||
|
||||
return BudgetVsActualReport(
|
||||
items=items,
|
||||
total_budgeted=total_budgeted,
|
||||
total_actual=total_actual,
|
||||
currency="GBP",
|
||||
)
|
||||
|
||||
|
||||
async def get_spending_trends(
|
||||
db: AsyncSession, user_id: uuid.UUID, months: int = 6
|
||||
) -> SpendingTrendsReport:
|
||||
cutoff = (date.today().replace(day=1) - relativedelta(months=months - 1))
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
TO_CHAR(t.date, 'YYYY-MM') AS month,
|
||||
COALESCE(c.name, 'Uncategorised') AS category_name,
|
||||
SUM(ABS(t.amount)) AS amount
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
WHERE t.user_id = CAST(:uid AS uuid)
|
||||
AND t.type = 'expense'
|
||||
AND t.status != 'void'
|
||||
AND t.deleted_at IS NULL
|
||||
AND t.date >= :cutoff
|
||||
GROUP BY TO_CHAR(t.date, 'YYYY-MM'), c.name
|
||||
ORDER BY month ASC, amount DESC
|
||||
""").bindparams(uid=str(user_id), cutoff=cutoff)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
points = [
|
||||
SpendingTrendPoint(month=row.month, category_name=row.category_name, amount=Decimal(str(row.amount or 0)))
|
||||
for row in rows
|
||||
]
|
||||
categories = list(dict.fromkeys(p.category_name for p in points))
|
||||
|
||||
return SpendingTrendsReport(points=points, categories=categories, currency="GBP")
|
||||
|
||||
|
||||
async def take_net_worth_snapshot(db: AsyncSession, user_id: uuid.UUID, base_currency: str) -> None:
|
||||
today = date.today()
|
||||
existing = await db.execute(
|
||||
select(NetWorthSnapshot).where(
|
||||
NetWorthSnapshot.user_id == user_id,
|
||||
NetWorthSnapshot.date == today,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return
|
||||
|
||||
assets, liabilities = await _current_net_worth(db, user_id)
|
||||
snapshot = NetWorthSnapshot(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
date=today,
|
||||
total_assets=assets,
|
||||
total_liabilities=liabilities,
|
||||
net_worth=assets - liabilities,
|
||||
base_currency=base_currency,
|
||||
breakdown={},
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(snapshot)
|
||||
await db.flush()
|
||||
Loading…
Add table
Add a link
Reference in a new issue