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:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

View 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()