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
332
backend/app/api/v1/transactions.py
Normal file
332
backend/app/api/v1/transactions.py
Normal 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"},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue