diff --git a/package-lock.json b/package-lock.json index 58962e4..560fdf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "nodemon": "^2.0.4", "openai": "^4.86.2", "passport": "^0.4.1", - "passport-local": "^1.0.0" + "passport-local": "^1.0.0", + "zod": "^3.24.2" }, "devDependencies": { "@babel/core": "^7.23.9", @@ -8200,6 +8201,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -14173,6 +14182,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==" } } } diff --git a/package.json b/package.json index 61288fc..e678d2b 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "nodemon": "^2.0.4", "openai": "^4.86.2", "passport": "^0.4.1", - "passport-local": "^1.0.0" + "passport-local": "^1.0.0", + "zod": "^3.24.2" }, "devDependencies": { "@babel/core": "^7.23.9", diff --git a/src/controllers/expenses.js b/src/controllers/expenses.js index 84590cd..d7f586e 100644 --- a/src/controllers/expenses.js +++ b/src/controllers/expenses.js @@ -12,8 +12,8 @@ const findAllExpensesByAccountID = async (req, res) => { // Fetch expenses with specified conditions const expenses = await Expense - .find({accountID: req.params.aid}, "-file") - .sort({date: -1, _id: -1}) + .find({ accountID: req.params.aid }, "-file") + .sort({ date: -1, _id: -1 }) .skip(expensesPerPage * page) .limit(expensesPerPage); @@ -86,7 +86,7 @@ const findExpenseFileByID = async (req, res) => { // Create an expense const create = async (req, res) => { - const {userID, accountID, category1, category2, description, date, amount, file} = req.body; + const { userID, accountID, category1, category2, description, date, amount, file } = req.body; // Check expense description length if (description?.length > 32) { @@ -150,13 +150,13 @@ const remove = async (req, res) => { const expense = await Expense.findByIdAndDelete(req.params.id); if (!expense) { - return res.status(404).send({message: "No expense with selected ID!"}); + return res.status(404).send({ message: "No expense with selected ID!" }); } // Update account balance await updateAccountBalances.updateAllAccountBalances(expense.accountID, expense.amount, "+"); - res.send({message: "Expense deleted!"}); + res.send({ message: "Expense deleted!" }); } catch (error) { res.status(500).send({ @@ -171,7 +171,7 @@ const remove = async (req, res) => { // Update expense with requested ID const update = async (req, res) => { - const {accountID, category1, category2, description, date, amount, file} = req.body; + const { accountID, category1, category2, description, date, amount, file } = req.body; // Check expense description length if (description?.length > 32) { @@ -189,12 +189,12 @@ const update = async (req, res) => { let editedExpense = { // Add properties to the object - ...(category1 && {category1: category1}), - ...(category2 && {category2: category2}), - ...(accountID && {accountID: accountID}), - ...(description && {description: description}), - ...(date && {date: date}), - ...(amount && {amount: amount}), + ...(category1 && { category1: category1 }), + ...(category2 && { category2: category2 }), + ...(accountID && { accountID: accountID }), + ...(description && { description: description }), + ...(date && { date: date }), + ...(amount && { amount: amount }), }; if (file) { @@ -208,7 +208,7 @@ const update = async (req, res) => { try { // Fetch the old expense and edit it - const expense = await Expense.findByIdAndUpdate(req.params.id, {$set: editedExpense}); + const expense = await Expense.findByIdAndUpdate(req.params.id, { $set: editedExpense }); if (!expense) { return res.status(404).send({ @@ -236,7 +236,7 @@ const update = async (req, res) => { await updateAccountBalances.updateAllAccountBalances(expense.accountID, difference, operation); } - res.send({message: "Expense updated!"}); + res.send({ message: "Expense updated!" }); } catch (error) { res.status(500).send({ @@ -271,9 +271,22 @@ const expensesBreakdown = async (req, res) => { }; try { - const breakdown = await Expense.aggregate() - .match(filterObject) - .group({"_id": "$category1", "sum": {$sum: "$amount"}}); + const pipeline = [ + { $match: filterObject }, + { $group: { _id: { c1: "$category1", c2: "$category2" }, sum: { $sum: "$amount" } } }, + { $sort: { "_id.c1": 1, sum: -1 } }, + { + $group: { + _id: "$_id.c1", + total: { $sum: "$sum" }, + category2: { $push: { category2: "$_id.c2", sum: "$sum" } } + } + }, + { $project: { _id: 0, category1: "$_id", total: 1, category2: 1 } }, + { $sort: { total: -1 } } + ]; + + const breakdown = await Expense.aggregate(pipeline).option({ allowDiskUse: true }); res.status(200).json(breakdown); diff --git a/src/middlewares/drafts.js b/src/middlewares/drafts.js index 72dbe9c..ae1f951 100644 --- a/src/middlewares/drafts.js +++ b/src/middlewares/drafts.js @@ -23,14 +23,16 @@ const parseSmsManually = (sms, isNlb) => { const parseSmsWithAI = async (sms) => { try { const smsAI = await openai.createExpense(sms) + const parsedDate = (smsAI.date && (new Date(smsAI.date).toString() !== "Invalid Date")) ? new Date(smsAI.date) : Date.now() return { - description: smsAI.description, - date: Date.now(), + description: smsAI.description?.slice(0, 31), + date: parsedDate, amount: parseInt(smsAI.amount), category1: smsAI.category1, category2: smsAI.category2 }; } catch (e) { + console.log(e) throw new Error("AI SMS parsing failed.") } }; diff --git a/src/middlewares/openai.js b/src/middlewares/openai.js index a19f16c..2888e9e 100644 --- a/src/middlewares/openai.js +++ b/src/middlewares/openai.js @@ -1,5 +1,7 @@ import OpenAI from "openai"; import {categoriesExpenses} from "../models/expenses.js"; +import { z } from "zod"; +import {zodResponseFormat} from "openai/helpers/zod"; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY || 'api-key', @@ -8,6 +10,14 @@ const openai = new OpenAI({ const createExpense = async (sms) => { + const ExpenseEntry = z.object({ + description: z.string(), + date: z.string().optional(), + amount: z.number().int(), + category1: z.string(), + category2: z.string(), + }); + const completion = await openai.chat.completions.create({ //model: "gpt-4-1106-preview", model: "gpt-4o-mini", @@ -16,7 +26,8 @@ const createExpense = async (sms) => { role: "system", content: //"You process and categorize received SMS transaction data sent in JSON format. Prepare a json response object containing the following: date (change from DD.MM.YYYY to MM.DD.YYYY), amount (in cents), and description (maximum of 5 words). Then categorize the transaction by choosing the right category1 and corresponding category2 based on the json in the next message. For ambiguous cases, set 'Draft' for both 'category1' and 'category2'. If you encounter an error, return a json object with the error property equal to true." - "You process and categorize received SMS transaction data sent in JSON format. Prepare a json response object containing the following: amount (in cents), and description (maximum of 5 words). Then categorize the transaction by choosing the right category1 and corresponding category2 based on the json in the next message (use just the provided category names, do not alter category names). For ambiguous cases, set 'Draft' for both 'category1' and 'category2'. If you encounter an error, return a json object with the error property equal to true." + //"You process and categorize received SMS transaction data sent in JSON format. Prepare a json response object containing the following: amount (in cents), and description (maximum of 5 words). Then categorize the transaction by choosing the right category1 and corresponding category2 based on the json in the next message (use just the provided category names, do not alter category names). For ambiguous cases, set 'Draft' for both 'category1' and 'category2'. If you encounter an error, return a json object with the error property equal to true." + "You are a helpful finance tracking assistant. You process and categorize received SMS expense data sent in JSON format. Prepare a json response object containing the following: amount (the amount will be in Euros but always convert it to integer cents value), and description (up to 32 characters, based on what you receive, ideally the name of the store/service). If the data contains the date (which will always be in European DD.MM.YYYY format, so carefully rewrite it as YYYY-MM-DD), include it in the property 'date' in format YYYY-MM-DD, otherwise completely omit the date property from your response. Then categorize the transaction by choosing the right category1 value and corresponding category2 value based on the json in the next message (use just the provided category names exactly, do not alter category names, keep the capitalization the same as in the provided json). For ambiguous cases, set 'Draft' for both 'category1' and 'category2'. If you encounter an error, return a json object with the error property equal to true." }, { role: "system", @@ -24,6 +35,7 @@ const createExpense = async (sms) => { }, {role: "user", content: sms}, ], + response_format: zodResponseFormat(ExpenseEntry, "expense") }); return JSON.parse(completion.choices[0].message.content);