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,332 @@
import csv
import io
import mimetypes
import os
import uuid
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.audit import write_audit
from app.dependencies import get_current_user, get_db
from app.schemas.transaction import TransactionCreate, TransactionFilter, TransactionUpdate
from app.services.transaction_service import (
TransactionError,
create_transaction,
delete_transaction,
get_transaction,
import_csv,
list_transactions,
update_transaction,
_to_response,
)
MAX_IMPORT_FILE_BYTES = 10 * 1024 * 1024 # 10 MB
MAX_IMPORT_ROWS = 50_000
ALLOWED_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
}
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
router = APIRouter()
@router.get("")
async def get_transactions(
account_id: uuid.UUID | None = None,
category_id: uuid.UUID | None = None,
type: str | None = None,
status: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
search: str | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
from datetime import date
filters = TransactionFilter(
account_id=account_id,
category_id=category_id,
type=type,
status=status,
date_from=date.fromisoformat(date_from) if date_from else None,
date_to=date.fromisoformat(date_to) if date_to else None,
search=search,
page=page,
page_size=page_size,
)
return await list_transactions(db, user.id, filters)
@router.post("", status_code=201)
async def create(
body: TransactionCreate,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
result = await create_transaction(db, user.id, body, user.base_currency)
await write_audit(db, user_id=user.id, action="transaction_create")
await db.commit()
return result
except TransactionError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
@router.get("/{txn_id}")
async def get_one(
txn_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
txn = await get_transaction(db, txn_id, user.id)
return _to_response(txn)
except TransactionError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
@router.put("/{txn_id}")
async def update(
txn_id: uuid.UUID,
body: TransactionUpdate,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
result = await update_transaction(db, txn_id, user.id, body, user.base_currency)
await write_audit(db, user_id=user.id, action="transaction_update", resource_type="transaction", resource_id=txn_id)
await db.commit()
return result
except TransactionError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
@router.delete("/{txn_id}", status_code=204)
async def delete(
txn_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
await delete_transaction(db, txn_id, user.id)
await write_audit(db, user_id=user.id, action="transaction_delete", resource_type="transaction", resource_id=txn_id)
await db.commit()
except TransactionError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
@router.post("/{txn_id}/attachments")
async def upload_attachment(
txn_id: uuid.UUID,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
settings = get_settings()
# Validate extension
filename = file.filename or "upload"
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="Unsupported file type. Allowed: JPG, PNG, WebP, PDF")
# Verify transaction ownership
try:
txn = await get_transaction(db, txn_id, user.id)
except TransactionError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
current_refs: list = txn.get("attachment_refs", []) if isinstance(txn, dict) else []
# Fetch raw model for JSONB mutation
from sqlalchemy import select
from app.db.models.transaction import Transaction as TxnModel
result = await db.execute(
select(TxnModel).where(TxnModel.id == txn_id, TxnModel.user_id == user.id)
)
txn_row = result.scalar_one_or_none()
if not txn_row:
raise HTTPException(status_code=404, detail="Transaction not found")
current_refs = list(txn_row.attachment_refs or [])
if len(current_refs) >= settings.max_attachments_per_txn:
raise HTTPException(status_code=400, detail=f"Maximum {settings.max_attachments_per_txn} attachments per transaction")
# Read and size-check
content = await file.read(settings.max_attachment_bytes + 1)
if len(content) > settings.max_attachment_bytes:
raise HTTPException(status_code=413, detail="File too large (max 10 MB)")
# Sniff MIME from content
import magic # python-magic
detected_mime = magic.from_buffer(content[:2048], mime=True)
if detected_mime not in ALLOWED_MIME_TYPES:
raise HTTPException(status_code=400, detail="File content does not match an allowed type (JPEG, PNG, WebP, PDF)")
# Store file
attachment_id = str(uuid.uuid4())
user_upload_dir = Path(settings.upload_dir) / str(user.id)
user_upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = f"{attachment_id}{ext}"
stored_path = user_upload_dir / stored_name
stored_path.write_bytes(content)
# Update attachment_refs
ref = {
"id": attachment_id,
"filename": filename,
"mime_type": detected_mime,
"size": len(content),
"stored_name": stored_name,
}
from sqlalchemy import update as sql_update
import copy
new_refs = copy.copy(current_refs)
new_refs.append(ref)
await db.execute(
sql_update(TxnModel)
.where(TxnModel.id == txn_id)
.values(attachment_refs=new_refs)
)
await write_audit(db, user_id=user.id, action="transaction_update", resource_type="transaction", resource_id=txn_id, metadata={"attachment_added": attachment_id})
await db.commit()
return ref
@router.get("/{txn_id}/attachments/{attachment_id}")
async def download_attachment(
txn_id: uuid.UUID,
attachment_id: str,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
settings = get_settings()
from sqlalchemy import select
from app.db.models.transaction import Transaction as TxnModel
result = await db.execute(
select(TxnModel).where(TxnModel.id == txn_id, TxnModel.user_id == user.id)
)
txn_row = result.scalar_one_or_none()
if not txn_row:
raise HTTPException(status_code=404, detail="Transaction not found")
ref = next((r for r in (txn_row.attachment_refs or []) if r["id"] == attachment_id), None)
if not ref:
raise HTTPException(status_code=404, detail="Attachment not found")
path = Path(settings.upload_dir) / str(user.id) / ref["stored_name"]
if not path.exists():
raise HTTPException(status_code=404, detail="Attachment file missing")
return FileResponse(
path=str(path),
media_type=ref["mime_type"],
filename=ref["filename"],
)
@router.delete("/{txn_id}/attachments/{attachment_id}", status_code=204)
async def delete_attachment(
txn_id: uuid.UUID,
attachment_id: str,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
settings = get_settings()
from sqlalchemy import select, update as sql_update
from app.db.models.transaction import Transaction as TxnModel
result = await db.execute(
select(TxnModel).where(TxnModel.id == txn_id, TxnModel.user_id == user.id)
)
txn_row = result.scalar_one_or_none()
if not txn_row:
raise HTTPException(status_code=404, detail="Transaction not found")
refs = list(txn_row.attachment_refs or [])
ref = next((r for r in refs if r["id"] == attachment_id), None)
if not ref:
raise HTTPException(status_code=404, detail="Attachment not found")
# Delete file
path = Path(settings.upload_dir) / str(user.id) / ref["stored_name"]
try:
path.unlink(missing_ok=True)
except OSError:
pass
new_refs = [r for r in refs if r["id"] != attachment_id]
await db.execute(
sql_update(TxnModel)
.where(TxnModel.id == txn_id)
.values(attachment_refs=new_refs)
)
await write_audit(db, user_id=user.id, action="transaction_update", resource_type="transaction", resource_id=txn_id, metadata={"attachment_deleted": attachment_id})
await db.commit()
@router.post("/import")
async def import_transactions(
file: UploadFile = File(...),
account_id: uuid.UUID = Form(...),
date_col: str = Form(default="date"),
description_col: str = Form(default="description"),
amount_col: str = Form(default="amount"),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
if not file.filename or not file.filename.lower().endswith(".csv"):
raise HTTPException(status_code=400, detail="Only CSV files are supported")
content = await file.read(MAX_IMPORT_FILE_BYTES + 1)
if len(content) > MAX_IMPORT_FILE_BYTES:
raise HTTPException(status_code=413, detail="File too large (max 10 MB)")
try:
text = content.decode("utf-8-sig") # handle BOM
except UnicodeDecodeError:
text = content.decode("latin-1")
reader = csv.DictReader(io.StringIO(text))
rows = []
for row in reader:
if len(rows) >= MAX_IMPORT_ROWS:
raise HTTPException(status_code=400, detail=f"File contains too many rows (max {MAX_IMPORT_ROWS:,})")
mapped = {}
# Flexible column mapping
for key, col in [("date", date_col), ("description", description_col), ("amount", amount_col)]:
val = row.get(col) or row.get(col.lower()) or row.get(col.upper())
if val is not None:
mapped[key] = val.strip()
if "date" in mapped and "amount" in mapped:
mapped.setdefault("description", "Imported transaction")
rows.append(mapped)
if not rows:
raise HTTPException(status_code=400, detail="No valid rows found. Check column names.")
result = await import_csv(db, user.id, account_id, rows, user.base_currency)
await write_audit(db, user_id=user.id, action="import_data", metadata=result)
await db.commit()
return result
@router.get("/import/template")
async def import_template():
from fastapi.responses import Response
csv_content = "date,description,amount,merchant,notes\n2026-01-15,Tesco Groceries,-45.67,Tesco,\n2026-01-14,Salary,2500.00,Employer,January salary\n"
return Response(
content=csv_content,
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=import_template.csv"},
)