diff --git a/backend/app/api/v1/transactions.py b/backend/app/api/v1/transactions.py index 58e645c..1fc2e5d 100644 --- a/backend/app/api/v1/transactions.py +++ b/backend/app/api/v1/transactions.py @@ -278,59 +278,28 @@ 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.""" +_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 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: + 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) - - 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." - ) - custom_base_url = (user_row.ai_base_url or "").rstrip("/") custom_model = (user_row.ai_model or "").strip() @@ -339,28 +308,14 @@ async def parse_attachment( 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}, - } + 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}, - } + 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": prompt}]}], - }, + 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() @@ -374,17 +329,10 @@ async def parse_attachment( 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": prompt}, - ], - }], - }, + 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() @@ -397,7 +345,6 @@ async def parse_attachment( 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"): @@ -419,6 +366,65 @@ async def parse_attachment( } +@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(...), diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index e9b6ed5..f6b44dd 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -41,3 +41,12 @@ export async function parseReceipt(txnId: string, attachmentId: string): Promise const { data } = await api.post(`/transactions/${txnId}/attachments/${attachmentId}/parse`); return data; } + +export async function parseReceiptFile(file: File): Promise { + const form = new FormData(); + form.append("file", file); + const { data } = await api.post("/transactions/parse-receipt", form, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return data; +} diff --git a/frontend/src/pages/transactions/TransactionFormModal.tsx b/frontend/src/pages/transactions/TransactionFormModal.tsx index 145a5e8..1486e31 100644 --- a/frontend/src/pages/transactions/TransactionFormModal.tsx +++ b/frontend/src/pages/transactions/TransactionFormModal.tsx @@ -2,7 +2,7 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { format } from "date-fns"; -import { X, Loader2 } from "lucide-react"; +import { X, Loader2, Sparkles } from "lucide-react"; import type { Account } from "@/api/accounts"; const schema = z.object({ @@ -21,22 +21,35 @@ const schema = z.object({ type Form = z.infer; +export interface TransactionInitialValues { + description?: string; + merchant?: string; + amount?: number; + date?: string; + currency?: string; +} + interface Props { accounts: Account[]; categories: { id: string; name: string; type: string }[]; onClose: () => void; onSubmit: (data: any) => void; isLoading: boolean; + initialValues?: TransactionInitialValues; + parsedFromReceipt?: boolean; } -export default function TransactionFormModal({ accounts, categories, onClose, onSubmit, isLoading }: Props) { +export default function TransactionFormModal({ accounts, categories, onClose, onSubmit, isLoading, initialValues, parsedFromReceipt }: Props) { const { register, handleSubmit, watch, formState: { errors } } = useForm
({ resolver: zodResolver(schema), defaultValues: { type: "expense", status: "cleared", - currency: "GBP", - date: format(new Date(), "yyyy-MM-dd"), + currency: initialValues?.currency ?? "GBP", + date: initialValues?.date ?? format(new Date(), "yyyy-MM-dd"), + description: initialValues?.description ?? "", + merchant: initialValues?.merchant ?? "", + amount: initialValues?.amount ? Math.abs(initialValues.amount) : undefined, }, }); @@ -70,6 +83,13 @@ export default function TransactionFormModal({ accounts, categories, onClose, on + {parsedFromReceipt && ( +
+ + Fields pre-filled from receipt — review before saving +
+ )} + {/* Type */}
diff --git a/frontend/src/pages/transactions/TransactionList.tsx b/frontend/src/pages/transactions/TransactionList.tsx index eaadaaf..5af1641 100644 --- a/frontend/src/pages/transactions/TransactionList.tsx +++ b/frontend/src/pages/transactions/TransactionList.tsx @@ -1,16 +1,19 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getTransactions, deleteTransaction, createTransaction, getCategories } from "@/api/transactions"; import type { Transaction } from "@/api/transactions"; import { getAccounts } from "@/api/accounts"; +import { parseReceiptFile } from "@/api/settings"; +import type { ParsedReceipt } from "@/api/settings"; import { formatCurrency } from "@/utils/currency"; import { cn } from "@/utils/cn"; import { format, startOfMonth, subMonths, startOfYear } from "date-fns"; import { Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload, - ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw + ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw, ScanLine, Loader2, } from "lucide-react"; import TransactionFormModal from "./TransactionFormModal"; +import type { TransactionInitialValues } from "./TransactionFormModal"; import TransactionDetailDrawer from "./TransactionDetailDrawer"; import { Link } from "react-router-dom"; @@ -49,6 +52,10 @@ export default function TransactionList() { const qc = useQueryClient(); const [showForm, setShowForm] = useState(false); const [selectedTxn, setSelectedTxn] = useState(null); + const [receiptParsed, setReceiptParsed] = useState(null); + const [scanError, setScanError] = useState(null); + const [scanning, setScanning] = useState(false); + const receiptInputRef = useRef(null); const [search, setSearch] = useState(""); const [filterType, setFilterType] = useState(""); const [filterAccount, setFilterAccount] = useState(""); @@ -89,9 +96,25 @@ export default function TransactionList() { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["accounts"] }); setShowForm(false); + setReceiptParsed(null); }, }); + async function handleReceiptFile(file: File) { + setScanning(true); + setScanError(null); + try { + const parsed = await parseReceiptFile(file); + setReceiptParsed(parsed); + setShowForm(true); + } catch (e: any) { + setScanError(e?.response?.data?.detail ?? "Could not parse receipt. Check your AI settings."); + } finally { + setScanning(false); + if (receiptInputRef.current) receiptInputRef.current.value = ""; + } + } + const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a])); const categoryMap = Object.fromEntries(categories.map((c) => [c.id, c])); @@ -114,15 +137,38 @@ export default function TransactionList() { Import CSV +
+ { const f = e.target.files?.[0]; if (f) handleReceiptFile(f); }} + /> + {scanError && ( +
+ {scanError} + +
+ )} + {/* Date presets */}
{DATE_PRESETS.map((p) => ( @@ -301,9 +347,17 @@ export default function TransactionList() { setShowForm(false)} + onClose={() => { setShowForm(false); setReceiptParsed(null); }} onSubmit={(data) => createMutation.mutate(data)} isLoading={createMutation.isPending} + initialValues={receiptParsed ? { + description: receiptParsed.description ?? undefined, + merchant: receiptParsed.merchant ?? undefined, + amount: receiptParsed.amount ?? undefined, + date: receiptParsed.date ?? undefined, + currency: receiptParsed.currency ?? undefined, + } : undefined} + parsedFromReceipt={!!receiptParsed} /> )}