Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
50 changes: 50 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions backend/prisma/migrations/20240110_attachments/migration.sql
Original file line number Diff line number Diff line change
@@ -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");
10 changes: 10 additions & 0 deletions backend/prisma/migrations/20240111_ai_tagging/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
21 changes: 21 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
116 changes: 116 additions & 0 deletions backend/src/routes/attachments.js
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions backend/src/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});

Expand All @@ -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";
Expand All @@ -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" });
Expand Down
24 changes: 21 additions & 3 deletions backend/src/routes/transactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -15,15 +23,25 @@ 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) {
where.date = {};
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({
Expand Down
Loading