diff --git a/.env.example b/.env.example index 97f9b97..8c71a45 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,7 @@ JWT_SECRET= # URLs — used for OIDC callback and frontend redirect APP_URL=http://localhost:3001 FRONTEND_URL=http://localhost:5173 + +# AI receipt tagging (optional) — can also be set in Settings → AI. +# Fallback Anthropic API key used when none is configured in the app. +ANTHROPIC_API_KEY= diff --git a/backend/package-lock.json b/backend/package-lock.json index 97141be..da830fb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "hasInstallScript": true, "dependencies": { + "@anthropic-ai/sdk": "^0.71.0", "@prisma/client": "^5.14.0", "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", @@ -32,6 +33,35 @@ "prisma": "^5.14.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.71.2", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", + "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@panva/asn1.js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", @@ -1037,6 +1067,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1549,6 +1580,19 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2720,6 +2764,12 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/backend/package.json b/backend/package.json index 821a798..a2c7361 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "db:studio": "prisma studio" }, "dependencies": { + "@anthropic-ai/sdk": "^0.71.0", "@prisma/client": "^5.14.0", "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", diff --git a/backend/prisma/migrations/20240110_attachments/migration.sql b/backend/prisma/migrations/20240110_attachments/migration.sql new file mode 100644 index 0000000..b02f186 --- /dev/null +++ b/backend/prisma/migrations/20240110_attachments/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE "Attachment" ( + "id" TEXT NOT NULL PRIMARY KEY, + "transactionId" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "storagePath" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Attachment_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "Attachment_transactionId_idx" ON "Attachment"("transactionId"); diff --git a/backend/prisma/migrations/20240111_ai_tagging/migration.sql b/backend/prisma/migrations/20240111_ai_tagging/migration.sql new file mode 100644 index 0000000..c0fab71 --- /dev/null +++ b/backend/prisma/migrations/20240111_ai_tagging/migration.sql @@ -0,0 +1,10 @@ +-- Attachment tags (AI-generated, JSON-encoded array) +ALTER TABLE "Attachment" ADD COLUMN "tags" TEXT; + +-- AI tagging configuration on the Settings singleton +ALTER TABLE "Settings" ADD COLUMN "aiTaggingEnabled" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Settings" ADD COLUMN "aiProvider" TEXT NOT NULL DEFAULT 'claude'; +ALTER TABLE "Settings" ADD COLUMN "aiModel" TEXT; +ALTER TABLE "Settings" ADD COLUMN "anthropicApiKey" TEXT; +ALTER TABLE "Settings" ADD COLUMN "odysseusBaseUrl" TEXT; +ALTER TABLE "Settings" ADD COLUMN "odysseusApiKey" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 46ce456..08b9212 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -69,12 +69,27 @@ model Transaction { recurring RecurringTransaction? @relation(fields: [recurringId], references: [id]) importedFrom String? externalId String? + attachments Attachment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([externalId, accountId]) } +model Attachment { + id String @id @default(cuid()) + transactionId String + transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade) + filename String + mimeType String + size Int + storagePath String + tags String? // JSON-encoded array of AI-generated tags + createdAt DateTime @default(now()) + + @@index([transactionId]) +} + model User { id String @id @default(cuid()) username String @unique @@ -105,6 +120,12 @@ model Settings { googleOidcEnabled Boolean @default(false) googleClientId String? googleClientSecret String? + aiTaggingEnabled Boolean @default(false) + aiProvider String @default("claude") // "claude" | "odysseus" + aiModel String? // Claude model id, or Odysseus model name + anthropicApiKey String? + odysseusBaseUrl String? + odysseusApiKey String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/backend/src/index.js b/backend/src/index.js index bfecc31..05acab9 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -10,6 +10,7 @@ const usersRouter = require("./routes/users"); const configRouter = require("./routes/config"); const accountsRouter = require("./routes/accounts"); const transactionsRouter = require("./routes/transactions"); +const attachmentsRouter = require("./routes/attachments"); const categoriesRouter = require("./routes/categories"); const recurringRouter = require("./routes/recurring"); const importRouter = require("./routes/import"); @@ -38,6 +39,7 @@ app.use("/users", requireAdmin, usersRouter); app.use("/config", configRouter); app.use("/accounts", accountsRouter); app.use("/transactions", transactionsRouter); +app.use(attachmentsRouter); // /transactions/:id/attachments, /attachments/:id app.use("/categories", categoriesRouter); app.use("/recurring", recurringRouter); app.use("/import", importRouter); diff --git a/backend/src/routes/attachments.js b/backend/src/routes/attachments.js new file mode 100644 index 0000000..88232bf --- /dev/null +++ b/backend/src/routes/attachments.js @@ -0,0 +1,116 @@ +const express = require("express"); +const multer = require("multer"); +const fs = require("fs"); +const path = require("path"); +const { PrismaClient } = require("@prisma/client"); +const { loadAiConfig, tagStoredAttachment } = require("../services/aiTagging"); + +const router = express.Router(); +const prisma = new PrismaClient(); + +const ATT_SELECT = { id: true, filename: true, mimeType: true, size: true, tags: true, createdAt: true }; + +const UPLOAD_DIR = path.join(__dirname, "../../uploads/attachments"); +fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +const ALLOWED = new Set([ + "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", "image/heic", + "application/pdf", +]); + +const upload = multer({ + storage: multer.diskStorage({ + destination: UPLOAD_DIR, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 10)}${ext}`); + }, + }), + limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB per file + fileFilter: (_req, file, cb) => cb(null, ALLOWED.has(file.mimetype)), +}); + +// POST /transactions/:id/attachments — upload one or more receipts/invoices +router.post("/transactions/:id/attachments", upload.array("files"), async (req, res) => { + const transaction = await prisma.transaction.findUnique({ where: { id: req.params.id } }); + if (!transaction) { + (req.files || []).forEach((f) => fs.unlink(f.path, () => {})); + return res.status(404).json({ error: "Transaction not found" }); + } + + const created = await Promise.all( + (req.files || []).map((f) => + prisma.attachment.create({ + data: { + transactionId: transaction.id, + filename: f.originalname, + mimeType: f.mimetype, + size: f.size, + storagePath: path.basename(f.path), + }, + select: { ...ATT_SELECT, storagePath: true }, + }) + ) + ); + + // Best-effort AI tagging — never fails the upload. + const aiConfig = await loadAiConfig().catch(() => null); + if (aiConfig) { + await Promise.all( + created.map(async (att) => { + try { + att.tags = JSON.stringify(await tagStoredAttachment(att, aiConfig)); + } catch (e) { + console.error(`AI tagging failed for attachment ${att.id}:`, e.message); + } + }) + ); + } + + res.status(201).json(created.map(({ storagePath, ...rest }) => rest)); +}); + +// POST /transactions/:id/attachments/retag — re-run AI tagging on all of a transaction's attachments +router.post("/transactions/:id/attachments/retag", async (req, res) => { + const aiConfig = await loadAiConfig(); + if (!aiConfig) return res.status(400).json({ error: "AI tagging is not enabled" }); + + const attachments = await prisma.attachment.findMany({ where: { transactionId: req.params.id } }); + const results = await Promise.all( + attachments.map(async (att) => { + try { + const tags = await tagStoredAttachment(att, aiConfig); + return { id: att.id, filename: att.filename, mimeType: att.mimeType, size: att.size, tags: JSON.stringify(tags), createdAt: att.createdAt }; + } catch (e) { + return { id: att.id, filename: att.filename, mimeType: att.mimeType, size: att.size, tags: att.tags, createdAt: att.createdAt, error: e.message }; + } + }) + ); + res.json(results); +}); + +// GET /attachments/:id — stream the stored file for inline viewing +router.get("/attachments/:id", async (req, res) => { + const att = await prisma.attachment.findUnique({ where: { id: req.params.id } }); + if (!att) return res.status(404).json({ error: "Attachment not found" }); + + const filePath = path.join(UPLOAD_DIR, att.storagePath); + if (!fs.existsSync(filePath)) return res.status(404).json({ error: "File missing" }); + + res.setHeader("Content-Type", att.mimeType); + res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(att.filename)}"`); + fs.createReadStream(filePath).pipe(res); +}); + +// DELETE /attachments/:id — remove an attachment and its file +router.delete("/attachments/:id", async (req, res) => { + const att = await prisma.attachment.findUnique({ where: { id: req.params.id } }); + if (!att) return res.status(404).json({ error: "Attachment not found" }); + + await prisma.attachment.delete({ where: { id: att.id } }); + fs.unlink(path.join(UPLOAD_DIR, att.storagePath), () => {}); + + res.status(204).end(); +}); + +module.exports = router; diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js index 05ebe8f..3d636f1 100644 --- a/backend/src/routes/config.js +++ b/backend/src/routes/config.js @@ -27,6 +27,12 @@ router.get("/", async (req, res) => { googleOidcEnabled: s.googleOidcEnabled, googleClientId: s.googleClientId ?? "", hasGoogleClientSecret: !!s.googleClientSecret, + aiTaggingEnabled: s.aiTaggingEnabled, + aiProvider: s.aiProvider ?? "claude", + aiModel: s.aiModel ?? "", + hasAnthropicApiKey: !!(s.anthropicApiKey || process.env.ANTHROPIC_API_KEY), + odysseusBaseUrl: s.odysseusBaseUrl ?? "", + hasOdysseusApiKey: !!s.odysseusApiKey, }); }); @@ -36,6 +42,7 @@ router.put("/", async (req, res) => { appName, defaultCurrency, appPort, jwtSecret, oidcEnabled, oidcTenantId, oidcClientId, oidcClientSecret, googleOidcEnabled, googleClientId, googleClientSecret, + aiTaggingEnabled, aiProvider, aiModel, anthropicApiKey, odysseusBaseUrl, odysseusApiKey, } = req.body; const data = {}; if (appName !== undefined) data.appName = String(appName).trim() || "FinTrack"; @@ -49,6 +56,12 @@ router.put("/", async (req, res) => { if (googleOidcEnabled !== undefined) data.googleOidcEnabled = Boolean(googleOidcEnabled); if (googleClientId !== undefined) data.googleClientId = googleClientId || null; if (googleClientSecret !== undefined) data.googleClientSecret = googleClientSecret || null; + if (aiTaggingEnabled !== undefined) data.aiTaggingEnabled = Boolean(aiTaggingEnabled); + if (aiProvider !== undefined) data.aiProvider = ["claude", "odysseus"].includes(aiProvider) ? aiProvider : "claude"; + if (aiModel !== undefined) data.aiModel = aiModel || null; + if (anthropicApiKey !== undefined) data.anthropicApiKey = anthropicApiKey || null; + if (odysseusBaseUrl !== undefined) data.odysseusBaseUrl = odysseusBaseUrl || null; + if (odysseusApiKey !== undefined) data.odysseusApiKey = odysseusApiKey || null; await prisma.settings.update({ where: { id: "singleton" }, data }); res.json({ ok: true, note: "Port and JWT secret changes take effect after restart" }); diff --git a/backend/src/routes/transactions.js b/backend/src/routes/transactions.js index cb93c48..93137df 100644 --- a/backend/src/routes/transactions.js +++ b/backend/src/routes/transactions.js @@ -4,7 +4,15 @@ const { PrismaClient } = require("@prisma/client"); const router = express.Router(); const prisma = new PrismaClient(); -const INCLUDE = { account: true, toAccount: true, category: true }; +const INCLUDE = { + account: true, + toAccount: true, + category: true, + attachments: { + select: { id: true, filename: true, mimeType: true, size: true, tags: true, createdAt: true }, + orderBy: { createdAt: "asc" }, + }, +}; // Balance delta for a transaction relative to its accountId function delta(type, amount) { @@ -15,7 +23,8 @@ router.get("/", async (req, res) => { const { accountId, categoryId, type, from, to, search, page = 1, limit = 50 } = req.query; const where = {}; - if (accountId) where.OR = [{ accountId }, { toAccountId: accountId }]; + const and = []; + if (accountId) and.push({ OR: [{ accountId }, { toAccountId: accountId }] }); if (categoryId) where.categoryId = categoryId; if (type) where.type = type; if (from || to) { @@ -23,7 +32,16 @@ router.get("/", async (req, res) => { if (from) where.date.gte = new Date(from); if (to) where.date.lte = new Date(to); } - if (search) where.description = { contains: search }; + // Search matches the description OR any AI-generated attachment tag. + if (search) { + and.push({ + OR: [ + { description: { contains: search } }, + { attachments: { some: { tags: { contains: search } } } }, + ], + }); + } + if (and.length) where.AND = and; const [transactions, total] = await Promise.all([ prisma.transaction.findMany({ diff --git a/backend/src/services/aiTagging.js b/backend/src/services/aiTagging.js new file mode 100644 index 0000000..c0e8d17 --- /dev/null +++ b/backend/src/services/aiTagging.js @@ -0,0 +1,137 @@ +const fs = require("fs"); +const path = require("path"); +const Anthropic = require("@anthropic-ai/sdk"); +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +const UPLOAD_DIR = path.join(__dirname, "../../uploads/attachments"); + +// Image mime types Claude's vision accepts. Anything else (incl. PDF) is handled separately. +const CLAUDE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"]); + +const PROMPT = + "You are a finance assistant tagging a receipt or invoice so the user can search for it later. " + + "Read the document and produce 3 to 8 short, lowercase search tags: the merchant or store name, " + + "the spending category (e.g. groceries, fuel, software, restaurant), and a few notable line items or keywords. " + + 'Respond with ONLY a JSON array of strings and nothing else, e.g. ["albert heijn","groceries","coffee"].'; + +// Load and normalize AI config from the Settings singleton (env var is a fallback for the Anthropic key). +async function loadAiConfig() { + const s = await prisma.settings.findUnique({ where: { id: "singleton" } }); + if (!s || !s.aiTaggingEnabled) return null; + return { + enabled: true, + provider: s.aiProvider || "claude", + model: s.aiModel || null, + anthropicApiKey: s.anthropicApiKey || process.env.ANTHROPIC_API_KEY || null, + odysseusBaseUrl: (s.odysseusBaseUrl || "http://localhost:7000").replace(/\/+$/, ""), + odysseusApiKey: s.odysseusApiKey || null, + }; +} + +// Turn arbitrary model output into a clean, deduped tag list. +function parseTags(text) { + if (!text) return []; + let raw = []; + const match = text.match(/\[[\s\S]*\]/); + if (match) { + try { raw = JSON.parse(match[0]); } catch { /* fall through */ } + } + if (!Array.isArray(raw) || raw.length === 0) { + raw = text.split(/[\n,]+/); + } + const seen = new Set(); + const tags = []; + for (const item of raw) { + const t = String(item).trim().replace(/^["'\-•*\d.\s]+/, "").replace(/["']+$/, "").toLowerCase().trim(); + if (t && t.length <= 40 && !seen.has(t)) { + seen.add(t); + tags.push(t); + } + if (tags.length >= 10) break; + } + return tags; +} + +async function tagWithClaude(buffer, mimeType, cfg) { + if (!cfg.anthropicApiKey) throw new Error("Anthropic API key is not configured"); + const client = new Anthropic({ apiKey: cfg.anthropicApiKey }); + const data = buffer.toString("base64"); + + let fileBlock; + if (CLAUDE_IMAGE_TYPES.has(mimeType)) { + fileBlock = { type: "image", source: { type: "base64", media_type: mimeType === "image/jpg" ? "image/jpeg" : mimeType, data } }; + } else if (mimeType === "application/pdf") { + fileBlock = { type: "document", source: { type: "base64", media_type: "application/pdf", data } }; + } else { + return []; // unsupported type for vision — nothing to read + } + + const response = await client.messages.create({ + model: cfg.model || "claude-haiku-4-5", + max_tokens: 300, + messages: [{ role: "user", content: [fileBlock, { type: "text", text: PROMPT }] }], + }); + + const text = (response.content || []) + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n"); + return parseTags(text); +} + +async function tagWithOdysseus(buffer, mimeType, filename, cfg) { + if (!cfg.odysseusApiKey) throw new Error("Odysseus API token is not configured"); + const auth = { Authorization: `Bearer ${cfg.odysseusApiKey}` }; + + // 1. Upload the file → get an attachment id + const form = new FormData(); + form.append("files", new Blob([buffer], { type: mimeType }), filename); + const upRes = await fetch(`${cfg.odysseusBaseUrl}/api/upload`, { method: "POST", headers: auth, body: form }); + if (!upRes.ok) throw new Error(`Odysseus upload failed (${upRes.status})`); + const upJson = await upRes.json(); + const fileId = upJson?.files?.[0]?.id; + if (!fileId) throw new Error("Odysseus upload returned no file id"); + + // 2. Create a chat session + const sessForm = new FormData(); + sessForm.append("name", "FinTrack tagging"); + if (cfg.model) sessForm.append("model", cfg.model); + const sessRes = await fetch(`${cfg.odysseusBaseUrl}/api/session`, { method: "POST", headers: auth, body: sessForm }); + if (!sessRes.ok) throw new Error(`Odysseus session failed (${sessRes.status})`); + const sessJson = await sessRes.json(); + const sessionId = sessJson?.id; + if (!sessionId) throw new Error("Odysseus session returned no id"); + + // 3. Ask for tags with the file attached + const chatRes = await fetch(`${cfg.odysseusBaseUrl}/api/chat`, { + method: "POST", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ message: PROMPT, session: sessionId, attachments: [fileId] }), + }); + if (!chatRes.ok) throw new Error(`Odysseus chat failed (${chatRes.status})`); + const chatJson = await chatRes.json(); + return parseTags(chatJson?.response || ""); +} + +// Generate tags for a single file buffer. Returns string[] (possibly empty); never throws to the caller +// unless `config` is missing — callers should treat failures as "no tags". +async function generateTags({ buffer, mimeType, filename }, config) { + const cfg = config || (await loadAiConfig()); + if (!cfg) return []; + if (cfg.provider === "odysseus") return tagWithOdysseus(buffer, mimeType, filename, cfg); + return tagWithClaude(buffer, mimeType, cfg); +} + +// Best-effort tagging for a stored attachment row; writes tags back to the DB. Returns the tags written. +async function tagStoredAttachment(attachment, config) { + const filePath = path.join(UPLOAD_DIR, attachment.storagePath); + if (!fs.existsSync(filePath)) return []; + const buffer = fs.readFileSync(filePath); + const tags = await generateTags({ buffer, mimeType: attachment.mimeType, filename: attachment.filename }, config); + await prisma.attachment.update({ where: { id: attachment.id }, data: { tags: JSON.stringify(tags) } }); + return tags; +} + +module.exports = { loadAiConfig, generateTags, tagStoredAttachment, parseTags }; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 88699f4..a7bc358 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -38,6 +38,17 @@ export const transactions = { remove: (id) => api.delete(`/transactions/${id}`), }; +export const attachments = { + upload: (transactionId, files) => { + const form = new FormData(); + files.forEach((f) => form.append("files", f)); + return api.post(`/transactions/${transactionId}/attachments`, form).then((r) => r.data); + }, + blob: (id) => api.get(`/attachments/${id}`, { responseType: "blob" }).then((r) => r.data), + remove: (id) => api.delete(`/attachments/${id}`), + retag: (transactionId) => api.post(`/transactions/${transactionId}/attachments/retag`).then((r) => r.data), +}; + export const categories = { list: () => api.get("/categories").then((r) => r.data), create: (data) => api.post("/categories", data).then((r) => r.data), diff --git a/frontend/src/components/Dialog.jsx b/frontend/src/components/Dialog.jsx new file mode 100644 index 0000000..9375cd2 --- /dev/null +++ b/frontend/src/components/Dialog.jsx @@ -0,0 +1,39 @@ +import { useEffect } from "react"; + +/** + * FinTrack dialog — a centered glass-strong panel over a dimmed, blurred + * overlay. Click-outside and Esc close it. + */ +export default function Dialog({ open = true, onClose, title, children, width = 460, style = {} }) { + useEffect(() => { + if (!open) return; + const onKey = (e) => { if (e.key === "Escape" && onClose) onClose(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
e.target === e.currentTarget && onClose?.()}> +
+ {title && ( +
+

{title}

+ +
+ )} + {children} +
+
+ ); +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 789bf4c..a1b367e 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -718,6 +718,124 @@ function BackupManager() { ); } +// ── AI / Receipt tagging ───────────────────────────────────── +const CLAUDE_MODELS = [ + { value: "claude-haiku-4-5", label: "Claude Haiku 4.5 — fast & cheap (recommended)" }, + { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 — balanced" }, + { value: "claude-opus-4-8", label: "Claude Opus 4.8 — most accurate" }, +]; + +function AiConfig() { + const [cfg, setCfg] = useState({ + aiTaggingEnabled: false, aiProvider: "claude", aiModel: "claude-haiku-4-5", + anthropicApiKey: "", hasAnthropicApiKey: false, + odysseusBaseUrl: "", odysseusApiKey: "", hasOdysseusApiKey: false, + }); + const [loaded, setLoaded] = useState(false); + const [msg, setMsg] = useState(null); + + useEffect(() => { + api.get("/config").then(res => { + const d = res.data; + setCfg({ + aiTaggingEnabled: d.aiTaggingEnabled ?? false, + aiProvider: d.aiProvider ?? "claude", + aiModel: d.aiModel || "claude-haiku-4-5", + anthropicApiKey: "", hasAnthropicApiKey: d.hasAnthropicApiKey ?? false, + odysseusBaseUrl: d.odysseusBaseUrl ?? "", odysseusApiKey: "", hasOdysseusApiKey: d.hasOdysseusApiKey ?? false, + }); + setLoaded(true); + }); + }, []); + + const set = (patch) => setCfg(c => ({ ...c, ...patch })); + + function switchProvider(provider) { + set({ aiProvider: provider, aiModel: provider === "claude" ? (CLAUDE_MODELS.some(m => m.value === cfg.aiModel) ? cfg.aiModel : "claude-haiku-4-5") : (CLAUDE_MODELS.some(m => m.value === cfg.aiModel) ? "" : cfg.aiModel) }); + } + + async function handleSubmit(e) { + e.preventDefault(); setMsg(null); + try { + await api.put("/config", { + aiTaggingEnabled: cfg.aiTaggingEnabled, + aiProvider: cfg.aiProvider, + aiModel: cfg.aiModel || undefined, + anthropicApiKey: cfg.anthropicApiKey || undefined, + odysseusBaseUrl: cfg.odysseusBaseUrl || undefined, + odysseusApiKey: cfg.odysseusApiKey || undefined, + }); + setMsg({ type: "success", text: "AI settings saved" }); + setCfg(c => ({ ...c, anthropicApiKey: "", odysseusApiKey: "", hasAnthropicApiKey: c.hasAnthropicApiKey || !!c.anthropicApiKey, hasOdysseusApiKey: c.hasOdysseusApiKey || !!c.odysseusApiKey })); + } catch (err) { setMsg({ type: "error", text: err.response?.data?.error || "Failed to save" }); } + } + + if (!loaded) return

