diff --git a/src/app.js b/src/app.js index 25215f3a..69b0c847 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..9b7ac3dd 100644 --- a/src/constants.js +++ b/src/constants.js @@ -103,3 +103,43 @@ export const ChatEventEnum = Object.freeze({ }); export const AvailableChatEvents = Object.values(ChatEventEnum); + +/** + * @type {{FOOD_AND_DRINK: "Food & drink",SHOPPING: "shopping",ENTERTAINMENT: "entertainment",HOME: "Home",TRANSPORTATION: "Transportation",OTHERS: "Others", } as const} + */ +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 {{HOME: "Home", TRIP: "Trip",OFFICE: "Office",SPORTS: "Sports",OTHERS: "Others",} as const} + */ + +export const ExpenseGroupTypes = { + HOME: "Home", + TRIP: "Trip", + OFFICE: "Office", + SPORTS: "Sports", + OTHERS: "Others", +}; + +export const AvailableExpenseGroupTypes = Object.values(ExpenseGroupTypes); + +/** + * @type {{CASH: "Cash", UPI: "Upi",CARD: "Card"} as const} + */ + +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..06508d64 --- /dev/null +++ b/src/controllers/apps/expense-split-app/expense.controller.js @@ -0,0 +1,932 @@ +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"); + } + + if (!group.participants.includes(req.user._id.toString())) { + throw new ApiError( + 403, + "You have to be a part of this group to add any expense" + ); + } + + //Owner has to be participant of the group to add expense in the group + if (!group.participants.includes(owner.toString())) { + 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.toString())) { + 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.toString())) { + 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 < 1) { + 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; + + 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 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.toString())) { + 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.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 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), + }, + }, + { + $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), + }, + }, + { + $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..563c5856 --- /dev/null +++ b/src/controllers/apps/expense-split-app/group.controller.js @@ -0,0 +1,742 @@ +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 checked in validator no need to check here + const members = [...new Set([...participants, req.user._id.toString()])]; //Prevents duplications + + async function isValidUser(members) { + for (const user of members) { + const foundUser = await User.findById(user); + if (!foundUser) { + return false; + } + } + return true; + } + //Checking if all the users exists or not + const isValid = await isValidUser(members); + if (!isValid) { + throw new ApiError( + 400, + "Invalid participant Id, Participant does not exist" + ); + } + + 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.toString())) { + throw new ApiError(403, "You are not participant of this group"); + } + + const balanceData = groupBalanceCalculator(expenseGroup.split); + + const agrregatedData = balanceData.map(async (data) => { + let array = []; + 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") + ); + + //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.toString())) { + 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(settleFrom) + settleAmount + ); + updatedSplit.set( + String(settleTo), + Number(updatedSplit.get(settleTo)) - 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(), + ]); + + const payload = aggregatedSettlement[0]; + + res + .status(200) + .json(new ApiResponse(200, { payload }, "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); //deleting expenses and settlement + 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.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 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, + }, + }, + ...commonSettlementAggregations(), + ]); + 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 }], + }); + + if (settlements.length < 1) { + return res + .status(200) + .json(new ApiResponse(200, {}, "No user settlement records found")); + } + + const aggregatedSettlements = settlements.map(async (settlement) => { + const pipelineData = await Settlement.aggregate([ + { + $match: { + _id: settlement._id, + }, + }, + ...commonSettlementAggregations(), + ]); + 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..862e5a45 --- /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 = new 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..389e0cb6 --- /dev/null +++ b/src/models/apps/expense-split-app/expensegroup.model.js @@ -0,0 +1,54 @@ +import mongoose, { Schema } from "mongoose"; +import { + AvailableExpenseGroupTypes, + ExpenseGroupTypes, +} from "../../../constants.js"; + +const expenseGroupSchema = new 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, + 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..cbd1064f --- /dev/null +++ b/src/models/apps/expense-split-app/settlement.model.js @@ -0,0 +1,33 @@ +import mongoose, { Schema } from "mongoose"; + +const settlementSchema = new 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: Date, + required: true, + default: Date.now, + }, + 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..6d9390ba --- /dev/null +++ b/src/routes/apps/expense-split-app/expense.routes.js @@ -0,0 +1,72 @@ +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 { addAnExpenseValidator } 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(); + +router.use(verifyJWT); + +router + .route("/addexpense/:groupId") + .post( + upload.fields([{ name: "billAttachments", maxCount: 5 }]), + addAnExpenseValidator(), + mongoIdPathVariableValidator("groupId"), + validate, + addExpense + ); + +router + .route("/:expenseId") + + .get(mongoIdPathVariableValidator("expenseId"), validate, viewExpense) + + .patch(mongoIdPathVariableValidator("expenseId"), validate, editExpense) + + .delete(mongoIdPathVariableValidator("expenseId"), validate, deleteExpense); + +router + .route("/group/:groupId") + + .get(mongoIdPathVariableValidator("groupId"), validate, viewGroupExpense); + +router.route("/user/expense").get(viewUserExpense); + +router.route("/user/recentexpense").get(recentUserExpense); + +router.route("/monthlyexpense/user").get(userMonthlyExpense); + +router.route("/categoryexpense/user").get(userCategoryExpense); + +router.route("/dailyexpense/user").get(userDailyExpense); + +router + .route("/monthlyexpense/group/:groupId") + .get(mongoIdPathVariableValidator("groupId"), validate, groupMonthlyExpense); + +router + .route("/dailyexpense/group/:groupId") + .get(mongoIdPathVariableValidator("groupId"), validate, groupDailyExpense); + +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..30c00aa1 --- /dev/null +++ b/src/routes/apps/expense-split-app/expensegroup.routes.js @@ -0,0 +1,61 @@ +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(); + +router.use(verifyJWT); +router.route("/availableUsers").get(searchAvailableUsers); +router + .route("/creategroup") + .post(createAExpenseGroupValidator(), validate, createExpenseGroup); + +router + .route("/:groupId") + + .get(mongoIdPathVariableValidator("groupId"), validate, viewExpenseGroup) + + .patch(mongoIdPathVariableValidator("groupId"), validate, editExpenseGroup) + + .delete( + mongoIdPathVariableValidator("groupId"), + validate, + deleteExpenseGroup + ); + +router + .route("/group-settlements/:groupId") + .post(mongoIdPathVariableValidator("groupId"), validate, groupBalaceSheet); + +router + .route("/makesettlement/:groupId") + .post(mongoIdPathVariableValidator("groupId"), validate, makeSettlement); + +router.route("/").get(getUserExpenseGroups); + +router.route("/settlements/user").get(userSettlementRecords); + +router.route("/settlements/group/:groupId").get(groupSettlementRecords); + +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..881b34f2 --- /dev/null +++ b/src/validators/apps/expense-split-app/expense.validator.js @@ -0,0 +1,16 @@ +import { body } from "express-validator"; + +const addAnExpenseValidator = () => { + 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 { addAnExpenseValidator }; 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..c22c910e --- /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: 1, + max: 100, + }) + .withMessage("Participants must be an array with minimum 1 member"), + ]; +}; + +export { createAExpenseGroupValidator }; diff --git a/yarn.lock b/yarn.lock index 58eb5387..99002d22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2726,10 +2726,10 @@ ee-first@1.1.1: resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -ejs@^3.1.6: - version "3.1.9" - resolved "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz" - integrity sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ== +ejs@^3.1.10, ejs@^3.1.6: + version "3.1.10" + resolved "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" + integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== dependencies: jake "^10.8.5" @@ -3106,7 +3106,7 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" -formidable@^3.2.4: +formidable@^2.1.2, formidable@^3.5.1: 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== @@ -6412,4 +6412,4 @@ yn@3.1.1: yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== \ No newline at end of file