diff --git a/server/controllers/expense.controller.js b/server/controllers/expense.controller.js new file mode 100644 index 0000000..ea6beb6 --- /dev/null +++ b/server/controllers/expense.controller.js @@ -0,0 +1,193 @@ +import { validationResult } from 'express-validator'; +import { + createExpense, + getExpenseById, + getAllExpenses, + updateExpense, + deleteExpense +} from '../services/expense.service.js'; + +// @desc Create a new expense +// @route POST /api/expenses +// @access Private +export const createExpenseController = async (req, res) => { + // Validate request body + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const userId = req.user.user_id; + const expenseData = { + ...req.body, + user_id: userId + }; + + const result = await createExpense(userId,expenseData); + + if (!result.success) { + return res.status(400).json({ + success: false, + error: result.error + }); + } + + return res.status(201).json({ + success: true, + data: result.data + }); + } catch (error) { + console.error('Error creating expense:', error); + return res.status(500).json({ + success: false, + error: 'Server error while creating expense' + }); + } +}; + +// @desc Get a single expense by ID +// @route GET /api/expenses/:id +// @access Private +export const getExpenseController = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.user_id; + + const result = await getExpenseById(id, userId); + + if (!result.success) { + return res.status(404).json({ + success: false, + error: result.error + }); + } + + return res.status(200).json({ + success: true, + data: result.data + }); + } catch (error) { + console.error('Error fetching expense:', error); + return res.status(500).json({ + success: false, + error: 'Server error while fetching expense' + }); + } +}; + +// @desc Get all expenses for a user +// @route GET /api/expenses +// @access Private +export const getAllExpensesController = async (req, res) => { + try { + const userId = req.user.user_id; + const { startDate, endDate, categoryId, type } = req.query; + + const result = await getAllExpenses(userId, { + startDate, + endDate, + categoryId, + type + }); + + if (!result.success) { + return res.status(400).json({ + success: false, + error: result.error + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + count: result.data.length + }); + } catch (error) { + console.error('Error fetching expenses:', error); + return res.status(500).json({ + success: false, + error: 'Server error while fetching expenses' + }); + } +}; + +// @desc Update an expense +// @route PUT /api/expenses/:id +// @access Private +export const updateExpenseController = async (req, res) => { + // Validate request body + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { id } = req.params; + const userId = req.user.user_id; + const updateData = req.body; + + // Don't allow updating user_id + if (updateData.user_id) { + delete updateData.user_id; + } + + const result = await updateExpense(id, userId, updateData); + + if (!result.success) { + const statusCode = result.error.includes('not found') ? 404 : 400; + return res.status(statusCode).json({ + success: false, + error: result.error + }); + } + + return res.status(200).json({ + success: true, + data: result.data + }); + } catch (error) { + console.error('Error updating expense:', error); + return res.status(500).json({ + success: false, + error: 'Server error while updating expense' + }); + } +}; + +// @desc Delete an expense +// @route DELETE /api/expenses/:id +// @access Private +export const deleteExpenseController = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.user_id; + + const result = await deleteExpense(id, userId); + + if (!result.success) { + const statusCode = result.error.includes('not found') ? 404 : 400; + return res.status(statusCode).json({ + success: false, + error: result.error + }); + } + + return res.status(200).json({ + success: true, + data: { id } + }); + } catch (error) { + console.error('Error deleting expense:', error); + return res.status(500).json({ + success: false, + error: 'Server error while deleting expense' + }); + } +}; diff --git a/server/package-lock.json b/server/package-lock.json index 2525759..76ba302 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15,6 +15,7 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-validator": "^7.2.1", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", @@ -830,6 +831,28 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-validator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", + "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.12.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/express-validator/node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/exsolve": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", @@ -1345,6 +1368,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", diff --git a/server/package.json b/server/package.json index c5b9cdc..0c653ee 100644 --- a/server/package.json +++ b/server/package.json @@ -22,15 +22,16 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.14.0", + "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-validator": "^7.2.1", "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", - "bcrypt": "^5.1.1", - "jsonwebtoken": "^9.0.2", - "cookie-parser": "^1.4.6", "validator": "^13.11.0" }, "devDependencies": { diff --git a/server/routes/expense.route.js b/server/routes/expense.route.js new file mode 100644 index 0000000..427cfc6 --- /dev/null +++ b/server/routes/expense.route.js @@ -0,0 +1,68 @@ +import express from 'express'; +import { requireAuth } from '../middleware/auth.middleware.js'; +import { + createExpenseController, + getExpenseController, + getAllExpensesController, + updateExpenseController, + deleteExpenseController +} from '../controllers/expense.controller.js'; +import { + createExpenseValidator, + updateExpenseValidator, + getExpenseValidator, + deleteExpenseValidator, + listExpensesValidator +} from '../validators/expense.validator.js'; + +const router = express.Router(); + +// Apply authentication middleware to all routes +router.use(requireAuth); + +// @route POST /api/expenses +// @desc Create a new expense +// @access Private +router.post( + '/', + createExpenseValidator, + createExpenseController +); + +// @route GET /api/expenses/:id +// @desc Get a single expense by ID +// @access Private +router.get( + '/:id', + getExpenseValidator, + getExpenseController +); + +// @route GET /api/expenses +// @desc Get all expenses for the authenticated user +// @access Private +router.get( + '/', + listExpensesValidator, + getAllExpensesController +); + +// @route PUT /api/expenses/:id +// @desc Update an expense +// @access Private +router.put( + '/:id', + updateExpenseValidator, + updateExpenseController +); + +// @route DELETE /api/expenses/:id +// @desc Delete an expense +// @access Private +router.delete( + '/:id', + deleteExpenseValidator, + deleteExpenseController +); + +export default router; diff --git a/server/server.js b/server/server.js index 67dd472..3e2993e 100644 --- a/server/server.js +++ b/server/server.js @@ -7,7 +7,7 @@ import { PrismaClient } from '@prisma/client'; import incomeRoutes from './routes/income.route.js'; import authRoutes from './routes/auth.route.js'; import categoryRoutes from './routes/category.route.js'; - +import expenseRoutes from './routes/expense.route.js'; dotenv.config(); const app = express(); @@ -24,6 +24,7 @@ app.use(cookieParser()); app.use('/api/auth', authRoutes); app.use('/api/incomes', requireAuth, incomeRoutes); app.use('/api/categories', categoryRoutes); +app.use('/api/expenses', expenseRoutes) // Initialize a single Prisma client instance const prisma = new PrismaClient(); diff --git a/server/services/expense.service.js b/server/services/expense.service.js new file mode 100644 index 0000000..783c4cc --- /dev/null +++ b/server/services/expense.service.js @@ -0,0 +1,197 @@ +import { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; + +//Post + +export const createExpense = async (userId, expenseData) => { + try { + const expense = await prisma.expense.create({ + data: { + amount: expenseData.amount, + description: expenseData.description, + type: expenseData.type || "ONE_TIME", + receipt_upload: expenseData.receipt_upload, + expense_date: expenseData.expense_date + ? new Date(expenseData.expense_date) + : null, + start_date: expenseData.start_date + ? new Date(expenseData.start_date) + : null, + end_date: expenseData.end_date ? new Date(expenseData.end_date) : null, + user_id: expenseData.user_id, + category_id: expenseData.category_id, + }, + include: { + category: true, + }, + }); + return { success: true, data: expense }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2002") { + return { success: false, error: "Expense already exists" }; + } + if (error.code === "P2003") { + return { success: false, error: "Invalid user_id or category_id" }; + } + } + return { success: false, error: error.message }; + } +}; + +//Get expense by id + +export const getExpenseById = async (expenseId, userId) => { + try { + const expense = await prisma.expense.findFirst({ + where: { + expense_id: parseInt(expenseId), + user_id: parseInt(userId), + }, + include: { + category: true, + }, + }); + + if (!expense) { + return { success: false, error: "Expense not found" }; + } + + return { success: true, data: expense }; + } catch (error) { + return { success: false, error: error.message }; + } +}; + +//Get all + +export const getAllExpenses = async (userId, filters = {}) => { + try { + const { startDate, endDate, categoryId, type } = filters; + + const where = { + user_id: parseInt(userId), + }; + + if (startDate || endDate) { + where.expense_date = {}; + if (startDate) where.expense_date.gte = new Date(startDate); + if (endDate) where.expense_date.lte = new Date(endDate); + } + + if (categoryId) where.category_id = parseInt(categoryId); + if (type) where.type = type; + + const expenses = await prisma.expense.findMany({ + where, + include: { + category: true, + }, + orderBy: { + expense_date: "desc", + }, + }); + + return { success: true, data: expenses }; + } catch (error) { + return { success: false, error: error.message }; + } +}; + +//Update expense + +export const updateExpense = async (expenseId, userId, updateData) => { + try { + // First verify the expense exists and belongs to the user + const existingExpense = await prisma.expense.findFirst({ + where: { + expense_id: parseInt(expenseId), + user_id: parseInt(userId), + }, + }); + + if (!existingExpense) { + return { success: false, error: "Expense not found or access denied" }; + } + + // Prepare the data to update + const dataToUpdate = {}; + const allowedFields = [ + "amount", + "description", + "type", + "receipt_upload", + "expense_date", + "start_date", + "end_date", + "category_id", + ]; + + Object.keys(updateData).forEach((key) => { + if (allowedFields.includes(key) && updateData[key] !== undefined) { + // Handle date fields + if (key.endsWith("_date") && updateData[key]) { + dataToUpdate[key] = new Date(updateData[key]); + } else { + dataToUpdate[key] = updateData[key]; + } + } + }); + + const updatedExpense = await prisma.expense.update({ + where: { + expense_id: parseInt(expenseId), + }, + data: dataToUpdate, + include: { + category: true, + }, + }); + + return { success: true, data: updatedExpense }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2025") { + return { success: false, error: "Expense not found" }; + } + if (error.code === "P2003") { + return { success: false, error: "Invalid category_id" }; + } + } + return { success: false, error: error.message }; + } +}; + +//Delete + +export const deleteExpense = async (expenseId, userId) => { + try { + // First verify the expense exists and belongs to the user + const existingExpense = await prisma.expense.findFirst({ + where: { + expense_id: parseInt(expenseId), + user_id: parseInt(userId), + }, + }); + + if (!existingExpense) { + return { success: false, error: "Expense not found or access denied" }; + } + + const deletedExpense = await prisma.expense.delete({ + where: { + expense_id: parseInt(expenseId), + }, + }); + + return { success: true, data: deletedExpense }; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ) { + return { success: false, error: "Expense not found" }; + } + return { success: false, error: error.message }; + } +}; diff --git a/server/validators/expense.validator.js b/server/validators/expense.validator.js new file mode 100644 index 0000000..29119de --- /dev/null +++ b/server/validators/expense.validator.js @@ -0,0 +1,143 @@ +import { body, param, query } from "express-validator"; + +export const createExpenseValidator = [ + body("amount") + .isFloat({ gt: 0 }) + .withMessage("Amount must be a positive number") + .notEmpty() + .withMessage("Amount is required"), + + body("description") + .optional() + .isString() + .withMessage("Description must be a string") + .isLength({ max: 2000 }) + .withMessage("Description must be less than 2000 characters"), + + body("type") + .optional() + .isIn(["ONE_TIME", "RECURRING"]) + .withMessage("Type must be either ONE_TIME or RECURRING"), + + body("receipt_upload") + .optional() + .isString() + .withMessage("Receipt upload must be a string") + .isURL() + .withMessage("Receipt upload must be a valid URL"), + + body("expense_date") + .optional() + .isISO8601() + .withMessage("Expense date must be a valid date") + .toDate(), + + body("start_date") + .optional() + .isISO8601() + .withMessage("Start date must be a valid date") + .toDate(), + + body("end_date") + .optional() + .isISO8601() + .withMessage("End date must be a valid date") + .toDate(), + + body("category_id") + .isInt({ min: 1 }) + .withMessage("Category ID must be a positive integer") + .notEmpty() + .withMessage("Category ID is required"), +]; + +export const updateExpenseValidator = [ + param("id") + .isInt({ min: 1 }) + .withMessage("Expense ID must be a positive integer") + .toInt(), + + body("amount") + .optional() + .isFloat({ gt: 0 }) + .withMessage("Amount must be a positive number"), + + body("description") + .optional() + .isString() + .withMessage("Description must be a string") + .isLength({ max: 2000 }) + .withMessage("Description must be less than 2000 characters"), + + body("type") + .optional() + .isIn(["ONE_TIME", "RECURRING"]) + .withMessage("Type must be either ONE_TIME or RECURRING"), + + body("receipt_upload") + .optional() + .isString() + .withMessage("Receipt upload must be a string") + .isURL() + .withMessage("Receipt upload must be a valid URL"), + + body("expense_date") + .optional() + .isISO8601() + .withMessage("Expense date must be a valid date") + .toDate(), + + body("start_date") + .optional() + .isISO8601() + .withMessage("Start date must be a valid date") + .toDate(), + + body("end_date") + .optional() + .isISO8601() + .withMessage("End date must be a valid date") + .toDate(), + + body("category_id") + .optional() + .isInt({ min: 1 }) + .withMessage("Category ID must be a positive integer"), +]; + +export const getExpenseValidator = [ + param("id") + .isInt({ min: 1 }) + .withMessage("Expense ID must be a positive integer") + .toInt(), +]; + +export const deleteExpenseValidator = [ + param("id") + .isInt({ min: 1 }) + .withMessage("Expense ID must be a positive integer") + .toInt(), +]; + +export const listExpensesValidator = [ + query("startDate") + .optional() + .isISO8601() + .withMessage("Start date must be a valid date"), + + query("endDate") + .optional() + .isISO8601() + .withMessage("End date must be a valid date"), + + query("categoryId") + .optional() + .isInt({ min: 1 }) + .withMessage("Category ID must be a positive integer") + .toInt(), + + query("type") + .optional() + .isIn(["ONE_TIME", "RECURRING"]) + .withMessage("Type must be either ONE_TIME or RECURRING"), +];