From 4c49b4731803637ce72dbfddc63c2d23f898ea0c Mon Sep 17 00:00:00 2001 From: arnb-smnta Date: Wed, 1 May 2024 20:00:40 +0530 Subject: [PATCH] feat: expensesplit-app backend --- src/app.js | 6 + src/constants.js | 36 + .../expense-split-app/expense.controller.js | 930 ++++++++++++++++++ .../expense-split-app/group.controller.js | 725 ++++++++++++++ .../apps/expense-split-app/expense.model.js | 67 ++ .../expense-split-app/expensegroup.model.js | 55 ++ .../expense-split-app/settlement.model.js | 32 + .../apps/expense-split-app/expense.routes.js | 122 +++ .../expense-split-app/expensegroup.routes.js | 103 ++ .../expense-split-app/expense.validator.js | 16 + .../expenseGroup.validator.js | 15 + yarn.lock | 9 +- 12 files changed, 2112 insertions(+), 4 deletions(-) create mode 100644 src/controllers/apps/expense-split-app/expense.controller.js create mode 100644 src/controllers/apps/expense-split-app/group.controller.js create mode 100644 src/models/apps/expense-split-app/expense.model.js create mode 100644 src/models/apps/expense-split-app/expensegroup.model.js create mode 100644 src/models/apps/expense-split-app/settlement.model.js create mode 100644 src/routes/apps/expense-split-app/expense.routes.js create mode 100644 src/routes/apps/expense-split-app/expensegroup.routes.js create mode 100644 src/validators/apps/expense-split-app/expense.validator.js create mode 100644 src/validators/apps/expense-split-app/expenseGroup.validator.js diff --git a/src/app.js b/src/app.js index 25215f3a..de999af8 100644 --- a/src/app.js +++ b/src/app.js @@ -143,6 +143,8 @@ import redirectRouter from "./routes/kitchen-sink/redirect.routes.js"; import requestinspectionRouter from "./routes/kitchen-sink/requestinspection.routes.js"; import responseinspectionRouter from "./routes/kitchen-sink/responseinspection.routes.js"; import statuscodeRouter from "./routes/kitchen-sink/statuscode.routes.js"; +import expenseGroupRouter from "./routes/apps/expense-split-app/expensegroup.routes.js"; +import expenseRouter from "./routes/apps/expense-split-app/expense.routes.js"; // * Seeding handlers import { avoidInProduction } from "./middlewares/auth.middlewares.js"; @@ -189,6 +191,10 @@ app.use("/api/v1/social-media/comments", socialCommentRouter); app.use("/api/v1/chat-app/chats", chatRouter); app.use("/api/v1/chat-app/messages", messageRouter); +//* expense Split-app api's +app.use("/api/v1/expenseGroup", expenseGroupRouter); +app.use("/api/v1/expense", expenseRouter); + app.use("/api/v1/todos", todoRouter); // * Kitchen sink apis diff --git a/src/constants.js b/src/constants.js index 704e1732..7c3e7437 100644 --- a/src/constants.js +++ b/src/constants.js @@ -103,3 +103,39 @@ export const ChatEventEnum = Object.freeze({ }); export const AvailableChatEvents = Object.values(ChatEventEnum); + +/** + * Type of expense types we can categorise it + */ +export const ExpenseTypes = { + FOOD_AND_DRINK: "Food & drink", + SHOPPING: "shopping", + ENTERTAINMENT: "entertainment", + HOME: "Home", + TRANSPORTATION: "Transportation", + OTHERS: "Others", +}; + +export const AvailableExpenseTypes = Object.values(ExpenseTypes); + +/** + * Type of Expense group type we can categorize it + */ + +export const ExpenseGroupTypes = { + HOME: "Home", + TRIP: "Trip", + OFFICE: "Office", + SPORTS: "Sports", + OTHERS: "Others", +}; + +export const AvailableExpenseGroupTypes = Object.values(ExpenseGroupTypes); + +export const PaymentMethods = { + CASH: "Cash", + UPI: "Upi", + CARD: "Card", +}; + +export const AvailablePaymentMethods = Object.values(PaymentMethods); diff --git a/src/controllers/apps/expense-split-app/expense.controller.js b/src/controllers/apps/expense-split-app/expense.controller.js new file mode 100644 index 00000000..35df4350 --- /dev/null +++ b/src/controllers/apps/expense-split-app/expense.controller.js @@ -0,0 +1,930 @@ +import { User } from "../../../models/apps/auth/user.models.js"; +import { Expense } from "../../../models/apps/expense-split-app/expense.model.js"; +import { ApiError } from "../../../utils/ApiError.js"; +import { asyncHandler } from "../../../utils/asyncHandler.js"; +import { ApiResponse } from "../../../utils/ApiResponse.js"; +import { ExpenseGroup } from "../../../models/apps/expense-split-app/expensegroup.model.js"; +import { addSplit, clearSplit } from "./group.controller.js"; +import mongoose from "mongoose"; +import { getLocalPath, getStaticFilePath } from "../../../utils/helpers.js"; +const commonExpenseAggregations = () => { + return [ + { + $lookup: { + from: "users", + localField: "Owner", + foreignField: "_id", + as: "Owner", + pipeline: [ + { + $project: { + password: 0, + refreshToken: 0, + forgotPasswordToken: 0, + forgotPasswordExpiry: 0, + emailVerificationToken: 0, + emailVerificationExpiry: 0, + }, + }, + ], + }, + }, + { + $lookup: { + from: "users", + foreignField: "_id", + localField: "participants", + as: "participants", + pipeline: [ + { + $project: { + password: 0, + refreshToken: 0, + forgotPasswordToken: 0, + forgotPasswordExpiry: 0, + emailVerificationToken: 0, + emailVerificationExpiry: 0, + }, + }, + ], + }, + }, + { + $lookup: { + from: "expensegroups", + foreignField: "_id", + localField: "groupId", + as: "groupId", + pipeline: [ + { + $lookup: { + from: "users", + foreignField: "_id", + localField: "participants", + as: "participants", + pipeline: [ + { + $project: { + password: 0, + refreshToken: 0, + forgotPasswordToken: 0, + forgotPasswordExpiry: 0, + emailVerificationToken: 0, + emailVerificationExpiry: 0, + }, + }, + ], + }, + }, + { + $lookup: { + from: "users", + localField: "groupOwner", + foreignField: "_id", + as: "groupOwner", + pipeline: [ + { + $project: { + password: 0, + refreshToken: 0, + forgotPasswordToken: 0, + forgotPasswordExpiry: 0, + emailVerificationToken: 0, + emailVerificationExpiry: 0, + }, + }, + ], + }, + }, + ], + }, + }, + ]; +}; + +const addExpense = asyncHandler(async (req, res) => { + const { groupId } = req.params; + const { + name, + description, + Amount, + Category, + expenseDate, + participants, + expenseMethod, + Owner, + } = req.body; + + //To see if owner exists or not + + const ownerUser = await User.findOne({ + _id: new mongoose.Types.ObjectId(Owner), + }); + + if (!ownerUser) { + throw new ApiError(404, "Owner not found"); + } + + //To see if group exists or not + + const group = await ExpenseGroup.findById(groupId); + + if (!group) { + throw new ApiError(404, "Group not found"); + } + + //Owner has to be participant of the group to add expense in the group + if (!group.participants.includes(Owner)) { + throw new ApiError(400, "Owner must be part of the group"); + } + + const members = [...new Set([...participants])]; //Checking for duplicate id's and removing them if present + + const invalidParticipants = members.filter( + (participant) => !group.participants.includes(participant) + ); + + //Check if participant are present in the group all the particpants of expenses has to be particiapant of group + + if (invalidParticipants.length > 0) { + throw new ApiError(400, "All participants must be part of the group"); + } + + const billFiles = []; + + //For photos of bills of expenses + if (req.files && req.files.billAttachments?.length > 0) { + req.files.billAttachments?.map((attachment) => { + billFiles.push({ + url: getStaticFilePath(req, attachment.filename), + localPath: getLocalPath(attachment.filename), + }); + }); + } + + const expensePerMember = Amount / members.length; + const newExpense = await Expense.create({ + name, + description, + Amount, + Category, + expenseDate, + expenseMethod, + Owner: new mongoose.Types.ObjectId(ownerUser._id), + participants: members, + expensePerMember, + groupId: new mongoose.Types.ObjectId(groupId), + billAttachments: billFiles, + }); + + if (!newExpense) { + throw new ApiError(500, "Internal Server Error"); + } + + //New Expense is created we need to update the split values in the group and the group total + + addSplit(groupId, Amount, Owner, members); + + const aggregatedExpense = await Expense.aggregate([ + { + $match: { + _id: newExpense._id, + }, + }, + ...commonExpenseAggregations(), + ]); + const payload = aggregatedExpense[0]; + return res + .status(200) + .json(new ApiResponse(200, { payload }, "new Expense created succesfully")); +}); + +const editExpense = asyncHandler(async (req, res) => { + const { expenseId } = req.params; + + const { + name, + description, + Amount, + Category, + expenseDate, + participants, + expenseMethod, + Owner, + } = req.body; + + const oldExpense = await Expense.findById(expenseId); + + if (!oldExpense) { + throw new ApiError(404, "Expense not found, Invalid expense Id"); + } + + const group = await ExpenseGroup.find({ + _id: new mongoose.Types.ObjectId(oldExpense.groupId), + }); + + if (!group) { + throw new ApiError(404, "Group not found,Invalid expense to be ediited"); + } + + if (oldExpense.Owner.toString() !== req.user._id.toString()) { + throw new ApiError(403, "You are not authorised to perform this action"); + } + + //Clearing the split in group for the old expense + await clearSplit( + oldExpense.groupId, + oldExpense.Amount, + oldExpense.Owner, + oldExpense.participants + ); + + if (name) { + oldExpense.name = name; + } + if (description) { + oldExpense.description = description; + } + if (Amount) { + oldExpense.Amount = Amount; + } + if (Category) { + oldExpense.Category = Category; + } + if (expenseDate) { + oldExpense.expenseDate = expenseDate; + } + if (participants) { + const members = [...new Set([...participants])]; //Checking for duplicate id's and removing them if present + + const invalidParticipants = members.filter( + (participant) => !group.participants.includes(participant) + ); + + if (invalidParticipants.length > 0) { + throw new ApiError( + 403, + "Participants of expenses are not participants of group" + ); + } + + oldExpense.participants = members; + } + if (expenseMethod) { + oldExpense.expenseMethod = expenseMethod; + } + if (Owner) { + if (!group.participants.includes(Owner)) { + throw new ApiError(400, "Owner must be part of the group"); + } + oldExpense.Owner = Owner; + } + + //Redifining the expense per memeber if it was possibly changed + + const expensePerMember = ( + oldExpense.Amount / oldExpense.participants.length + ).toFixed(2); + + oldExpense.expensePerMember = expensePerMember; + + //Have to update the split values once again + + //Saving the new expense + await oldExpense.save(); + + //Adding the new split in group + //Old expense is updated with new values + await addSplit( + oldExpense.groupId, + oldExpense.Amount, + oldExpense.Owner, + oldExpense.participants + ); + + //Common expense aggregations + + const aggregatedExpense = await Expense.aggregate([ + { + $match: { + _id: oldExpense._id, + }, + }, + ...commonExpenseAggregations(), + ]); + + const payload = aggregatedExpense[0]; + + return res + .status(200) + .json(new ApiResponse(200, { payload }, "Expense updated succesfully")); +}); +const deleteExpense = asyncHandler(async (req, res) => { + const { expenseId } = req.params; + + const expense = await Expense.findById(expenseId); + + if (!expense) { + throw new ApiError(404, "expense not found, Invalid expense Id"); + } + + //The logged in user has to be the owner of the expense to delete it + + if (expense.Owner.toString() !== req.user._id.toString()) { + throw new ApiError(403, "You are not authorised to perform this action"); + } + + await Expense.findByIdAndDelete(expenseId); + + await clearSplit( + expense.groupId, + expense.Amount, + expense.Owner, + expense.participants + ); + + return res + .status(200) + .json(new ApiResponse(200, {}, "Expense Deleted successfully")); +}); +const viewExpense = asyncHandler(async (req, res) => { + const { expenseId } = req.params; + + const expense = await Expense.findById(expenseId); + + if (!expense) { + throw new ApiError(404, "Expense not found, invalid expense Id"); + } + + const group = await ExpenseGroup.find({ + _id: new mongoose.Types.ObjectId(expense.groupId), + }); + + //Just a fall through case + + if (!group) { + throw new ApiError(404, "Group to which this expense is does not exists"); + } + + if (!group.participants.includes(req.user._id)) { + throw new ApiError( + 403, + "You are not participant of this group ,You cannot view this expense" + ); + } + + const aggregatedExpense = await Expense.aggregate([ + { + $match: { _id: expense._id }, + }, + ...commonExpenseAggregations(), + ]); + + const payload = aggregatedExpense[0]; + + return res + .status(200) + .json(new ApiResponse(200, { payload }, "Expense Fetched succesfully")); +}); +const viewGroupExpense = asyncHandler(async (req, res) => { + const { groupId } = req.params; + + const group = await ExpenseGroup.findById(groupId); + if (!group) { + throw new ApiError(404, "Group not found, Invalid group Id"); + } + + if (!group.participants.includes(req.user._id)) { + throw new ApiError(403, "You are not part of this group to see expenses"); + } + + const groupExpenses = await Expense.find({ + groupId: groupId, + }).sort({ + updatedAt: -1, + }); + + if (groupExpenses.length === 0) { + return res + .status(200) + .json(new ApiError(200, {}, "No expense in the group")); + } + + var totalAmount = 0; + + for (let expense of groupExpenses) { + totalAmount += Number(expense.Amount); + } + + const agrregatedExpenses = groupExpenses.map(async (expense) => { + const agrregatedExpense = await Expense.aggregate([ + { + $match: { + _id: expense._id, + }, + }, + ...commonExpenseAggregations(), + ]); + return agrregatedExpense[0]; + }); + + const expenses = await Promise.all(agrregatedExpenses); + + res.status(200).json( + new ApiResponse( + 200, + { + payload: { expenses, totalAmount: totalAmount }, + }, + "Group expenses fetched succesfully" + ) + ); +}); +const recentUserExpense = asyncHandler(async (req, res) => { + //Top 5 recent user expense + + const recentExpense = await Expense.find({ + participants: req.user._id, + }) + .sort({ + updatedAt: -1, + }) + .limit(5); + + if (recentExpense.length === 0) { + return res.status(200).json(new ApiResponse(200, {}, "No recent expenses")); + } + + const aggregatedExpenses = recentExpense.map(async (expense) => { + const agrregatedExpense = await Expense.aggregate([ + { + $match: { + _id: expense._id, + }, + }, + ...commonExpenseAggregations(), + ]); + return agrregatedExpense[0]; + }); + + const payload = await Promise.all(aggregatedExpenses); + + return res + .status(200) + .json( + new ApiResponse(200, { payload }, "Recent Expenses fetched succesfully") + ); +}); +const groupCategoryExpense = asyncHandler(async (req, res) => { + const { groupId } = req.params; + //Some validations left + + const group = await ExpenseGroup.find({ + _id: new mongoose.Types.ObjectId(groupId), + }); + + if (!group) { + throw new ApiError(404, "Group not found invalid group id"); + } + + if (!group.participants.includes(req.user._id)) { + throw new ApiError( + 403, + "You must be a participant of this group to perform this action" + ); + } + + const categoryWiseExpenses = await Expense.aggregate([ + { + $match: { + groupId: new mongoose.Types.ObjectId(groupId), // Replace this ObjectId with your actual groupId + }, + }, + { + $group: { + _id: "$Category", + expenses: { + $push: "$$ROOT", + }, + total: { + $sum: "$Amount", + }, + }, + }, + { + $project: { + _id: 1, + expenses: 1, + total: 1, + }, + }, + ]); + + if (categoryWiseExpenses.length < 0) { + return res + .status(200) + .json(new ApiResponse(200, {}, "Group has no expenses")); + } + + const aggregatedExpenses = categoryWiseExpenses.map(async (expenseCat) => { + const aggexp = expenseCat.expenses.map(async (expense) => { + const payload = await Expense.aggregate([ + { + $match: { _id: expense._id }, + }, + ...commonExpenseAggregations(), + ]); + + return payload[0]; + }); + + const awaitedReq = await Promise.all(aggexp); + return { _id: expenseCat._id, expenses: awaitedReq }; + }); + + const payload = await Promise.all(aggregatedExpenses); + return res + .status(200) + .json( + new ApiResponse( + 200, + { payload }, + "Category wise group expense fetched succesfully" + ) + ); +}); +const userCategoryExpense = asyncHandler(async (req, res) => { + const categoryWiseExpenses = await Expense.aggregate([ + { + $match: { + participants: new mongoose.Types.ObjectId(req.user._id), + }, + }, + { + $group: { + _id: "$Category", + expenses: { + $push: "$$ROOT", + }, + total: { + $sum: "$Amount", + }, + }, + }, + { + $project: { + _id: 1, + expenses: 1, + total: 1, + }, + }, + ]); + + if (categoryWiseExpenses.length < 1) { + return res + .status(200) + .json(new ApiResponse(200, {}, "User has no expense")); + } + + const aggregatedExpenses = categoryWiseExpenses.map(async (expenseCat) => { + const aggexp = expenseCat.expenses.map(async (expense) => { + const payload = await Expense.aggregate([ + { + $match: { _id: expense._id }, + }, + ...commonExpenseAggregations(), + ]); + + return payload[0]; + }); + + const awaitedReq = await Promise.all(aggexp); + return { _id: expenseCat._id, expenses: awaitedReq }; + }); + + const payload = await Promise.all(aggregatedExpenses); + + return res + .status(200) + .json( + new ApiResponse( + 200, + { payload }, + "User category expenses fetched succesfully" + ) + ); +}); +const groupMonthlyExpense = asyncHandler(async (req, res) => { + const { groupId } = req.params; + + const expenseGroup = await ExpenseGroup.findById(groupId); + + if (!expenseGroup) { + throw new ApiError(404, "Group not found, invalid group Id"); + } + + if (!expenseGroup.participants.includes(req.user._id)) { + throw new ApiError(403, "You are not part of this group"); + } + + const monthlyExpenses = await Expense.aggregate([ + { + $match: { + groupId: new mongoose.Types.ObjectId(groupId), // Replace this ObjectId with your actual groupId + }, + }, + { + $group: { + _id: { + month: { $month: "$expenseDate" }, + year: { $year: "$expenseDate" }, + }, + amount: { $sum: "$Amount" }, + expenses: { $push: "$$ROOT" }, + }, + }, + { + $sort: { "_id.month": 1 }, + }, + ]); + + if (monthlyExpenses.length < 1) { + return res + .status(200) + .json(new ApiResponse(200, {}, "No expenses found in this group")); + } + const aggregatedExpenses = monthlyExpenses.map(async (expenseMonthly) => { + const aggexp = expenseMonthly.expenses.map(async (expense) => { + const payload = await Expense.aggregate([ + { + $match: { _id: expense._id }, + }, + ...commonExpenseAggregations(), + ]); + + return payload[0]; + }); + + const awaitedReq = await Promise.all(aggexp); + return { + _id: expenseMonthly._id, + amount: expenseMonthly.amount, + expenses: awaitedReq, + }; + }); + + const payload = await Promise.all(aggregatedExpenses); + + return res + .status(200) + .json( + new ApiResponse(200, { payload }, "Monthly expenses fetched succesfully") + ); +}); +const groupDailyExpense = asyncHandler(async (req, res) => { + const { groupId } = req.params; + + const group = await ExpenseGroup.find({ + _id: new mongoose.Types.ObjectId(groupId), + }); + if (!group) { + throw new ApiError(404, "Group not found invalid group Id"); + } + + if (!group.participants.includes(req.user._id)) { + throw new ApiError(403, "You are not part of this group"); + } + + const dailyExpenses = await Expense.aggregate([ + { + $match: { + groupId: new mongoose.Types.ObjectId(groupId), // Replace this ObjectId with your actual groupId + }, + }, + { + $group: { + _id: { + day: { $dayOfMonth: "$expenseDate" }, + month: { $month: "$expenseDate" }, + year: { $year: "$expenseDate" }, + }, + amount: { $sum: "$Amount" }, + expenses: { $push: "$$ROOT" }, + }, + }, + { + $sort: { "_id.year": 1, "_id.month": 1, "_id.day": 1 }, + }, + ]); + + if (dailyExpenses.length < 1) { + return res + .status(200) + .json(new ApiResponse(200, {}, "Group has no expenses")); + } + const aggregatedExpenses = dailyExpenses.map(async (expenseDaily) => { + const aggexp = expenseDaily.expenses.map(async (expense) => { + const payload = await Expense.aggregate([ + { + $match: { _id: expense._id }, + }, + ...commonExpenseAggregations(), + ]); + + return payload[0]; + }); + + const awaitedReq = await Promise.all(aggexp); + return { + _id: expenseDaily._id, + amount: expenseDaily.amount, + expenses: awaitedReq, + }; + }); + + const payload = await Promise.all(aggregatedExpenses); + + return res + .status(200) + .json( + new ApiResponse(200, { payload }, "Monthly expenses fetched succesfully") + ); +}); +const userMonthlyExpense = asyncHandler(async (req, res) => { + const monthlyExpenses = await Expense.aggregate([ + { + $match: { + participants: new mongoose.Types.ObjectId(req.user._id), // Replace this ObjectId with your actual groupId + }, + }, + { + $group: { + _id: { + month: { $month: "$expenseDate" }, + year: { $year: "$expenseDate" }, + }, + amount: { $sum: "$Amount" }, + expenses: { $push: "$$ROOT" }, + }, + }, + { + $sort: { "_id.month": 1 }, + }, + ]); + + if (monthlyExpenses.length < 1) { + return res + .status(200) + .json(new ApiResponse(200, {}, "User has no expenses")); + } + + const aggregatedExpenses = monthlyExpenses.map(async (expenseMonthly) => { + const aggexp = expenseMonthly.expenses.map(async (expense) => { + const payload = await Expense.aggregate([ + { + $match: { _id: expense._id }, + }, + ...commonExpenseAggregations(), + ]); + + return payload[0]; + }); + + const awaitedReq = await Promise.all(aggexp); + return { + _id: expenseMonthly._id, + amount: expenseMonthly.amount, + expenses: awaitedReq, + }; + }); + + const payload = await Promise.all(aggregatedExpenses); + + res + .status(200) + .json( + new ApiResponse( + 200, + { payload }, + "User monthly expense fetched succesfully" + ) + ); +}); +const userDailyExpense = asyncHandler(async (req, res) => { + const dailyExpenses = await Expense.aggregate([ + { + $match: { + participants: new mongoose.Types.ObjectId(req.user._id), // Replace this ObjectId with your actual groupId + }, + }, + { + $group: { + _id: { + day: { $dayOfMonth: "$expenseDate" }, + month: { $month: "$expenseDate" }, + year: { $year: "$expenseDate" }, + }, + amount: { $sum: "$Amount" }, + expenses: { $push: "$$ROOT" }, + }, + }, + { + $sort: { "_id.year": 1, "_id.month": 1, "_id.day": 1 }, + }, + ]); + + if (dailyExpenses.length < 1) { + return res + .status(200) + .json(new ApiResponse(200, {}, "user has no expenses")); + } + const aggregatedExpenses = dailyExpenses.map(async (expenseDaily) => { + const aggexp = expenseDaily.expenses.map(async (expense) => { + const payload = await Expense.aggregate([ + { + $match: { _id: expense._id }, + }, + ...commonExpenseAggregations(), + ]); + + return payload[0]; + }); + + const awaitedReq = await Promise.all(aggexp); + return { + _id: expenseDaily._id, + amount: expenseDaily.amount, + expenses: awaitedReq, + }; + }); + + const payload = await Promise.all(aggregatedExpenses); + return res + .status(200) + .json( + new ApiResponse( + 200, + { payload }, + "User daily expense fetched succesfully" + ) + ); +}); +const viewUserExpense = asyncHandler(async (req, res) => { + const userExpenses = await Expense.find({ + participants: req.user?._id, + }).sort({ + updatedAt: -1, //to get the newest first + }); + + if (userExpenses.length < 1) { + return res + .status(200) + .json(new ApiResponse(200, {}, "User has no expense")); + } + + var totalAmount = 0; + + for (let expense of userExpenses) { + totalAmount += Number(expense.expensePerMember); + } + + const agrregatedExpenses = userExpenses.map(async (expense) => { + const agrregatedExpense = await Expense.aggregate([ + { + $match: { + _id: expense._id, + }, + }, + ...commonExpenseAggregations(), + ]); + return agrregatedExpense[0]; + }); + + const expenses = await Promise.all(agrregatedExpenses); + + res + .status(200) + .json( + new ApiResponse( + 200, + { payload: { expenses: expenses, total: totalAmount } }, + "User expenses fetched succesfully" + ) + ); +}); + +export { + addExpense, + editExpense, + deleteExpense, + viewExpense, + viewGroupExpense, + recentUserExpense, + groupCategoryExpense, + userCategoryExpense, + groupMonthlyExpense, + groupDailyExpense, + userMonthlyExpense, + userDailyExpense, + viewUserExpense, +}; diff --git a/src/controllers/apps/expense-split-app/group.controller.js b/src/controllers/apps/expense-split-app/group.controller.js new file mode 100644 index 00000000..b5d968f9 --- /dev/null +++ b/src/controllers/apps/expense-split-app/group.controller.js @@ -0,0 +1,725 @@ +import { asyncHandler } from "../../../utils/asyncHandler.js"; +import { ExpenseGroup } from "../../../models/apps/expense-split-app/expensegroup.model.js"; +import { Expense } from "../../../models/apps/expense-split-app/expense.model.js"; +import { ApiResponse } from "../../../utils/ApiResponse.js"; +import { ExpenseGroupTypes } from "../../../constants.js"; +import { ApiError } from "../../../utils/ApiError.js"; +import { Settlement } from "../../../models/apps/expense-split-app/settlement.model.js"; +import { User } from "../../../models/apps/auth/user.models.js"; +import { removeLocalFile } from "../../../utils/helpers.js"; +import mongoose from "mongoose"; +const commonSettlementAggregations = () => { + return [ + { + $lookup: { + from: "users", + localField: "settleTo", + foreignField: "_id", + as: "settleTo", + pipeline: [ + { + $project: { + password: 0, + refreshToken: 0, + forgotPasswordToken: 0, + forgotPasswordExpiry: 0, + emailVerificationToken: 0, + emailVerificationExpiry: 0, + }, + }, + ], + }, + }, + { + $lookup: { + from: "users", + localField: "settleFrom", + foreignField: "_id", + as: "settleFrom", + pipeline: [ + { + $project: { + password: 0, + refreshToken: 0, + forgotPasswordToken: 0, + forgotPasswordExpiry: 0, + emailVerificationToken: 0, + emailVerificationExpiry: 0, + }, + }, + ], + }, + }, + { + $lookup: { + from: "expensegroups", + localField: "groupId", + foreignField: "_id", + as: "groupId", + pipeline: [ + { + $lookup: { + from: "users", + foreignField: "_id", + localField: "participants", + as: "participants", + pipeline: [ + { + $project: { + password: 0, + refreshToken: 0, + forgotPasswordToken: 0, + forgotPasswordExpiry: 0, + emailVerificationToken: 0, + emailVerificationExpiry: 0, + }, + }, + ], + }, + }, + ], + }, + }, + ]; +}; +const commonGroupAggregation = () => { + //This is the common aggregation for Response structure of group + // ! Have to figure out the split lookup [work in progress] + return [ + { + $lookup: { + from: "users", + foreignField: "_id", + localField: "participants", + as: "participants", + pipeline: [ + { + $project: { + password: 0, + refreshToken: 0, + forgotPasswordToken: 0, + forgotPasswordExpiry: 0, + emailVerificationToken: 0, + emailVerificationExpiry: 0, + }, + }, + ], + }, + }, + { + $lookup: { + from: "users", + localField: "groupOwner", + foreignField: "_id", + as: "groupOwner", + pipeline: [ + { + $project: { + password: 0, + refreshToken: 0, + forgotPasswordToken: 0, + forgotPasswordExpiry: 0, + emailVerificationToken: 0, + emailVerificationExpiry: 0, + }, + }, + ], + }, + }, + ]; +}; + +const deleteCascadeExpenses = async (groupId) => { + // Helper function to delete the expenses when a group is deleted along with its settlements + + const expenses = await Expense.find({ + groupId: groupId, + }); + + let attachments = []; + + attachments = attachments.concat( + ...expenses.map((expense) => expense.billAttachments) + ); + + attachments.forEach((attachment) => { + removeLocalFile(attachment.localPath); + }); + + await Expense.deleteMany({ + groupId: new mongoose.Types.ObjectId(groupId), + }); + + await Settlement.deleteMany({ + groupId: new mongoose.Types.ObjectId(groupId), + }); +}; + +const searchAvailableUsers = asyncHandler(async (req, res) => { + const users = await User.aggregate([ + { + $match: { + _id: { + $ne: req.user._id, // avoid logged in user + }, + }, + }, + { + $project: { + avatar: 1, + username: 1, + email: 1, + }, + }, + ]); + + return res + .status(200) + .json(new ApiResponse(200, users, "Users fetched successfully")); +}); + +const createExpenseGroup = asyncHandler(async (req, res) => { + const { name, description, participants, groupCategory } = req.body; + + // Check if user is not sending himself as a participant. This will be done manually + if (participants.includes(req.user._id.toString())) { + throw new ApiError( + 400, + "Participants array should not contain the group creator" + ); + } + //Name and Participants is already checke din validator no need to check here + const members = [...new Set([...participants, req.user._id.toString()])]; //Prevents duplications + + let splitJson = {}; // Initializing the split of the group + for (let user of members) { + splitJson[user] = 0; + } + let split = splitJson; + + var group = await ExpenseGroup.create({ + name, + description, + groupOwner: req.user._id, + participants: members, + groupCategory: groupCategory ? groupCategory : ExpenseGroupTypes.OTHERS, + split: split, + }); + + const newGroup = await ExpenseGroup.aggregate([ + { + $match: { + _id: group._id, + }, + }, + ...commonGroupAggregation(), + ]); + + const payload = newGroup[0]; + return res + .status(200) + .json(new ApiResponse(200, { payload }, "Group created succesfully")); +}); +const viewExpenseGroup = asyncHandler(async (req, res) => { + const { groupId } = req.params; + + const group = await ExpenseGroup.findById(groupId); + + if (!group) { + throw new ApiError(404, "Group not found, Invalid group id"); + } + + if (!group.participants.includes(req.user._id.toString())) { + throw new ApiError(403, "You are not part of the group"); + } + + //Doing the common aggregations + + const Group = await ExpenseGroup.aggregate([ + { + $match: { + _id: group._id, + }, + }, + ...commonGroupAggregation(), + ]); + + return res + .status(200) + .json(new ApiResponse(200, { Group }, "Group fetched succesfully")); +}); +const getUserExpenseGroups = asyncHandler(async (req, res) => { + const userGroups = await ExpenseGroup.find({ + participants: req.user._id, + }).sort({ + createdAt: -1, + }); //Will have to sort with aggregations for newer first + + if (userGroups.length < 1) { + return res + .status(200) + .json(new ApiResponse(200, {}, "User is not part of any expense groups")); + } + + const aggregatedGroups = userGroups.map(async (group) => { + const aggregatedGroup = await ExpenseGroup.aggregate([ + { + $match: { + _id: group._id, + }, + }, + ...commonGroupAggregation(), + ]); + return aggregatedGroup[0]; + }); + + const groups = await Promise.all(aggregatedGroups); + + return res + .status(200) + .json(new ApiResponse(200, { groups }, "User groups fetched succesfully")); +}); +const groupBalaceSheet = asyncHandler(async (req, res) => { + const { groupId } = req.params; + + const expenseGroup = await ExpenseGroup.findById(groupId); + + if (!expenseGroup) { + throw new ApiError(404, "Group not found, Invalid group ID"); + } + + if (!expenseGroup.participants.includes(req.user._id)) { + throw new ApiError(403, "You are not participant of this group"); + } + + const balanceData = groupBalanceCalculator(expenseGroup.split); + + const agrregatedData = balanceData.map(async (data) => { + let array = []; + console.log(data); + for (let i = 0; i <= 1; i++) { + const user = await User.findById(data[i]).select( + " -password -refreshToken -forgotPasswordToken -forgotPasswordExpiry -emailVerificationToken -emailVerificationExpiry" + ); + if (i === 0) { + array.push({ settleFrom: user }); + } else { + array.push({ settleTo: user }); + } + } + + array.push({ value: data[2] }); + + return array; + }); + + const payload = await Promise.all(agrregatedData); + + return res + .status(200) + .json( + new ApiResponse(200, { payload }, "Group balance fetched succesfully") + ); + + //Work in progress + + //This will return all the balances accumulated in a group who owes whom and how much by analyzing the group split and expenses +}); +const makeSettlement = asyncHandler(async (req, res) => { + const { groupId } = req.params; + + const { settleTo, settleFrom, settleAmount, settleDate } = req.body; + + if (!settleTo || !settleFrom || !settleAmount || !settleDate) { + throw new ApiError(400, "All the fields are required"); + } + + const group = await ExpenseGroup.findById(groupId); + if (!group) { + throw new ApiError(404, "Group not found, Invalid group Id"); + } + if (!group.participants.includes(req.user._id)) { + throw new ApiError(403, "You are not part of this group"); + } + + if ( + settleTo.toString() !== req.user._id.toString() && + settleFrom !== req.user._id.toString() + ) { + throw new ApiError( + 403, + "You can only settle with yourself or the other participant" + ); + } + + const updatedSplit = group.split; + + updatedSplit.set( + String(settleFrom), + updatedSplit.get(settleFromId) + settleAmount + ); + updatedSplit.set( + String(settleTo), + Number(updatedSplit.get(settleToId)) - Number(settleAmount) + ); + + // Save the updated split back to the group + group.split = updatedSplit; + + const settlement = await Settlement.create({ + settleTo, + settleFrom, + SettlementDate: settleDate || Date.now(), + Amount: settleAmount, + groupId, + }); + + await group.save(); + + const aggregatedSettlement = await Settlement.aggregate([ + { + $match: { + _id: settlement._id, + }, + }, + ...commonSettlementAggregations(), + ]); + + res.status(200).json(new ApiResponse(200, {}, "Settlement done succesfully")); +}); +const deleteExpenseGroup = asyncHandler(async (req, res) => { + const { groupId } = req.params; + const expenseGroup = await ExpenseGroup.findById(groupId); + if (!expenseGroup) { + throw new ApiError(404, "Group not found, Invalid group id"); + } + + if (expenseGroup.groupOwner.toString() !== req.user._id.toString()) { + throw new ApiError( + 403, + "you are not the owner of this group to perform this action" + ); + } + + await deleteCascadeExpenses(groupId); + await ExpenseGroup.findByIdAndDelete(groupId); + return res + .status(200) + .json(new ApiResponse(200, {}, "Expense Group deleted succesfully")); +}); +const editExpenseGroup = asyncHandler(async (req, res) => { + const { groupId } = req.params; + const group = await ExpenseGroup.findById(groupId); + const { name, description } = req.body; + if (!group) { + throw new ApiError(404, "Group not found, Invalid group Id"); + } + + if (!name && !description) { + throw new ApiError(400, "Enter something that needs to be updated"); + } + + if (group.groupOwner.toString() !== req.user?._id.toString()) { + throw new ApiError(403, "You are not authorised to perform this action"); + } + + if (name) { + group.name = name; + } + + if (description) { + group.description = description; + } + + await group.save(); + + const updatedGroup = await ExpenseGroup.aggregate([ + { + $match: { + _id: group._id, + }, + }, + ...commonGroupAggregation(), + ]); + const payload = updatedGroup[0]; + return res + .status(200) + .json(new ApiResponse(200, { payload }, "Group updated succesfully")); +}); + +const addMembersInExpenseGroup = asyncHandler(async (req, res) => { + const { groupId, userId } = req.params; + const group = await ExpenseGroup.findById(groupId); + if (!group) { + throw new ApiError(404, "Group not found , Invalid group Id"); + } + const user = await User.findById(userId); + + if (!user) { + throw new ApiError(404, "User not found ,Invalid user id"); + } + if (group.groupOwner.toString() !== req.user?._id.toString()) { + throw new ApiError(403, "You are not authorised to perform this action"); + } + + const existingParticipants = group.participants; + + if (existingParticipants?.includes(userId)) { + throw new ApiError(409, "Participants already in group chat"); + } + //Updating the split of group with new member + group.set(`split.${userId.toString()}`, 0); + //Updating the participant id + group.participants.push(userId); + + await group.save(); + + const edittedGroup = await ExpenseGroup.aggregate([ + { + $match: { + _id: group._id, + }, + }, + ...commonGroupAggregation(), + ]); + const payload = edittedGroup[0]; + + return res + .status(200) + .json(new ApiResponse(200, { payload }, "Memeber added succesfully")); +}); + +const groupSettlementRecords = asyncHandler(async (req, res) => { + const { groupId } = req.params; + + const group = await ExpenseGroup.find({ + _id: new mongoose.Types.ObjectId(groupId), + }); + if (!group) { + throw new ApiError(404, "Group not found invalid group Id"); + } + + if (!group.participants.includes(req.user._id)) { + throw new ApiError(403, "You are not part of this group"); + } + + const settlements = await Settlement.find({ + groupId: new mongoose.Types.ObjectId(groupId), + }); + + if (settlements.length < 1) { + return res + .status(200) + .json(new ApiResponse(200, {}, "No group settlement records found")); + } + + const aggregatedSettlements = settlements.map(async (settlement) => { + const pipelineData = await Settlement.aggregate([ + { + $match: { + _id: settlement._id, + }, + }, + ...commonGroupAggregation(), + ]); + return pipelineData[0]; + }); + + const payload = await Promise.all(aggregatedSettlements); + + return res + .status(200) + .json( + new ApiResponse( + 200, + { payload }, + "Group settlement records fetched succesfully" + ) + ); +}); +const userSettlementRecords = asyncHandler(async (req, res) => { + const settlements = await Settlement.find({ + $or: [{ settleTo: req.user._id }, { settleFrom: req.user._id }], + }); + + const aggregatedSettlements = settlements.map(async (settlement) => { + if (aggregatedSettlements.length < 1) { + return res + .status(200) + .json(new ApiResponse(200, {}, "No user settlement records found")); + } + + const pipelineData = await Settlement.aggregate([ + { + $match: { + _id: settlement._id, + }, + }, + ...commonGroupAggregation(), + ]); + return pipelineData[0]; + }); + + const payload = await Promise.all(aggregatedSettlements); + + return res + .status(200) + .json(new ApiResponse(200, { payload }, "Settlement records")); +}); + +//Supporting function + +//Adding the split in group when creating or ediiting expense + +export const addSplit = async (groupId, Amount, Owner, members) => { + const group = await ExpenseGroup.findById(groupId); + + // Adding the expense amount in group total + group.groupTotal = Number(group.groupTotal) + Number(Amount); + + // Initialize split if it doesn't exist + if (!group.split || !(group.split instanceof Map)) { + group.split = new Map(); + } + + // Adding positive value to owner of the expense + if (!group.split.has(Owner)) { + group.split.set(Owner, 0); + } + group.split.set(Owner, group.split.get(Owner) + Amount); + + // Calculate expense per person + let expensePerPerson = Amount / members.length; + + // Updating the split values payable per user + members.forEach((user) => { + if (!group.split.has(user)) { + group.split.set(user, 0); + } + group.split.set(user, group.split.get(user) - expensePerPerson); + }); + + // Update group split for the owner + let bal = 0; + group.split.forEach((val) => { + bal += val; + }); + group.split.set(Owner, group.split.get(Owner) - bal); + + // Save the updated group + await group.save(); +}; + +//This works reverse of add split used for editting or deleting expense +export const clearSplit = async (groupId, Amount, Owner, participants) => { + let group = await ExpenseGroup.findById(groupId); + + // Subtract the expense amount from group total + group.groupTotal -= Amount; + + // Subtract the expense amount from owner's split + group.split.set(Owner, (group.split.get(Owner) || 0) - Amount); + + // Calculate expense per person + let expensePerPerson = Amount / participants.length; + expensePerPerson = expensePerPerson.toFixed(2); + + // Update split values for each participant + participants.forEach((user) => { + group.split.set( + user, + (group.split.get(user) || 0) + parseFloat(expensePerPerson) + ); + }); + + // Recalculate balance + let bal = 0; + group.split.forEach((val) => { + bal += val; + }); + + // Adjust owner's split to make the total balance zero + group.split.set(Owner, (group.split.get(Owner) || 0) - bal); + group.split.set(Owner, group.split.get(Owner).toFixed(2)); + + // Save the updated group + await group.save(); +}; + +//Responsible for finding out group balances who owes whom and how much a aggregated balance of all the split in group +const groupBalanceCalculator = (split) => { + const splits = []; + const transactionMap = split; + + // Function to settle similar figures + function settleSimilarFigures() { + const vis = new Map(); + for (let [transaction1, value1] of transactionMap.entries()) { + vis.set(transaction1, 1); + for (let [transaction2, value2] of transactionMap.entries()) { + if (!vis.has(transaction2) && transaction1 !== transaction2) { + if (value2 === -value1) { + if (value2 > value1) { + splits.push([transaction1, transaction2, value2]); + } else { + splits.push([transaction2, transaction1, value1]); + } + transactionMap.set(transaction2, 0); + transactionMap.set(transaction1, 0); + } + } + } + } + } + + // Helper function to find maximum and minimum values in the split + function getMaxMinCredit() { + let maxKey, minKey; + let max = Number.MIN_VALUE; + let min = Number.MAX_VALUE; + for (let [key, value] of transactionMap.entries()) { + if (value < min) { + min = value; + minKey = key; + } + if (value > max) { + max = value; + maxKey = key; + } + } + return [minKey, maxKey]; + } + + // Function to create settlement figures between uneven +ve and -ve values + function helper() { + const [minKey, maxKey] = getMaxMinCredit(); + if (!minKey || !maxKey) return; + const minValue = Math.min( + -transactionMap.get(minKey), + transactionMap.get(maxKey) + ); + transactionMap.set(minKey, transactionMap.get(minKey) + minValue); + transactionMap.set(maxKey, transactionMap.get(maxKey) - minValue); + const roundedMinValue = Math.round((minValue + Number.EPSILON) * 100) / 100; + const res = [minKey, maxKey, roundedMinValue]; + splits.push(res); + helper(); + } + + settleSimilarFigures(); + helper(); + return splits; +}; + +export { + createExpenseGroup, + viewExpenseGroup, + getUserExpenseGroups, + groupBalaceSheet, + makeSettlement, + deleteExpenseGroup, + editExpenseGroup, + addMembersInExpenseGroup, + groupSettlementRecords, + userSettlementRecords, +}; diff --git a/src/models/apps/expense-split-app/expense.model.js b/src/models/apps/expense-split-app/expense.model.js new file mode 100644 index 00000000..311f406f --- /dev/null +++ b/src/models/apps/expense-split-app/expense.model.js @@ -0,0 +1,67 @@ +import mongoose, { Schema } from "mongoose"; +import { + AvailableExpenseTypes, + AvailablePaymentMethods, + ExpenseTypes, + PaymentMethods, +} from "../../../constants.js"; + +const expenseSchema = mongoose.Schema( + { + name: { + type: String, + required: true, + }, + description: { + type: String, + }, + groupId: { + type: Schema.Types.ObjectId, + ref: "ExpenseGroup", + }, + Amount: { + type: Number, + required: true, + }, + Category: { + type: String, + enum: AvailableExpenseTypes, + default: ExpenseTypes.OTHERS, + }, + expenseDate: { + type: Date, + default: Date.now, + }, + Owner: { + type: Schema.Types.ObjectId, + ref: "User", + }, + participants: [ + { + type: Schema.Types.ObjectId, + ref: "User", + }, + ], + expensePerMember: { + type: Number, + required: true, + }, + expenseMethod: { + type: String, + enum: AvailablePaymentMethods, + default: PaymentMethods.CASH, + }, + billAttachments: { + type: [ + { + url: String, + localPath: String, + }, + ], + default: [], + }, + }, + { timestamps: true } +); + +export const Expense = mongoose.model("Expense", expenseSchema); diff --git a/src/models/apps/expense-split-app/expensegroup.model.js b/src/models/apps/expense-split-app/expensegroup.model.js new file mode 100644 index 00000000..87b3ad80 --- /dev/null +++ b/src/models/apps/expense-split-app/expensegroup.model.js @@ -0,0 +1,55 @@ +import mongoose, { Schema } from "mongoose"; +import { + AvailableExpenseGroupTypes, + ExpenseGroupTypes, +} from "../../../constants.js"; + +const expenseGroupSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + }, + description: { + type: String, + }, + groupOwner: { + type: Schema.Types.ObjectId, + ref: "User", + }, + + participants: [ + { + type: Schema.Types.ObjectId, + ref: "User", + }, + ], + groupCategory: { + type: String, + enum: AvailableExpenseGroupTypes.OTHERS, + default: ExpenseGroupTypes.OTHERS, + required: true, + }, + groupTotal: { type: Number, default: 0 }, + split: { + type: Map, + of: Number, + default: {}, + }, + }, + { timestamps: true } +); + +// Middleware to convert split keys to ObjectId +expenseGroupSchema.pre("save", function (next) { + const split = this.split; + const updatedSplit = new Map(); + + for (const key of split.keys()) { + updatedSplit.set(new mongoose.Types.ObjectId(key), split.get(key)); + } + + this.split = updatedSplit; + next(); +}); +export const ExpenseGroup = mongoose.model("ExpenseGroup", expenseGroupSchema); diff --git a/src/models/apps/expense-split-app/settlement.model.js b/src/models/apps/expense-split-app/settlement.model.js new file mode 100644 index 00000000..b2ac6320 --- /dev/null +++ b/src/models/apps/expense-split-app/settlement.model.js @@ -0,0 +1,32 @@ +import mongoose, { Schema } from "mongoose"; + +const settlementSchema = mongoose.Schema( + { + groupId: { + type: Schema.Types.ObjectId, + ref: "ExpenseGroup", + required: true, + }, + settleTo: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + settleFrom: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + SettlementDate: { + type: String, + required: true, + }, + Amount: { + type: Number, + required: true, + }, + }, + { timestamps: true } +); + +export const Settlement = mongoose.model("Settlement", settlementSchema); diff --git a/src/routes/apps/expense-split-app/expense.routes.js b/src/routes/apps/expense-split-app/expense.routes.js new file mode 100644 index 00000000..33ee3943 --- /dev/null +++ b/src/routes/apps/expense-split-app/expense.routes.js @@ -0,0 +1,122 @@ +import { Router } from "express"; +import { + addExpense, + deleteExpense, + editExpense, + groupCategoryExpense, + groupDailyExpense, + groupMonthlyExpense, + recentUserExpense, + userCategoryExpense, + userDailyExpense, + userMonthlyExpense, + viewExpense, + viewGroupExpense, + viewUserExpense, +} from "../../../controllers/apps/expense-split-app/expense.controller.js"; +import { addAExpenseValidator } from "../../../validators/apps/expense-split-app/expense.validator.js"; +import { validate } from "../../../validators/validate.js"; +import { mongoIdPathVariableValidator } from "../../../validators/common/mongodb.validators.js"; +import { verifyJWT } from "../../../middlewares/auth.middlewares.js"; +import { upload } from "../../../middlewares/multer.middlewares.js"; +const router = Router(); + +//all routes are secured routes + +router.use(verifyJWT); + +//Creates a new expense accepts bill photos also + +//! Validated + +router + .route("/addexpense/:groupId") + .post( + upload.fields([{ name: "billAttachments", maxCount: 5 }]), + addAExpenseValidator(), + mongoIdPathVariableValidator("groupId"), + validate, + addExpense + ); + +router + .route("/:expenseId") + + // gets expense details + + //! Validated + + .get(mongoIdPathVariableValidator("expenseId"), validate, viewExpense) + + //edit expense details not the bills attachments + + //!Validated + + .patch(mongoIdPathVariableValidator("expenseId"), validate, editExpense) + + //Deletes expenses + + //! Validated + + .delete(mongoIdPathVariableValidator("expenseId"), validate, deleteExpense); + +router + .route("/group/:groupId") + + //shows all the expense in a group + + //! validated + + .get(mongoIdPathVariableValidator("groupId"), validate, viewGroupExpense); + +//View all the expense of the user + +//!validated + +router.route("/user/expense").get(viewUserExpense); + +//Gives top 5 recent user expenses + +//!validated + +router.route("/user/recentexpense").get(recentUserExpense); + +//Sorts all the expenses of user month wise and displays recent first + +//!validated + +router.route("/monthlyexpense/user").get(userMonthlyExpense); + +//shows all user expenses category wise + +//!validated + +router.route("/categoryexpense/user").get(userCategoryExpense); + +//Shows the daily expense of user of that day + +//!validated + +router.route("/dailyexpense/user").get(userDailyExpense); + +//Shows all the expense in a group month wise + +//!validated + +router + .route("/monthlyexpense/group/:groupId") + .get(mongoIdPathVariableValidator("groupId"), validate, groupMonthlyExpense); + +//Shows all the expense in a group daily +//!validated +router + .route("/dailyexpense/group/:groupId") + .get(mongoIdPathVariableValidator("groupId"), validate, groupDailyExpense); + +//Shows all the expense in a group category wise +//!validated +router + .route("/categoryexpense/group/:groupId") + .get(mongoIdPathVariableValidator("groupId"), validate, groupCategoryExpense); + +export default router; diff --git a/src/routes/apps/expense-split-app/expensegroup.routes.js b/src/routes/apps/expense-split-app/expensegroup.routes.js new file mode 100644 index 00000000..673c37a1 --- /dev/null +++ b/src/routes/apps/expense-split-app/expensegroup.routes.js @@ -0,0 +1,103 @@ +import { Router } from "express"; +import { createAExpenseGroupValidator } from "../../../validators/apps/expense-split-app/expenseGroup.validator.js"; +import { validate } from "../../../validators/validate.js"; +import { + editExpenseGroup, + createExpenseGroup, + deleteExpenseGroup, + getUserExpenseGroups, + groupBalaceSheet, + makeSettlement, + viewExpenseGroup, + userSettlementRecords, + groupSettlementRecords, + addMembersInExpenseGroup, +} from "../../../controllers/apps/expense-split-app/group.controller.js"; +import { mongoIdPathVariableValidator } from "../../../validators/common/mongodb.validators.js"; +import { verifyJWT } from "../../../middlewares/auth.middlewares.js"; +const router = Router(); + +//All routes are secured routes + +router.use(verifyJWT); + +//Create a new group + +// ! validated + +router + .route("/creategroup") + .post(createAExpenseGroupValidator(), validate, createExpenseGroup); + +router + .route("/:groupId") + + //Get all expenses in a group + + // !validated + + .get(mongoIdPathVariableValidator("groupId"), validate, viewExpenseGroup) + + //Route to edit group name and description only + + // ! validated + + .patch(mongoIdPathVariableValidator("groupId"), validate, editExpenseGroup) + + //Route to delete the whole group + + // ! validated + + .delete( + mongoIdPathVariableValidator("groupId"), + validate, + deleteExpenseGroup + ); + +//Returns a group balance sheet who owes whom how much + +// !validated + +router + .route("/group-settlements/:groupId") + .post(mongoIdPathVariableValidator("groupId"), validate, groupBalaceSheet); + +//Makes settlement of owes in the group and creates a settlement transaction + +// ! aggregation validation left + +router + .route("/makeSettlement/:groupId") + .post(mongoIdPathVariableValidator("groupId"), validate, makeSettlement); + +//Gets all the expense group that user is a part of + +//!validated + +router.route("/").get(getUserExpenseGroups); + +//Get all user settlements + +//! aggregation validation left + +router.route("/settlements/user").get(userSettlementRecords); + +//Get all group settlements + +//! aggregation validation left + +router.route("/settlements/group/:groupId").get(groupSettlementRecords); + +//Responsible for adding members in group + +// ! Validated + +router + .route("/group/:groupId/:userId") + .post( + mongoIdPathVariableValidator("groupId"), + mongoIdPathVariableValidator("userId"), + validate, + addMembersInExpenseGroup + ); +export default router; diff --git a/src/validators/apps/expense-split-app/expense.validator.js b/src/validators/apps/expense-split-app/expense.validator.js new file mode 100644 index 00000000..7c77961e --- /dev/null +++ b/src/validators/apps/expense-split-app/expense.validator.js @@ -0,0 +1,16 @@ +import { body } from "express-validator"; + +const addAExpenseValidator = () => { + return [ + body("name").trim().notEmpty().withMessage("Expense name is required"), + body("Amount").trim().notEmpty().withMessage("Expense Amount is required"), + body("participants") + .isArray({ + min: 2, + max: 100, + }) + .withMessage("Participants must be an array with more than 2 members"), + ]; +}; + +export { addAExpenseValidator }; diff --git a/src/validators/apps/expense-split-app/expenseGroup.validator.js b/src/validators/apps/expense-split-app/expenseGroup.validator.js new file mode 100644 index 00000000..27d70e5d --- /dev/null +++ b/src/validators/apps/expense-split-app/expenseGroup.validator.js @@ -0,0 +1,15 @@ +import { body } from "express-validator"; + +const createAExpenseGroupValidator = () => { + return [ + body("name").trim().notEmpty().withMessage("Group name is required"), + body("participants") + .isArray({ + min: 2, + max: 100, + }) + .withMessage("Participants must be an array with more than 2 members"), + ]; +}; + +export { createAExpenseGroupValidator }; diff --git a/yarn.lock b/yarn.lock index 58eb5387..f40c7be5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3106,14 +3106,15 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" -formidable@^3.2.4: - version "3.5.1" - resolved "https://registry.npmjs.org/formidable/-/formidable-3.5.1.tgz#9360a23a656f261207868b1484624c4c8d06ee1a" - integrity sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og== +formidable@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.1.2.tgz#fa973a2bec150e4ce7cac15589d7a25fc30ebd89" + integrity sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g== dependencies: dezalgo "^1.0.4" hexoid "^1.0.0" once "^1.4.0" + qs "^6.11.0" forwarded@0.2.0: version "0.2.0"