From 1f0d613b1d5eae1a839555d1b1ffac0fb94deecb Mon Sep 17 00:00:00 2001 From: Lan Sovinc Date: Fri, 6 Mar 2026 15:19:06 +0100 Subject: [PATCH 1/2] implement investments --- __tests__/expenses.js | 4 +- __tests__/incomes.js | 4 +- src/controllers/accounts.js | 10 +- src/controllers/investments.js | 623 ++++++++++++++++++++++++++ src/middlewares/authJWT.js | 103 ++++- src/models/db.js | 1 + src/models/investments.js | 82 ++++ src/models/users.js | 2 +- src/routes/investments.js | 59 +++ src/server.js | 2 + src/services/deleteUserEntries.js | 18 +- src/services/draftsAccount.js | 1 - src/services/updateAccountBalances.js | 36 +- test_entries.js | 14 +- 14 files changed, 935 insertions(+), 24 deletions(-) create mode 100644 src/controllers/investments.js create mode 100644 src/models/investments.js create mode 100644 src/routes/investments.js diff --git a/__tests__/expenses.js b/__tests__/expenses.js index 11a6b44..130bb28 100644 --- a/__tests__/expenses.js +++ b/__tests__/expenses.js @@ -310,9 +310,9 @@ describe('PUT /api/expenses/:id', () => { expect(response3.statusCode).toBe(200); expect(response2.body).toHaveProperty('availableBalance', 0); - expect(response2.body).toHaveProperty('totalBalance', 0); + expect(response2.body).toHaveProperty('investedAmount', 0); expect(response3.body).toHaveProperty('availableBalance', -testExpenseData.amount); - expect(response3.body).toHaveProperty('totalBalance', -testExpenseData.amount); + expect(response3.body).toHaveProperty('investedAmount', 0); }); diff --git a/__tests__/incomes.js b/__tests__/incomes.js index a2f890c..a75177d 100644 --- a/__tests__/incomes.js +++ b/__tests__/incomes.js @@ -309,9 +309,9 @@ describe('PUT /api/incomes/:id', () => { expect(response3.statusCode).toBe(200); expect(response2.body).toHaveProperty('availableBalance', 0); - expect(response2.body).toHaveProperty('totalBalance', 0); + expect(response2.body).toHaveProperty('investedAmount', 0); expect(response3.body).toHaveProperty('availableBalance', testIncomeData.amount); - expect(response3.body).toHaveProperty('totalBalance', testIncomeData.amount); + expect(response3.body).toHaveProperty('investedAmount', 0); }); diff --git a/src/controllers/accounts.js b/src/controllers/accounts.js index d5aecec..6fdeccc 100644 --- a/src/controllers/accounts.js +++ b/src/controllers/accounts.js @@ -10,7 +10,7 @@ const findAllAccountsByUserID = async (req, res) => { const userId = req.params.uid; const archived = !!req.query.archived; - const user = await User.findById(userId, 'accounts._id accounts.name accounts.totalBalance accounts.availableBalance accounts.startOfMonth accounts.archived'); + const user = await User.findById(userId, 'accounts._id accounts.name accounts.investedAmount accounts.availableBalance accounts.startOfMonth accounts.archived'); if (!user) { return res.status(404).json({ @@ -67,7 +67,6 @@ const createAccount = async (req, res) => { let newAccount = { name: req.body.name || "New Account", - totalBalance: 0, availableBalance: 0, budgets: [], goals: [], @@ -80,7 +79,7 @@ const createAccount = async (req, res) => { // Finds user and appends newAccount to the accounts array, then returns the new account const user = await User.findByIdAndUpdate(req.params.uid, {$push: {accounts: newAccount}}, { new: true, - select: 'accounts._id accounts.name accounts.totalBalance accounts.availableBalance accounts.startOfMonth' + select: 'accounts._id accounts.name accounts.investedAmount accounts.availableBalance accounts.startOfMonth' }); if (!user) { @@ -126,6 +125,9 @@ const deleteAccountByID = async (req, res) => { // Delete account's expenses from db await deleteUserEntries.deleteExpenses("accountID", req.params.id); + // Delete account's investments from db + await deleteUserEntries.deleteInvestments("accountID", req.params.id); + res.status(200).json(user); } catch (error) { @@ -149,7 +151,7 @@ const updateAccountByID = async (req, res) => { try { // Find the user with the requested account - const user = await User.findOne({'accounts._id': req.params.id}, 'accounts._id accounts.name accounts.startOfMonth accounts.totalBalance accounts.availableBalance'); + const user = await User.findOne({'accounts._id': req.params.id}, 'accounts._id accounts.name accounts.startOfMonth accounts.investedAmount accounts.availableBalance'); if (!user) { return res.status(404).json({ diff --git a/src/controllers/investments.js b/src/controllers/investments.js new file mode 100644 index 0000000..2d1547c --- /dev/null +++ b/src/controllers/investments.js @@ -0,0 +1,623 @@ +import mongoose from 'mongoose'; +import updateAccountBalances from '../services/updateAccountBalances.js'; + +const InvestmentPosition = mongoose.model('InvestmentPosition'); +const InvestmentEvent = mongoose.model('InvestmentEvent'); + +// Find all investment events for an account — paginated +const findAllEventsByAccountID = async (req, res) => { + try { + const page = req.query.page || 0; + const eventsPerPage = 16; + + const events = await InvestmentEvent + .find({ accountID: req.params.aid }) + .sort({ date: -1, _id: -1 }) + .skip(eventsPerPage * page) + .limit(eventsPerPage); + + res.status(200).json(events); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while fetching investment events!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Find all open positions for an account +const findPositionsByAccountID = async (req, res) => { + try { + const includeClosed = !!req.query.closed; + + const filter = { accountID: req.params.aid }; + if (!includeClosed) { + filter.closed = false; + } + + const positions = await InvestmentPosition + .find(filter) + .sort({ createdAt: -1 }); + + res.status(200).json(positions); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while fetching investment positions!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Find a single investment event by ID +const findEventByID = async (req, res) => { + try { + const event = await InvestmentEvent.findById(req.params.id); + + if (!event) { + return res.status(404).json({ + message: "No investment event with selected ID!" + }); + } + + res.status(200).json(event); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while fetching the investment event!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Find all events for a specific position — paginated +const findEventsByPositionID = async (req, res) => { + try { + const page = req.query.page || 0; + const eventsPerPage = 16; + + const events = await InvestmentEvent + .find({ positionID: req.params.pid }) + .sort({ date: -1, _id: -1 }) + .skip(eventsPerPage * page) + .limit(eventsPerPage); + + res.status(200).json(events); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while fetching position events!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Find a single position by ID +const findPositionByID = async (req, res) => { + try { + const position = await InvestmentPosition.findById(req.params.id); + + if (!position) { + return res.status(404).json({ + message: "No investment position with selected ID!" + }); + } + + res.status(200).json(position); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while fetching the investment position!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Create an investment event (BUY, SELL, YIELD) +const create = async (req, res) => { + + const { userID, accountID, positionID, action, assetType, assetName, symbol, description, date, units, unitPriceAmount, feeAmount } = req.body; + + if (!["BUY", "SELL", "YIELD"].includes(action)) { + return res.status(400).json({ message: "Invalid action." }); + } + + if (description?.length > 32) { + return res.status(400).json({ message: "Description too long." }); + } + + // SELL and YIELD always require an existing position + if ((action === "SELL" || action === "YIELD") && !positionID) { + return res.status(400).json({ message: "positionID is required for SELL and YIELD." }); + } + + try { + if (action === "BUY") { + await handleBuy({ userID, accountID, positionID, assetType, assetName, symbol, description, date, units, unitPriceAmount, feeAmount }, res); + } else if (action === "SELL") { + await handleSell({ userID, accountID, positionID, description, date, units, unitPriceAmount, feeAmount }, res); + } else if (action === "YIELD") { + await handleYield({ userID, accountID, positionID, description, date, unitPriceAmount, feeAmount }, res); + } + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while creating investment event!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +const handleBuy = async (params, res) => { + const { userID, accountID, positionID, assetType, assetName, symbol, description, date, units, unitPriceAmount, feeAmount } = params; + + if (!units || units <= 0) { + return res.status(400).json({ message: "Units must be greater than 0." }); + } + if (!unitPriceAmount || !Number.isSafeInteger(unitPriceAmount) || unitPriceAmount <= 0) { + return res.status(400).json({ message: "Unit price not a valid number." }); + } + + const cost = Math.ceil(units * unitPriceAmount); + const fee = Math.ceil(feeAmount || 0); + + let position; + + if (positionID) { + // Add to existing position (DCA) + position = await InvestmentPosition.findById(positionID); + + if (!position) { + return res.status(404).json({ message: "No position with selected ID!" }); + } + if (position.closed) { + return res.status(400).json({ message: "Cannot buy into a closed position." }); + } + if (position.accountID !== accountID) { + return res.status(400).json({ message: "Position does not belong to this account." }); + } + + position.quantity += units; + position.totalCostAmount += cost; + position.totalFeesAmount += fee; + position.currentUnitPriceAmount = unitPriceAmount; + await position.save(); + } else { + // Create new position + if (!assetType || !assetName) { + return res.status(400).json({ message: "assetType and assetName are required for new positions." }); + } + + position = await new InvestmentPosition({ + userID, + accountID, + assetType, + assetName, + symbol: symbol || "", + quantity: units, + totalCostAmount: cost, + totalFeesAmount: fee, + currentUnitPriceAmount: unitPriceAmount + }).save(); + } + + // Create event + const event = await new InvestmentEvent({ + userID, + accountID, + positionID: position._id.toString(), + action: "BUY", + date, + description, + units, + unitPriceAmount, + feeAmount: fee, + realizedPnLAmount: 0 + }).save(); + + // Subtract cost + fee from available balance + await updateAccountBalances.updateAllAccountBalances(accountID, cost + fee, "-"); + await updateAccountBalances.recalcInvestedAmount(accountID); + + res.status(200).json(event); +}; +//---------------------------------------------------------------------------------------------------------------------- + + +const handleSell = async (params, res) => { + const { userID, accountID, positionID, description, date, units, unitPriceAmount, feeAmount } = params; + + if (!units || units <= 0) { + return res.status(400).json({ message: "Units must be greater than 0." }); + } + if (!unitPriceAmount || !Number.isSafeInteger(unitPriceAmount) || unitPriceAmount <= 0) { + return res.status(400).json({ message: "Unit price not a valid number." }); + } + + const position = await InvestmentPosition.findById(positionID); + + if (!position) { + return res.status(404).json({ message: "No position with selected ID!" }); + } + if (position.closed) { + return res.status(400).json({ message: "Cannot sell from a closed position." }); + } + if (position.accountID !== accountID) { + return res.status(400).json({ message: "Position does not belong to this account." }); + } + if (units > position.quantity) { + return res.status(400).json({ message: "Cannot sell more units than held." }); + } + + const fee = Math.ceil(feeAmount || 0); + const grossProceeds = Math.ceil(units * unitPriceAmount); + const netProceeds = grossProceeds - fee; + + // Weighted-average cost basis for sold units + const costBasisSold = Math.ceil((position.totalCostAmount / position.quantity) * units); + const realizedPnL = netProceeds - costBasisSold; + + // Update position + position.quantity -= units; + position.totalCostAmount -= costBasisSold; + position.totalFeesAmount += fee; + position.realizedPnLAmount += realizedPnL; + position.currentUnitPriceAmount = unitPriceAmount; + + if (position.quantity <= 0) { + position.quantity = 0; + position.totalCostAmount = 0; + position.closed = true; + position.closedAt = new Date(); + } + + await position.save(); + + // Create event + const event = await new InvestmentEvent({ + userID, + accountID, + positionID: position._id.toString(), + action: "SELL", + date, + description, + units, + unitPriceAmount, + feeAmount: fee, + realizedPnLAmount: realizedPnL + }).save(); + + // Add net proceeds to available balance + await updateAccountBalances.updateAllAccountBalances(accountID, netProceeds, "+"); + await updateAccountBalances.recalcInvestedAmount(accountID); + + res.status(200).json(event); +}; +//---------------------------------------------------------------------------------------------------------------------- + + +const handleYield = async (params, res) => { + const { userID, accountID, positionID, description, date, unitPriceAmount, feeAmount } = params; + + if (!unitPriceAmount || !Number.isSafeInteger(unitPriceAmount) || unitPriceAmount <= 0) { + return res.status(400).json({ message: "Amount not a valid number." }); + } + + const position = await InvestmentPosition.findById(positionID); + + if (!position) { + return res.status(404).json({ message: "No position with selected ID!" }); + } + if (position.closed) { + return res.status(400).json({ message: "Cannot add yield to a closed position." }); + } + if (position.accountID !== accountID) { + return res.status(400).json({ message: "Position does not belong to this account." }); + } + + const fee = Math.ceil(feeAmount || 0); + const netYield = unitPriceAmount - fee; + + position.totalFeesAmount += fee; + position.totalYieldAmount += unitPriceAmount; + await position.save(); + + // Create event + const event = await new InvestmentEvent({ + userID, + accountID, + positionID: position._id.toString(), + action: "YIELD", + date, + description, + unitPriceAmount, + feeAmount: fee, + realizedPnLAmount: 0 + }).save(); + + // Add net yield to available balance + await updateAccountBalances.updateAllAccountBalances(accountID, netYield, "+"); + + res.status(200).json(event); +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Update current unit price on a position (price update) +const updatePositionPrice = async (req, res) => { + + const { currentUnitPriceAmount } = req.body; + + if (!currentUnitPriceAmount || !Number.isSafeInteger(currentUnitPriceAmount) || currentUnitPriceAmount <= 0) { + return res.status(400).json({ message: "Price not a valid number." }); + } + + try { + const position = await InvestmentPosition.findById(req.params.id); + + if (!position) { + return res.status(404).json({ message: "No investment position with selected ID!" }); + } + + if (position.closed) { + return res.status(400).json({ message: "Cannot update price on a closed position." }); + } + + position.currentUnitPriceAmount = currentUnitPriceAmount; + await position.save(); + + await updateAccountBalances.recalcInvestedAmount(position.accountID); + + res.status(200).json(position); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while updating position price!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Update position display info (assetName, symbol, assetType) +const updatePositionInfo = async (req, res) => { + + const { assetName, symbol, assetType } = req.body; + + if (assetName?.length > 64) { + return res.status(400).json({ message: "Asset name too long." }); + } + if (symbol?.length > 24) { + return res.status(400).json({ message: "Symbol too long." }); + } + + try { + const position = await InvestmentPosition.findById(req.params.id); + + if (!position) { + return res.status(404).json({ message: "No investment position with selected ID!" }); + } + + if (assetName) position.assetName = assetName; + if (symbol !== undefined) position.symbol = symbol; + if (assetType) position.assetType = assetType; + + await position.save(); + + res.status(200).json(position); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while updating position info!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Update event display info (description, date) +const updateEventInfo = async (req, res) => { + + const { description, date } = req.body; + + if (description?.length > 32) { + return res.status(400).json({ message: "Description too long." }); + } + + try { + const event = await InvestmentEvent.findById(req.params.id); + + if (!event) { + return res.status(404).json({ message: "No investment event with selected ID!" }); + } + + if (description !== undefined) event.description = description; + if (date) event.date = date; + + await event.save(); + + res.status(200).json(event); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while updating investment event!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Delete the most recent event for a position (undo last action) +const removeLatestEvent = async (req, res) => { + try { + const event = await InvestmentEvent.findById(req.params.id); + + if (!event) { + return res.status(404).json({ message: "No investment event with selected ID!" }); + } + + // Verify it is the latest event by creation order (not date) + const latestEvent = await InvestmentEvent + .findOne({ positionID: event.positionID }) + .sort({ _id: -1 }); + + if (latestEvent._id.toString() !== event._id.toString()) { + return res.status(400).json({ message: "Can only delete the latest event for a position." }); + } + + const position = await InvestmentPosition.findById(event.positionID); + + if (!position) { + return res.status(404).json({ message: "No position found for this event." }); + } + + // Reverse the event's effect on the position + position.totalFeesAmount -= event.feeAmount; + + if (event.action === "BUY") { + position.quantity -= event.units; + position.totalCostAmount -= Math.ceil(event.units * event.unitPriceAmount); + + // Refund cost + fee to available balance + const cost = Math.ceil(event.units * event.unitPriceAmount) + event.feeAmount; + await updateAccountBalances.updateAllAccountBalances(event.accountID, cost, "+"); + + } else if (event.action === "SELL") { + // Derive the exact costBasisSold from stored event data + const grossProceeds = Math.ceil(event.units * event.unitPriceAmount); + const costBasisSold = grossProceeds - event.feeAmount - event.realizedPnLAmount; + + position.quantity += event.units; + position.totalCostAmount += costBasisSold; + position.realizedPnLAmount -= event.realizedPnLAmount; + position.closed = false; + position.closedAt = null; + + // Remove net proceeds from available balance + const netProceeds = grossProceeds - event.feeAmount; + await updateAccountBalances.updateAllAccountBalances(event.accountID, netProceeds, "-"); + + } else if (event.action === "YIELD") { + position.totalYieldAmount -= event.unitPriceAmount; + + // Remove net yield from available balance + const netYield = event.unitPriceAmount - event.feeAmount; + await updateAccountBalances.updateAllAccountBalances(event.accountID, netYield, "-"); + } + + // Delete the event first, then decide if position should be removed + await InvestmentEvent.findByIdAndDelete(event._id); + + // Only delete position if no events remain for it + const remainingEvents = await InvestmentEvent.countDocuments({ positionID: position._id.toString() }); + if (remainingEvents === 0) { + await InvestmentPosition.findByIdAndDelete(position._id); + } else { + await position.save(); + } + + await updateAccountBalances.recalcInvestedAmount(event.accountID); + + res.send({ message: "Investment event deleted!" }); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while deleting investment event!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Delete an entire position and all its events, reversing all balance effects +const removePosition = async (req, res) => { + try { + const position = await InvestmentPosition.findById(req.params.id); + + if (!position) { + return res.status(404).json({ message: "No investment position with selected ID!" }); + } + + const events = await InvestmentEvent.find({ positionID: position._id.toString() }); + + // Sum up the net cash effect of all events to reverse it + let netCashEffect = 0; + for (const event of events) { + if (event.action === "BUY") { + netCashEffect -= Math.ceil(event.units * event.unitPriceAmount) + event.feeAmount; + } else if (event.action === "SELL") { + netCashEffect += Math.ceil(event.units * event.unitPriceAmount) - event.feeAmount; + } else if (event.action === "YIELD") { + netCashEffect += event.unitPriceAmount - event.feeAmount; + } + } + + // Reverse the net effect on available balance + if (netCashEffect > 0) { + await updateAccountBalances.updateAllAccountBalances(position.accountID, netCashEffect, "-"); + } else if (netCashEffect < 0) { + await updateAccountBalances.updateAllAccountBalances(position.accountID, Math.abs(netCashEffect), "+"); + } + + await InvestmentEvent.deleteMany({ positionID: position._id.toString() }); + await InvestmentPosition.findByIdAndDelete(position._id); + await updateAccountBalances.recalcInvestedAmount(position.accountID); + + res.send({ message: "Investment position deleted!" }); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while deleting investment position!" + }); + } +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Portfolio breakdown by asset type for an account +const positionsBreakdown = async (req, res) => { + try { + const pipeline = [ + { $match: { accountID: req.params.aid, closed: false } }, + { + $group: { + _id: "$assetType", + totalMarketValue: { $sum: { $multiply: ["$quantity", "$currentUnitPriceAmount"] } }, + totalCost: { $sum: "$totalCostAmount" }, + count: { $sum: 1 } + } + }, + { $sort: { totalMarketValue: -1 } } + ]; + + const breakdown = await InvestmentPosition.aggregate(pipeline); + + res.status(200).json(breakdown); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while fetching portfolio breakdown!" + }); + } +}; + +export default { + findAllEventsByAccountID, + findEventsByPositionID, + findPositionsByAccountID, + findEventByID, + findPositionByID, + create, + updatePositionPrice, + updatePositionInfo, + updateEventInfo, + removeLatestEvent, + removePosition, + positionsBreakdown +} \ No newline at end of file diff --git a/src/middlewares/authJWT.js b/src/middlewares/authJWT.js index 1999731..98da423 100644 --- a/src/middlewares/authJWT.js +++ b/src/middlewares/authJWT.js @@ -6,6 +6,8 @@ const User = mongoose.model('User'); const Expense = mongoose.model('Expense'); const Income = mongoose.model('Income'); const Token = mongoose.model('Token'); +const InvestmentEvent = mongoose.model('InvestmentEvent'); +const InvestmentPosition = mongoose.model('InvestmentPosition'); // Verify token in whitelist @@ -247,6 +249,102 @@ const verifyTokenGoal = (req, res, next) => { //---------------------------------------------------------------------------------------------------------------------- +// Verify Investment Event +const verifyTokenInvestmentEvent = (req, res, next) => { + let token = req.headers["x-access-token"]; + + if (!token) { + return res.status(403).send({ message: "No token provided!" }); + } + + jwt.verify(token, config.secret, async (err, decoded) => { + if (err) { + return res.status(401).send({ message: "Unauthorized!" }); + } + + try { + const event = await InvestmentEvent.findById(req.params.id, 'userID'); + + if (!event) { + return res.status(404).json({ message: "No investment event with selected ID!" }); + } + + if (event.userID !== decoded.id) { + return res.status(401).send({ message: "Unauthorized!" }); + } + + next(); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while fetching investment event!" + }); + } + }); +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Verify Investment Position +const verifyTokenInvestmentPosition = (req, res, next) => { + let token = req.headers["x-access-token"]; + + if (!token) { + return res.status(403).send({ message: "No token provided!" }); + } + + jwt.verify(token, config.secret, async (err, decoded) => { + if (err) { + return res.status(401).send({ message: "Unauthorized!" }); + } + + try { + const position = await InvestmentPosition.findById(req.params.id || req.params.pid, 'userID'); + + if (!position) { + return res.status(404).json({ message: "No investment position with selected ID!" }); + } + + if (position.userID !== decoded.id) { + return res.status(401).send({ message: "Unauthorized!" }); + } + + next(); + + } catch (error) { + res.status(500).send({ + message: error.message || "An error occurred while fetching investment position!" + }); + } + }); +}; +//---------------------------------------------------------------------------------------------------------------------- + + +// Verify Investment Post (same pattern as verifyTokenExpenseIncomePost) +const verifyTokenInvestmentPost = (req, res, next) => { + + let token = req.headers["x-access-token"]; + + if (!token) { + return res.status(403).send({ message: "No token provided!" }); + } + + jwt.verify(token, config.secret, (err, decoded) => { + if (err) { + return res.status(401).send({ message: "Unauthorized!" }); + } + + if (req.body.userID !== decoded.id) + return res.status(401).send({ message: "Unauthorized!" }); + + verifyUsersCall(req, res, next, [{ 'accounts._id': req.body.accountID }], decoded.id) + + }); +}; +//---------------------------------------------------------------------------------------------------------------------- + + /** * * @param req - request (passthrough) @@ -291,5 +389,8 @@ export default { verifyTokenExpense, verifyTokenIncome, verifyTokenExpenseIncomePost, - verifyTokenGoal + verifyTokenGoal, + verifyTokenInvestmentEvent, + verifyTokenInvestmentPosition, + verifyTokenInvestmentPost }; diff --git a/src/models/db.js b/src/models/db.js index d76a026..1c59958 100644 --- a/src/models/db.js +++ b/src/models/db.js @@ -3,6 +3,7 @@ import './tokens.js'; import './users.js'; import './incomes.js'; import './expenses.js'; +import './investments.js'; // When testing connect to test database, otherwise connect to the main database const dbURI = process.env.NODE_ENV === 'test' diff --git a/src/models/investments.js b/src/models/investments.js new file mode 100644 index 0000000..3be0bd3 --- /dev/null +++ b/src/models/investments.js @@ -0,0 +1,82 @@ +import mongoose from 'mongoose'; + +const Schema = mongoose.Schema; + +export const investmentAssetTypes = [ + { value: "ETF", label: "ETF" }, + { value: "STOCK", label: "Stock" }, + { value: "BOND", label: "Bond" }, + { value: "MUTUAL_FUND", label: "Mutual Fund" }, + { value: "REAL_ESTATE", label: "Real Estate" }, + { value: "CRYPTO", label: "Crypto" }, + { value: "COMMODITY", label: "Commodity" }, + { value: "FIXED_TERM_DEPOSIT", label: "Fixed-Term Deposit" }, + { value: "SAVINGS_ACCOUNT", label: "Savings Account" } +]; + +const assetTypeValues = investmentAssetTypes.map(t => t.value); + +export const investmentActions = [ + { value: "BUY", label: "Buy" }, + { value: "SELL", label: "Sell" }, + { value: "YIELD", label: "Yield" } +]; + +const actionValues = investmentActions.map(a => a.value); + +function round(value) { + return Math.ceil(value); +} + +function roundQuantity(value) { + return Math.round((Number(value) + Number.EPSILON) * 1e8) / 1e8; +} + +const investmentPosition = new Schema( + { + userID: { type: String, required: true }, + accountID: { type: String, required: true }, + + assetType: { type: String, enum: assetTypeValues, required: true }, + assetName: { type: String, maxlength: 64, required: true }, + symbol: { type: String, maxlength: 24, default: "" }, + + quantity: { type: Number, min: 0, set: roundQuantity, required: true }, + totalCostAmount: { type: Number, min: 0, set: round, required: true }, + currentUnitPriceAmount: { type: Number, min: 0, set: round, required: true }, + + totalFeesAmount: { type: Number, min: 0, set: round, default: 0 }, + totalYieldAmount: { type: Number, min: 0, set: round, default: 0 }, + realizedPnLAmount: { type: Number, set: round, default: 0 }, + closed: { type: Boolean, default: false }, + openedAt: { type: Date, default: Date.now }, + closedAt: { type: Date, default: null } + }, + { timestamps: true } +); + +investmentPosition.index({ accountID: 1, closed: 1 }); + +const investmentEvent = new Schema( + { + userID: { type: String, required: true }, + accountID: { type: String, required: true }, + positionID: { type: String, required: true }, + + action: { type: String, enum: actionValues, required: true }, + date: { type: Date, default: Date.now, required: true }, + description: { type: String, maxlength: 32, default: "" }, + + units: { type: Number, min: 0, set: roundQuantity }, + unitPriceAmount: { type: Number, min: 0, set: round }, + feeAmount: { type: Number, min: 0, set: round, default: 0 }, + realizedPnLAmount: { type: Number, set: round, default: 0 } + }, + { timestamps: true } +); + +investmentEvent.index({ accountID: 1, date: -1, _id: -1 }); +investmentEvent.index({ positionID: 1, date: 1, _id: 1 }); + +mongoose.model('InvestmentPosition', investmentPosition); +mongoose.model('InvestmentEvent', investmentEvent); diff --git a/src/models/users.js b/src/models/users.js index 8c9a97d..5afcd4b 100644 --- a/src/models/users.js +++ b/src/models/users.js @@ -33,7 +33,7 @@ const goal = new Schema({ const account = new Schema({ name: {type: String, maxlength: 32, required: true}, availableBalance: {type: Number, set: round, required: true}, - totalBalance: {type: Number, set: round, required: true}, + investedAmount: {type: Number, set: round, default: 0}, budgets: [budget], goals: [goal], startOfMonth: {type: Number, min: 1, max: 31, set:round, required: true}, diff --git a/src/routes/investments.js b/src/routes/investments.js new file mode 100644 index 0000000..63f5bb6 --- /dev/null +++ b/src/routes/investments.js @@ -0,0 +1,59 @@ +import { Router } from "express"; +import authJWT from "../middlewares/authJWT.js"; +import investmentsController from "../controllers/investments.js"; +import { investmentAssetTypes, investmentActions } from "../models/investments.js"; + +const router = Router(); + +router.use(function (req, res, next) { + res.header( + "Access-Control-Allow-Headers", + "x-access-token, Origin, Content-Type, Accept" + ); + next(); +}); + +// Return list of all available investment asset types and actions +router.get("/types", [authJWT.verifyTokenWhitelist], (req, res) => { + res.status(200).json({ assetTypes: investmentAssetTypes, actions: investmentActions }) +}); + +// Return all investment events for an account — paginated +router.get("/find/:aid", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenAccount], investmentsController.findAllEventsByAccountID); + +// Return all positions for an account +router.get("/positions/:aid", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenAccount], investmentsController.findPositionsByAccountID); + +// Return portfolio breakdown by asset type for an account +router.get("/breakdown/:aid", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenAccount], investmentsController.positionsBreakdown); + +// Return all events for a specific position — paginated +router.get("/events/:pid", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenInvestmentPosition], investmentsController.findEventsByPositionID); + +// Return a single position by ID +router.get("/position/:id", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenInvestmentPosition], investmentsController.findPositionByID); + +// Return a single investment event by ID +router.get("/:id", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenInvestmentEvent], investmentsController.findEventByID); + +// Create investment event (BUY, SELL, YIELD) +router.post("/", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenInvestmentPost], investmentsController.create); + +// Update position price +router.put("/position/price/:id", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenInvestmentPosition], investmentsController.updatePositionPrice); + +// Update position display info (assetName, symbol, assetType) +router.put("/position/:id", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenInvestmentPosition], investmentsController.updatePositionInfo); + +// Update event display info (description, date) +router.put("/:id", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenInvestmentEvent], investmentsController.updateEventInfo); + +// Delete an entire position and all its events +router.delete("/position/:id", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenInvestmentPosition], investmentsController.removePosition); + +// Delete latest investment event (undo) +router.delete("/:id", [authJWT.verifyTokenWhitelist, authJWT.verifyTokenInvestmentEvent], investmentsController.removeLatestEvent); + +router.use('/api/investments', router); + +export default router; diff --git a/src/server.js b/src/server.js index 4501a56..512ae38 100644 --- a/src/server.js +++ b/src/server.js @@ -32,6 +32,7 @@ import accountsRouter from './routes/accounts.js'; import incomesRouter from './routes/incomes.js'; import expensesRouter from './routes/expenses.js'; import goalsRouter from './routes/goals.js'; +import investmentsRouter from './routes/investments.js'; import './config/passport.js'; server.use(authenticationRouter); @@ -40,6 +41,7 @@ server.use(accountsRouter); server.use(incomesRouter); server.use(expensesRouter); server.use(goalsRouter); +server.use(investmentsRouter); // To handle paths correctly when using ES6 modules const __filename = fileURLToPath(import.meta.url); diff --git a/src/services/deleteUserEntries.js b/src/services/deleteUserEntries.js index fc8014b..03a125e 100644 --- a/src/services/deleteUserEntries.js +++ b/src/services/deleteUserEntries.js @@ -2,6 +2,8 @@ import mongoose from 'mongoose'; const Income = mongoose.model('Income'); const Expense = mongoose.model('Expense'); +const InvestmentPosition = mongoose.model('InvestmentPosition'); +const InvestmentEvent = mongoose.model('InvestmentEvent'); // Delete all incomes with provided userID / accountID const deleteIncomes = async (deleteField, ID) => { @@ -27,7 +29,21 @@ const deleteExpenses = async (deleteField, ID) => { } } +// Delete all investment positions and events with provided userID / accountID +const deleteInvestments = async (deleteField, ID) => { + + const query = { [deleteField]: ID }; + + try { + await InvestmentPosition.deleteMany(query); + await InvestmentEvent.deleteMany(query); + } catch (error) { + throw new Error(error || "An error occurred while deleting investments!"); + } +} + export default { deleteIncomes, - deleteExpenses + deleteExpenses, + deleteInvestments }; \ No newline at end of file diff --git a/src/services/draftsAccount.js b/src/services/draftsAccount.js index f69ed8a..362df9f 100644 --- a/src/services/draftsAccount.js +++ b/src/services/draftsAccount.js @@ -6,7 +6,6 @@ const User = mongoose.model('User'); const create = async (userID) => { const draftsAccount = { name: "Drafts", - totalBalance: 0, availableBalance: 0, budgets: [], goals: [], diff --git a/src/services/updateAccountBalances.js b/src/services/updateAccountBalances.js index 5eb2983..3dca4e4 100644 --- a/src/services/updateAccountBalances.js +++ b/src/services/updateAccountBalances.js @@ -1,8 +1,9 @@ import mongoose from 'mongoose'; const User = mongoose.model('User'); +const InvestmentPosition = mongoose.model('InvestmentPosition'); -// Update both totalAmount and availableAmount +// Update availableBalance for income/expense/investment cashflow operations const updateAllAccountBalances = async (accountID, amount, operation) => { try { @@ -21,12 +22,10 @@ const updateAllAccountBalances = async (accountID, amount, operation) => { // Add or subtract from account balance switch (operation) { case "+": - account.totalBalance += roundedAmount; account.availableBalance += roundedAmount; break; case "-": - account.totalBalance -= roundedAmount; account.availableBalance -= roundedAmount; break; } @@ -38,6 +37,33 @@ const updateAllAccountBalances = async (accountID, amount, operation) => { throw new Error(error || "An error occurred while updating account!"); } +} + +// Recalculate investedAmount from all open positions for an account +const recalcInvestedAmount = async (accountID) => { + + try { + const user = await User.findOne({ $or: [{ 'accounts._id': accountID }, { 'draftsAccount._id': accountID }] }); + + if (!user) { + throw new Error("No account with selected ID!"); + } + + let account = user.accounts.id(accountID) || user.draftsAccount; + + const result = await InvestmentPosition.aggregate([ + { $match: { accountID: accountID, closed: false } }, + { $group: { _id: null, total: { $sum: { $multiply: ["$quantity", "$currentUnitPriceAmount"] } } } } + ]); + + account.investedAmount = result[0]?.total || 0; + + await user.save(); + + } catch (error) { + throw new Error(error || "An error occurred while recalculating invested amount!"); + } + } // TODO: IMPLEMENT GOALS BEFORE REFACTORING @@ -47,7 +73,7 @@ const updateGoalAmount = (goalID, amount, operation) => { return new Promise((resolve, reject) => { // Find the user with requested account - User.findOne({'accounts.goals._id': goalID}, 'accounts.goals accounts.availableBalance') + User.findOne({ 'accounts.goals._id': goalID }, 'accounts.goals accounts.availableBalance') .then(user => { if (!user) { @@ -89,4 +115,4 @@ const updateGoalAmount = (goalID, amount, operation) => { }) } -export default {updateAllAccountBalances, updateGoalAmount} \ No newline at end of file +export default { updateAllAccountBalances, updateGoalAmount, recalcInvestedAmount } \ No newline at end of file diff --git a/test_entries.js b/test_entries.js index 394ee99..b0c6775 100644 --- a/test_entries.js +++ b/test_entries.js @@ -41,7 +41,7 @@ export const userToUpdate = { export const testAccountData = { name: 'Test Wallet', availableBalance: 0, - totalBalance: 0, + investedAmount: 0, startOfMonth: 1, budgets: [], goals: [], @@ -52,7 +52,7 @@ export const testAccountData = { export const accountDataToDelete = { name: 'Delete this Wallet', availableBalance: 0, - totalBalance: 0, + investedAmount: 0, startOfMonth: 1, budgets: [], goals: [], @@ -63,7 +63,7 @@ export const accountDataToDelete = { export const accountDataToUpdate = { name: 'Update this Wallet', availableBalance: 0, - totalBalance: 0, + investedAmount: 0, startOfMonth: 1, budgets: [], goals: [], @@ -119,7 +119,7 @@ export const incomeDataToUpdate = { export const accountDataToChangeExpenseAccountStart = { name: 'Remove from acc EXPENSE', availableBalance: -testExpenseData.amount, - totalBalance: -testExpenseData.amount, + investedAmount: 0, startOfMonth: 1, budgets: [], goals: [], @@ -130,7 +130,7 @@ export const accountDataToChangeExpenseAccountStart = { export const accountDataToChangeExpenseAccountDest = { name: 'Add to acc EXPENSE', availableBalance: 0, - totalBalance: 0, + investedAmount: 0, startOfMonth: 1, budgets: [], goals: [], @@ -141,7 +141,7 @@ export const accountDataToChangeExpenseAccountDest = { export const accountDataToChangeIncomeAccountStart = { name: 'Remove from acc INCOME', availableBalance: testIncomeData.amount, - totalBalance: testIncomeData.amount, + investedAmount: 0, startOfMonth: 1, budgets: [], goals: [], @@ -152,7 +152,7 @@ export const accountDataToChangeIncomeAccountStart = { export const accountDataToChangeIncomeAccountDest = { name: 'Add to acc INCOME', availableBalance: 0, - totalBalance: 0, + investedAmount: 0, startOfMonth: 1, budgets: [], goals: [], From 6fa96b9cd6c4fb9de6e1634992d134afc3cbe281 Mon Sep 17 00:00:00 2001 From: Lan Sovinc Date: Wed, 18 Mar 2026 15:23:16 +0100 Subject: [PATCH 2/2] implement investments 2 --- src/controllers/users.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/controllers/users.js b/src/controllers/users.js index bf4935a..dfcd161 100644 --- a/src/controllers/users.js +++ b/src/controllers/users.js @@ -65,6 +65,9 @@ const remove = async (req, res) => { // Delete user's expenses from db await deleteUserEntries.deleteExpenses("userID", req.params.id); + // Delete user's investments from db + await deleteUserEntries.deleteInvestments("userID", req.params.id); + res.send({message: "User deleted!"}); } catch (error) {