Phase 3 — Investments: - Multi-currency support: holdings track purchase currency, FX rates convert to base for totals - Capital gains report using UK Section 104 pool method, grouped by tax year - Capital Gains tab added to Reports page Phase 5 — Polish & Hardening: - Mobile-responsive layout: bottom nav, sidebar hidden on mobile, logo in TopBar, compact header buttons, hover-only actions now always visible on touch - Backup system: encrypted GPG backups via backup.sh, nightly scheduler job, admin API (list/trigger/download/restore), Settings UI with drag-to-restore confirmation - Docker entrypoint with gosu privilege drop to fix bind-mount ownership on fresh deployments - OWASP fixes: refresh token now bound to its session (new refresh_token_hash column + migration), CSRF secure flag tied to environment, IP-level rate limiting on login, TOTPEnableRequest Pydantic schema replaces raw dict - AES-256-GCM key rotation script (rotate_keys.py) with dry-run mode and atomic DB transaction - CLAUDE.md added for AI-assisted development context - README updated: correct reverse proxy port, accurate backup/restore commands, key rotation instructions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
334 lines
11 KiB
Python
334 lines
11 KiB
Python
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,
|
|
is_recurring: bool | 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,
|
|
is_recurring=is_recurring,
|
|
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"},
|
|
)
|