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() _RECEIPT_PROMPT = ( "You are a receipt parser. Extract information from this receipt and return ONLY a JSON object " "with exactly these keys (use null for any field you cannot determine):\n" '{"merchant": "store name", "amount": 0.00, "currency": "GBP", ' '"date": "YYYY-MM-DD", "description": "brief description", ' '"category": "one of: Food & Drink, Transport, Shopping, Entertainment, Health, Travel, Bills & Utilities, Other"}\n' "Return ONLY the JSON object. No markdown, no explanation, no code fences." ) async def _call_ai_parse(file_bytes: bytes, mime_type: str, user_row) -> dict: """Call the configured AI provider and return parsed receipt fields.""" import base64 import json import httpx from app.core.security import decrypt_field if not user_row.ai_provider or not user_row.ai_api_key_enc: raise HTTPException(status_code=400, detail="No AI provider configured. Add your API key in Settings → AI.") api_key = decrypt_field(user_row.ai_api_key_enc) b64 = base64.standard_b64encode(file_bytes).decode() custom_base_url = (user_row.ai_base_url or "").rstrip("/") custom_model = (user_row.ai_model or "").strip() try: if user_row.ai_provider == "anthropic": base_url = custom_base_url or "https://api.anthropic.com" model = custom_model or "claude-haiku-4-5-20251001" if mime_type == "application/pdf": content_block = {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": b64}} else: content_block = {"type": "image", "source": {"type": "base64", "media_type": mime_type, "data": b64}} async with httpx.AsyncClient(timeout=60) as client: resp = await client.post( f"{base_url}/v1/messages", headers={"x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json"}, json={"model": model, "max_tokens": 512, "messages": [{"role": "user", "content": [content_block, {"type": "text", "text": _RECEIPT_PROMPT}]}]}, ) resp.raise_for_status() text = resp.json()["content"][0]["text"].strip() elif user_row.ai_provider == "openai": base_url = custom_base_url or "https://api.openai.com" model = custom_model or "gpt-4o-mini" if mime_type == "application/pdf" and not custom_base_url: raise HTTPException(status_code=400, detail="PDF parsing is not supported with the OpenAI provider. Use an image format or switch to Anthropic.") async with httpx.AsyncClient(timeout=60) as client: resp = await client.post( f"{base_url}/v1/chat/completions", headers={"Authorization": f"Bearer {api_key}", "content-type": "application/json"}, json={"model": model, "max_tokens": 512, "messages": [{"role": "user", "content": [ {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{b64}"}}, {"type": "text", "text": _RECEIPT_PROMPT}, ]}]}, ) resp.raise_for_status() text = resp.json()["choices"][0]["message"]["content"].strip() else: raise HTTPException(status_code=400, detail="Unknown provider") except httpx.HTTPStatusError as e: raise HTTPException(status_code=502, detail=f"AI provider error: {e.response.status_code}") except httpx.RequestError: raise HTTPException(status_code=502, detail="Could not reach AI provider") if text.startswith("```"): text = text.split("```")[1] if text.startswith("json"): text = text[4:] text = text.strip() try: parsed = json.loads(text) except json.JSONDecodeError: raise HTTPException(status_code=502, detail="AI returned an unexpected response. Try again.") return { "merchant": parsed.get("merchant"), "amount": parsed.get("amount"), "currency": parsed.get("currency"), "date": parsed.get("date"), "description": parsed.get("description"), "category": parsed.get("category"), "raw": text, } @router.post("/parse-receipt") async def parse_receipt_upload( file: UploadFile = File(...), db: AsyncSession = Depends(get_db), user=Depends(get_current_user), ): """Upload a receipt image and parse it with AI — no existing transaction required.""" from app.db.models.user import User as UserModel settings = get_settings() 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") 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)") import magic mime_type = magic.from_buffer(content[:2048], mime=True) if mime_type not in ALLOWED_MIME_TYPES: raise HTTPException(status_code=400, detail="File content does not match an allowed type") user_row = await db.get(UserModel, user.id) return await _call_ai_parse(content, mime_type, user_row) @router.post("/{txn_id}/attachments/{attachment_id}/parse") async def parse_attachment( txn_id: uuid.UUID, attachment_id: str, db: AsyncSession = Depends(get_db), user=Depends(get_current_user), ): """Parse an already-uploaded attachment with AI.""" from sqlalchemy import select from app.db.models.transaction import Transaction as TxnModel from app.db.models.user import User as UserModel settings = get_settings() user_row = await db.get(UserModel, user.id) 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 await _call_ai_parse(path.read_bytes(), ref["mime_type"], user_row) @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"}, )