diff --git a/backend/alembic/versions/0003_ai_settings.py b/backend/alembic/versions/0003_ai_settings.py new file mode 100644 index 0000000..766efeb --- /dev/null +++ b/backend/alembic/versions/0003_ai_settings.py @@ -0,0 +1,23 @@ +"""add ai_provider and ai_api_key_enc to users + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-04-22 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("ai_provider", sa.Text, nullable=True)) + op.add_column("users", sa.Column("ai_api_key_enc", sa.LargeBinary, nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "ai_api_key_enc") + op.drop_column("users", "ai_provider") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index eed071f..85c5c1d 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin +from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin, settings router = APIRouter() router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -13,3 +13,4 @@ router.include_router(reports.router) router.include_router(investments.router) router.include_router(predictions.router) router.include_router(admin.router) +router.include_router(settings.router) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py new file mode 100644 index 0000000..f49969e --- /dev/null +++ b/backend/app/api/v1/settings.py @@ -0,0 +1,69 @@ +""" +User-level settings: AI provider configuration. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decrypt_field, encrypt_field +from app.dependencies import get_current_user, get_db +from app.db.models.user import User + +router = APIRouter(prefix="/settings", tags=["settings"]) + +SUPPORTED_PROVIDERS = {"anthropic", "openai"} + + +class AiSettingsResponse(BaseModel): + provider: str | None + has_api_key: bool + + +class AiSettingsSave(BaseModel): + provider: str + api_key: str + + +@router.get("/ai", response_model=AiSettingsResponse) +async def get_ai_settings(user: User = Depends(get_current_user)): + return AiSettingsResponse( + provider=user.ai_provider, + has_api_key=bool(user.ai_api_key_enc), + ) + + +@router.put("/ai", response_model=AiSettingsResponse) +async def save_ai_settings( + body: AiSettingsSave, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + if body.provider not in SUPPORTED_PROVIDERS: + raise HTTPException(status_code=400, detail=f"Unsupported provider. Choose: {', '.join(SUPPORTED_PROVIDERS)}") + if not body.api_key.strip(): + raise HTTPException(status_code=400, detail="api_key must not be empty") + + encrypted = encrypt_field(body.api_key.strip()) + await db.execute( + update(User) + .where(User.id == user.id) + .values(ai_provider=body.provider, ai_api_key_enc=encrypted) + ) + await db.commit() + return AiSettingsResponse(provider=body.provider, has_api_key=True) + + +@router.delete("/ai", status_code=204) +async def clear_ai_settings( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + await db.execute( + update(User) + .where(User.id == user.id) + .values(ai_provider=None, ai_api_key_enc=None) + ) + await db.commit() diff --git a/backend/app/api/v1/transactions.py b/backend/app/api/v1/transactions.py index 7345b5f..f34d8f6 100644 --- a/backend/app/api/v1/transactions.py +++ b/backend/app/api/v1/transactions.py @@ -278,6 +278,140 @@ async def delete_attachment( await db.commit() +@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), +): + """Send an attachment to the configured AI provider and extract receipt fields.""" + import base64 + import json + import httpx + from sqlalchemy import select + from app.db.models.transaction import Transaction as TxnModel + from app.db.models.user import User as UserModel + from app.core.security import decrypt_field + + settings = get_settings() + + # Reload user to get ai fields (get_current_user may be cached without them) + user_row = await db.get(UserModel, user.id) + if not user_row or 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) + + 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") + + mime_type = ref["mime_type"] + path = Path(settings.upload_dir) / str(user.id) / ref["stored_name"] + if not path.exists(): + raise HTTPException(status_code=404, detail="Attachment file missing") + + file_bytes = path.read_bytes() + b64 = base64.standard_b64encode(file_bytes).decode() + + 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." + ) + + try: + if user_row.ai_provider == "anthropic": + 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=30) as client: + resp = await client.post( + "https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + json={ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 512, + "messages": [{"role": "user", "content": [content_block, {"type": "text", "text": prompt}]}], + }, + ) + resp.raise_for_status() + text = resp.json()["content"][0]["text"].strip() + + elif user_row.ai_provider == "openai": + if mime_type == "application/pdf": + 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=30) as client: + resp = await client.post( + "https://api.openai.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "content-type": "application/json"}, + json={ + "model": "gpt-4o-mini", + "max_tokens": 512, + "messages": [{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{b64}"}}, + {"type": "text", "text": 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") + + # Strip markdown fences if model wrapped the JSON + 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"), + } + + @router.post("/import") async def import_transactions( file: UploadFile = File(...), diff --git a/backend/app/db/models/user.py b/backend/app/db/models/user.py index 2cd1f19..0f67bbc 100644 --- a/backend/app/db/models/user.py +++ b/backend/app/db/models/user.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, Integer, String, Text +from sqlalchemy import Boolean, DateTime, Integer, LargeBinary, String, Text from sqlalchemy.dialects.postgresql import INET, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -29,5 +29,8 @@ class User(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + ai_provider: Mapped[str | None] = mapped_column(Text, nullable=True) + ai_api_key_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + accounts: Mapped[list["Account"]] = relationship(back_populates="user", lazy="noload") # type: ignore[name-defined] sessions: Mapped[list["Session"]] = relationship(back_populates="user", lazy="noload") # type: ignore[name-defined] diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..eee55a7 --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,34 @@ +import { api } from "./client"; + +export interface AiSettings { + provider: string | null; + has_api_key: boolean; +} + +export interface ParsedReceipt { + merchant: string | null; + amount: number | null; + currency: string | null; + date: string | null; + description: string | null; + category: string | null; +} + +export async function getAiSettings(): Promise { + const { data } = await api.get("/settings/ai"); + return data; +} + +export async function saveAiSettings(provider: string, api_key: string): Promise { + const { data } = await api.put("/settings/ai", { provider, api_key }); + return data; +} + +export async function clearAiSettings(): Promise { + await api.delete("/settings/ai"); +} + +export async function parseReceipt(txnId: string, attachmentId: string): Promise { + const { data } = await api.post(`/transactions/${txnId}/attachments/${attachmentId}/parse`); + return data; +} diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 49dc1ce..46775b2 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -8,13 +8,14 @@ import { changePassword, updateProfile, exportData, getMe, } from "@/api/auth"; import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin"; +import { getAiSettings, saveAiSettings, clearAiSettings } from "@/api/settings"; import type { BackupFile } from "@/api/admin"; import { cn } from "@/utils/cn"; import { format } from "date-fns"; import { User, Shield, MonitorSmartphone, Download, HardDrive, Loader2, CheckCircle, Eye, EyeOff, Trash2, - LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw, + LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw, Sparkles, } from "lucide-react"; const SECTIONS = [ @@ -23,6 +24,7 @@ const SECTIONS = [ { id: "sessions", label: "Sessions", icon: MonitorSmartphone }, { id: "data", label: "Data", icon: Download }, { id: "backups", label: "Backups", icon: HardDrive }, + { id: "ai", label: "AI", icon: Sparkles }, ] as const; type Section = (typeof SECTIONS)[number]["id"]; @@ -64,6 +66,7 @@ export default function SettingsPage() { {section === "sessions" && } {section === "data" && } {section === "backups" && } + {section === "ai" && } @@ -648,6 +651,130 @@ function BackupsSection() { ); } +// ─── AI ─────────────────────────────────────────────────────────────────────── + +function AiSection() { + const qc = useQueryClient(); + const [provider, setProvider] = useState("anthropic"); + const [apiKey, setApiKey] = useState(""); + const [showKey, setShowKey] = useState(false); + const [success, setSuccess] = useState(""); + + const { data: current, isLoading } = useQuery({ + queryKey: ["ai-settings"], + queryFn: getAiSettings, + onSuccess: (d: any) => { if (d.provider) setProvider(d.provider); }, + } as any); + + const saveMutation = useMutation({ + mutationFn: () => saveAiSettings(provider, apiKey), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["ai-settings"] }); + setApiKey(""); + setSuccess("API key saved"); + setTimeout(() => setSuccess(""), 3000); + }, + }); + + const clearMutation = useMutation({ + mutationFn: clearAiSettings, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["ai-settings"] }); + setSuccess("API key removed"); + setTimeout(() => setSuccess(""), 3000); + }, + }); + + return ( +
+
+ + AI Receipt Parsing + {!isLoading && current?.has_api_key && ( + + Configured + + )} +
+ +

+ Upload a receipt photo to any transaction and use AI to automatically extract the merchant, amount, date, and description. + Your API key is stored encrypted on your server and never shared. +

+ + {success && } + {(saveMutation.isError || clearMutation.isError) && ( + + )} + +
+ + +
+ +
+ +
+ setApiKey(e.target.value)} + className={cn(inputCls, "pr-10")} + placeholder={current?.has_api_key ? "••••••••••••••••" : provider === "anthropic" ? "sk-ant-..." : "sk-..."} + /> + +
+