Loading…

; + + return ( +
+

+ Let AI read the receipts & invoices attached to transactions and add searchable tags + (merchant, category, line items). Tags are matched by the Transactions search box. +

+ + + + + +
+ {[{ id: "claude", label: "Claude (Anthropic)" }, { id: "odysseus", label: "Odysseus (self-hosted)" }].map(p => { + const active = cfg.aiProvider === p.id; + return ( + + ); + })} +
+
+ + {cfg.aiProvider === "claude" ? ( + <> + + + + + set({ anthropicApiKey: e.target.value })} placeholder={cfg.hasAnthropicApiKey ? "••••••••" : "sk-ant-…"} /> + + + ) : ( + <> + + set({ odysseusBaseUrl: e.target.value })} placeholder="http://localhost:7000" /> + + + set({ aiModel: e.target.value })} placeholder="e.g. llama3.2-vision" /> + + + set({ odysseusApiKey: e.target.value })} placeholder={cfg.hasOdysseusApiKey ? "••••••••" : "ody_…"} /> + +

+ Create an API token in Odysseus (Settings → API tokens). FinTrack uploads each receipt, opens a + session, and asks for tags — so the Odysseus model you use must support image/PDF input. +

+ + )} + + + + ); +} + // ── Main Settings page ─────────────────────────────────────── const NAV_TABS = [ { id: "appearance", label: "Appearance", icon: "🎨" }, @@ -725,6 +843,7 @@ const NAV_TABS = [ { id: "users", label: "Users", icon: "👥", adminOnly: true }, { id: "server", label: "Server", icon: "⚙", adminOnly: true }, { id: "sso", label: "SSO", icon: "🔑", adminOnly: true }, + { id: "ai", label: "AI", icon: "🤖", adminOnly: true }, { id: "backups", label: "Backups", icon: "💾", adminOnly: true }, ]; @@ -825,6 +944,13 @@ export default function Settings() { )} + {isAdmin && activeTab === "ai" && ( + <> + AI receipt tagging + + + )} + {isAdmin && activeTab === "backups" && ( <> Database backups diff --git a/frontend/src/pages/Transactions.jsx b/frontend/src/pages/Transactions.jsx index 25d234f..dffe94e 100644 --- a/frontend/src/pages/Transactions.jsx +++ b/frontend/src/pages/Transactions.jsx @@ -1,32 +1,45 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useSearchParams } from "react-router-dom"; -import { transactions as txApi, accounts as accountsApi, categories as catsApi } from "../api/client"; +import { + transactions as txApi, + accounts as accountsApi, + categories as catsApi, + attachments as attachmentsApi, +} from "../api/client"; import GlassCard from "../components/GlassCard"; +import Dialog from "../components/Dialog"; import { format } from "date-fns"; const fmt = (n) => new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" }).format(n); +const parseTags = (raw) => { try { const a = JSON.parse(raw || "[]"); return Array.isArray(a) ? a : []; } catch { return []; } }; +const txnTags = (t) => (t.attachments || []).flatMap((a) => parseTags(a.tags)); +const fmtDate = (d) => + new Date(d).toLocaleDateString("nl-NL", { day: "numeric", month: "short", year: "numeric" }); -const emptyForm = { - accountId: "", toAccountId: "", categoryId: "", - amount: "", description: "", - date: format(new Date(), "yyyy-MM-dd"), - type: "EXPENSE", notes: "", -}; +const GRID = "1.2fr 1fr 0.9fr 92px 96px 130px"; -const fieldStyle = { padding: "10px 14px", width: "100%", boxSizing: "border-box", display: "block" }; -const labelStyle = { fontSize: 12, color: "rgba(255,255,255,0.5)", fontWeight: 500, display: "block", marginBottom: 16 }; +const TXN_TYPES = [ + { id: "INCOME", label: "Income", color: "#34d399" }, + { id: "EXPENSE", label: "Expense", color: "#f87171" }, + { id: "TRANSFER", label: "Transfer", color: "#94a3b8" }, +]; + +const fieldStyle = { padding: "10px 14px", width: "100%", boxSizing: "border-box", display: "block", marginTop: 6 }; +const labelStyle = { fontSize: 12, color: "rgba(255,255,255,0.5)", fontWeight: 500, display: "block" }; export default function Transactions() { const [data, setData] = useState({ transactions: [], total: 0 }); const [accounts, setAccounts] = useState([]); const [categories, setCategories] = useState([]); - const [modal, setModal] = useState(false); - const [form, setForm] = useState(emptyForm); - const [editing, setEditing] = useState(null); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(""); + const [modal, setModal] = useState(null); // null | { mode: 'add' } | { mode: 'edit', txn } const [searchParams] = useSearchParams(); - const [filters, setFilters] = useState({ search: "", accountId: searchParams.get("accountId") || "", categoryId: "", type: "", page: 1 }); + const [filters, setFilters] = useState({ + search: "", + accountId: searchParams.get("accountId") || "", + categoryId: "", + type: "", + page: 1, + }); const load = useCallback(() => { txApi.list({ ...filters, limit: 50 }).then(setData); @@ -38,52 +51,7 @@ export default function Transactions() { catsApi.list().then(setCategories); }, []); - const open = (item = null) => { - setEditing(item?.id || null); - setSaveError(""); - setForm(item ? { - accountId: item.accountId, - toAccountId: item.toAccountId || "", - categoryId: item.categoryId || "", - amount: item.amount, - description: item.description, - date: format(new Date(item.date), "yyyy-MM-dd"), - type: item.type, - notes: item.notes || "", - } : { ...emptyForm, accountId: accounts[0]?.id || "" }); - setModal(true); - }; - - const save = async () => { - if (!form.description.trim()) { setSaveError("Description is required"); return; } - if (!form.amount || Number(form.amount) <= 0) { setSaveError("Enter a valid amount"); return; } - if (!form.accountId) { setSaveError("Select an account"); return; } - if (form.type === "TRANSFER" && !form.toAccountId) { setSaveError("Select a destination account"); return; } - if (form.type === "TRANSFER" && form.toAccountId === form.accountId) { setSaveError("From and To accounts must be different"); return; } - - setSaveError(""); - setSaving(true); - try { - const payload = { ...form, amount: Number(form.amount), categoryId: form.categoryId || null }; - if (editing) await txApi.update(editing, payload); - else await txApi.create(payload); - setModal(false); - load(); - } catch (err) { - setSaveError(err.response?.data?.error || err.message || "Failed to save"); - } finally { - setSaving(false); - } - }; - - const remove = async (id) => { - if (!confirm("Delete this transaction?")) return; - await txApi.remove(id); - load(); - }; - const allCategories = categories.flatMap((c) => [c, ...(c.children || [])]); - const isTransfer = form.type === "TRANSFER"; return (
@@ -92,74 +60,86 @@ export default function Transactions() {

Transactions

{data.total} transactions

- + - {/* Filters */} - -
- setFilters({ ...filters, search: e.target.value, page: 1 })} /> - - setFilters({ ...filters, search: e.target.value, page: 1 })} + /> + - setFilters({ ...filters, accountId: e.target.value, page: 1 })}> + + {accounts.map((a) => )} + +
-
- {/* Table */} - - - - - {["Date", "Description", "Account", "Category", "Type", "Amount", ""].map((h) => ( - - ))} - - - - {data.transactions.map((t) => ( - - - - - - - - - - ))} - {data.transactions.length === 0 && ( - - )} - -
{h}
{format(new Date(t.date), "dd MMM yyyy")}{t.description} - {t.type === "TRANSFER" && t.toAccount - ? {t.account?.name} {t.toAccount.name} - : t.account?.name} - - {t.category && ( - - {t.category.name} - - )} - - {t.type} - - {t.type === "EXPENSE" ? "-" : t.type === "INCOME" ? "+" : ""}{fmt(Number(t.amount))} - - - -
No transactions found
+ {/* Table head */} +
+ DescriptionAccountCategoryTypeReceipt + Amount +
+ + {/* Rows */} + {data.transactions.map((t, i) => { + const sign = t.type === "INCOME" ? "+" : t.type === "EXPENSE" ? "−" : ""; + const color = t.type === "INCOME" ? "#34d399" : t.type === "EXPENSE" ? "#f87171" : "#94a3b8"; + const files = t.attachments || []; + return ( +
setModal({ mode: "edit", txn: t })} + style={{ display: "grid", gridTemplateColumns: GRID, gap: 12, padding: "14px 18px", alignItems: "center", borderTop: i > 0 ? "1px solid rgba(255,255,255,0.08)" : "none", fontSize: 13, cursor: "pointer", transition: "background 0.15s ease" }} + onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255,255,255,0.04)")} + onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} + > +
+
{t.description}
+
{fmtDate(t.date)}
+
+
+ {t.type === "TRANSFER" && t.toAccount + ? {t.account?.name} {t.toAccount.name} + : t.account?.name} +
+
+ {t.category + ? {t.category.name} + : } +
+
{t.type}
+
+ {files.length > 0 + ? (() => { const tags = txnTags(t); return ( + 1 ? "s" : ""}`} style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 12, color: "#c4b5fd" }}>📎 {files.length} + ); })() + : } +
+
{sign}{fmt(Number(t.amount))}
+
+ ); + })} + + {data.transactions.length === 0 && ( +
No transactions yet
+ )}
{/* Pagination */} @@ -171,107 +151,337 @@ export default function Transactions() { )} - {/* Modal */} {modal && ( -
e.target === e.currentTarget && setModal(false)}> -
-

{editing ? "Edit" : "Add"} Transaction

- -
- {/* Type selector */} -
- {["EXPENSE", "INCOME", "TRANSFER"].map((t) => ( - - ))} -
+ setModal(null)} + onSaved={() => { setModal(null); load(); }} + /> + )} +
+ ); +} - - -
- - -
+function TransactionModal({ mode, txn, accounts, categories, onClose, onSaved }) { + const isEdit = mode === "edit"; + const [type, setType] = useState(txn?.type || "EXPENSE"); + const [desc, setDesc] = useState(txn?.description || ""); + const [amount, setAmount] = useState(txn ? String(txn.amount) : ""); + const [date, setDate] = useState(txn ? format(new Date(txn.date), "yyyy-MM-dd") : format(new Date(), "yyyy-MM-dd")); + const [accountId, setAccountId] = useState(txn?.accountId || accounts[0]?.id || ""); + const [toAccountId, setToAccountId] = useState(txn?.toAccountId || ""); + const [categoryId, setCategoryId] = useState(txn?.categoryId || ""); + const [notes, setNotes] = useState(txn?.notes || ""); - {isTransfer ? ( -
- -
- -
- ) : ( - - )} - - {!isTransfer && ( - - )} - -