+ {provider === "anthropic" + ? "Get your key at console.anthropic.com → API Keys" + : "Get your key at platform.openai.com → API Keys"} +

+
+ +
+ + + {current?.has_api_key && ( + + )} +
+
+ ); +} + // ─── Data ───────────────────────────────────────────────────────────────────── function DataSection() { diff --git a/frontend/src/pages/transactions/TransactionDetailDrawer.tsx b/frontend/src/pages/transactions/TransactionDetailDrawer.tsx index 3dfc329..01f1909 100644 --- a/frontend/src/pages/transactions/TransactionDetailDrawer.tsx +++ b/frontend/src/pages/transactions/TransactionDetailDrawer.tsx @@ -3,12 +3,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; import { X, Paperclip, Upload, Trash2, FileText, ImageIcon, Loader2, - ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, + ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Sparkles, CheckCircle, } from "lucide-react"; import { cn } from "@/utils/cn"; import { formatCurrency } from "@/utils/currency"; import type { Transaction, AttachmentRef } from "@/api/transactions"; -import { uploadAttachment, deleteAttachment, getAttachmentUrl } from "@/api/transactions"; +import { uploadAttachment, deleteAttachment, getAttachmentUrl, updateTransaction } from "@/api/transactions"; +import { parseReceipt } from "@/api/settings"; +import type { ParsedReceipt } from "@/api/settings"; const TYPE_COLORS = { income: "text-success", @@ -54,6 +56,9 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate const [attachments, setAttachments] = useState(transaction.attachment_refs ?? []); const [dragging, setDragging] = useState(false); const [uploadError, setUploadError] = useState(null); + const [parseResult, setParseResult] = useState<{ attId: string; data: ParsedReceipt } | null>(null); + const [parseError, setParseError] = useState(null); + const [applySuccess, setApplySuccess] = useState(false); const fileInputRef = useRef(null); const Icon = TYPE_ICONS[transaction.type] ?? ArrowDownCircle; @@ -74,10 +79,37 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate mutationFn: (attachmentId: string) => deleteAttachment(transaction.id, attachmentId), onSuccess: (_data, attachmentId) => { setAttachments((prev) => prev.filter((a) => a.id !== attachmentId)); + if (parseResult?.attId === attachmentId) setParseResult(null); qc.invalidateQueries({ queryKey: ["transactions"] }); }, }); + const parseMutation = useMutation({ + mutationFn: (attachmentId: string) => parseReceipt(transaction.id, attachmentId), + onSuccess: (data, attachmentId) => { + setParseResult({ attId: attachmentId, data }); + setParseError(null); + }, + onError: (err: any) => { + setParseError(err?.response?.data?.detail ?? "Parse failed"); + }, + }); + + const applyMutation = useMutation({ + mutationFn: (d: ParsedReceipt) => updateTransaction(transaction.id, { + ...(d.merchant ? { merchant: d.merchant } : {}), + ...(d.amount ? { amount: d.amount } : {}), + ...(d.date ? { date: d.date } : {}), + ...(d.description ? { description: d.description } : {}), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + setParseResult(null); + setApplySuccess(true); + setTimeout(() => setApplySuccess(false), 3000); + }, + }); + const handleFiles = useCallback((files: FileList | null) => { if (!files) return; setUploadError(null); @@ -176,34 +208,77 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate {attachments.length > 0 && (
{attachments.map((att) => ( -
- -
- +
+ +
+ + {att.filename} + +

{humanFileSize(att.size)}

+
+ +
- + + {/* Parse result card */} + {parseResult?.attId === att.id && ( +
+

+ AI parsed result +

+
+ {parseResult.data.merchant &&
Merchant{parseResult.data.merchant}
} + {parseResult.data.amount &&
Amount{parseResult.data.amount} {parseResult.data.currency ?? ""}
} + {parseResult.data.date &&
Date{parseResult.data.date}
} + {parseResult.data.description &&
Description{parseResult.data.description}
} + {parseResult.data.category &&
Category hint{parseResult.data.category}
} +
+
+ + +
+
+ )}
))}
@@ -250,6 +325,16 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate {uploadError && (

{uploadError}

)} + + {parseError && ( +

{parseError}

+ )} + + {applySuccess && ( +
+ Transaction updated from receipt +
+ )}