From 1867aa33fb1c9dc1ec13a90d126400d790e061e0 Mon Sep 17 00:00:00 2001 From: Aditya8369 Date: Thu, 12 Feb 2026 19:00:08 +0530 Subject: [PATCH] refactor: Large and Complex Route Files --- middleware/expenseValidator.js | 31 +++++ routes/expenseCreation.js | 64 ++++++++++ routes/expenseExport.js | 43 +++++++ routes/expenseUpdate.js | 51 ++++++++ routes/expenses.js | 221 --------------------------------- server.js | 6 + services/expenseService.js | 103 +++++++++++++++ utils/currencyUtils.js | 32 +++++ 8 files changed, 330 insertions(+), 221 deletions(-) create mode 100644 middleware/expenseValidator.js create mode 100644 routes/expenseCreation.js create mode 100644 routes/expenseExport.js create mode 100644 routes/expenseUpdate.js create mode 100644 services/expenseService.js create mode 100644 utils/currencyUtils.js diff --git a/middleware/expenseValidator.js b/middleware/expenseValidator.js new file mode 100644 index 00000000..30501768 --- /dev/null +++ b/middleware/expenseValidator.js @@ -0,0 +1,31 @@ +const Joi = require('joi'); +const currencyService = require('../services/currencyService'); + +const expenseSchema = Joi.object({ + description: Joi.string().trim().max(100).required(), + amount: Joi.number().min(0.01).required(), + currency: Joi.string().uppercase().optional(), + category: Joi.string().valid('food', 'transport', 'entertainment', 'utilities', 'healthcare', 'shopping', 'other').required(), + type: Joi.string().valid('income', 'expense').required(), + merchant: Joi.string().trim().max(50).optional(), + date: Joi.date().optional(), + workspaceId: Joi.string().hex().length(24).optional() +}); + +const expenseValidator = async (req, res, next) => { + const { error, value } = expenseSchema.validate(req.body); + if (error) return res.status(400).json({ error: error.details[0].message }); + + const user = req.user; // Assuming auth middleware sets req.user + const expenseCurrency = value.currency || user.preferredCurrency; + + if (!currencyService.isValidCurrency(expenseCurrency)) { + return res.status(400).json({ error: 'Invalid currency code' }); + } + + req.validatedExpense = value; + req.expenseCurrency = expenseCurrency; + next(); +}; + +module.exports = expenseValidator; diff --git a/routes/expenseCreation.js b/routes/expenseCreation.js new file mode 100644 index 00000000..f2120b7f --- /dev/null +++ b/routes/expenseCreation.js @@ -0,0 +1,64 @@ +const express = require('express'); +const Expense = require('../models/Expense'); +const User = require('../models/User'); +const expenseValidator = require('../middleware/expenseValidator'); +const auth = require('../middleware/auth'); +const expenseService = require('../services/expenseService'); +const { convertExpenseAmount } = require('../utils/currencyUtils'); + +const router = express.Router(); + +// POST new expense for authenticated user +router.post('/', auth, expenseValidator, async (req, res) => { + try { + const user = await User.findById(req.user._id); + const { validatedExpense: value, expenseCurrency } = req; + + // Handle auto-categorization + const { finalCategory, autoCategorized } = await expenseService.handleAutoCategorization(value, req.user._id); + + // Store original amount and currency + const expenseData = { + ...value, + category: finalCategory, + user: value.workspaceId ? req.user._id : req.user._id, + addedBy: req.user._id, + workspace: value.workspaceId || null, + originalAmount: value.amount, + originalCurrency: expenseCurrency, + amount: value.amount, + autoCategorized + }; + + // Handle currency conversion if needed + const conversionData = await expenseService.handleCurrencyConversion(value.amount, expenseCurrency, user.preferredCurrency); + Object.assign(expenseData, conversionData); + + const expense = new Expense(expenseData); + await expense.save(); + + // Handle approval submission + const { requiresApproval, workflow } = await expenseService.handleApprovalSubmission(expense, req.user._id); + + // Handle budget update + const amountForBudget = expenseData.convertedAmount || value.amount; + await expenseService.handleBudgetUpdate(req.user._id, value.type, amountForBudget, value.category); + + // Emit real-time update + const io = req.app.get('io'); + const expenseForSocket = expenseService.prepareExpenseResponse(expense, user.preferredCurrency); + expenseService.emitRealTimeUpdate(io, req.user._id, 'expense_created', expenseForSocket); + + const response = { + ...expenseService.prepareExpenseResponse(expense, user.preferredCurrency), + requiresApproval, + workflow: workflow ? { _id: workflow._id, status: workflow.status } : null + }; + + res.status(201).json(response); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/routes/expenseExport.js b/routes/expenseExport.js new file mode 100644 index 00000000..833dc831 --- /dev/null +++ b/routes/expenseExport.js @@ -0,0 +1,43 @@ +const express = require('express'); +const exportService = require('../services/exportService'); +const auth = require('../middleware/auth'); + +const router = express.Router(); + +// GET export expenses to CSV +router.get('/export', auth, async (req, res) => { + try { + const { format, startDate, endDate, category } = req.query; + + // Validate format + if (format && format !== 'csv') { + return res.status(400).json({ error: 'Only CSV format is supported' }); + } + + // Get expenses using export service + const expenses = await exportService.getExpensesForExport(req.user._id, { + startDate, + endDate, + category, + type: 'all' // Include both income and expenses + }); + + if (expenses.length === 0) { + return res.status(404).json({ error: 'No expenses found for the selected filters' }); + } + + // Generate CSV using ExportService + const csv = exportService.generateCSV(expenses); + + // Set CSV headers + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename="expenses.csv"'); + + res.send(csv); + } catch (error) { + console.error('Export error:', error); + res.status(500).json({ error: 'Failed to export expenses' }); + } +}); + +module.exports = router; diff --git a/routes/expenseUpdate.js b/routes/expenseUpdate.js new file mode 100644 index 00000000..5671efd3 --- /dev/null +++ b/routes/expenseUpdate.js @@ -0,0 +1,51 @@ +const express = require('express'); +const Expense = require('../models/Expense'); +const User = require('../models/User'); +const expenseValidator = require('../middleware/expenseValidator'); +const auth = require('../middleware/auth'); +const expenseService = require('../services/expenseService'); + +const router = express.Router(); + +// PUT update expense for authenticated user +router.put('/:id', auth, expenseValidator, async (req, res) => { + try { + const user = await User.findById(req.user._id); + const { validatedExpense: value, expenseCurrency } = req; + + // Prepare update data + const updateData = { + ...value, + originalAmount: value.amount, + originalCurrency: expenseCurrency, + amount: value.amount + }; + + // Handle currency conversion if needed + const conversionData = await expenseService.handleCurrencyConversion(value.amount, expenseCurrency, user.preferredCurrency); + Object.assign(updateData, conversionData); + + const expense = await Expense.findOneAndUpdate( + { _id: req.params.id, user: req.user._id }, + updateData, + { new: true } + ); + if (!expense) return res.status(404).json({ error: 'Expense not found' }); + + // Handle budget update + await expenseService.handleBudgetUpdate(req.user._id, value.type, updateData.convertedAmount || value.amount, value.category); + + // Emit real-time update + const io = req.app.get('io'); + const expenseForSocket = expenseService.prepareExpenseResponse(expense, user.preferredCurrency); + expenseService.emitRealTimeUpdate(io, req.user._id, 'expense_updated', expenseForSocket); + + const response = expenseService.prepareExpenseResponse(expense, user.preferredCurrency); + + res.json(response); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/routes/expenses.js b/routes/expenses.js index 2770868d..b1050e4d 100644 --- a/routes/expenses.js +++ b/routes/expenses.js @@ -86,230 +86,9 @@ router.get('/', auth, async (req, res) => { } }); -// POST new expense for authenticated user -router.post('/', auth, async (req, res) => { - try { - const { error, value } = expenseSchema.validate(req.body); - if (error) return res.status(400).json({ error: error.details[0].message }); - - const user = await User.findById(req.user._id); - const expenseCurrency = value.currency || user.preferredCurrency; - - // Validate currency - if (!currencyService.isValidCurrency(expenseCurrency)) { - return res.status(400).json({ error: 'Invalid currency code' }); - } - - // Auto-categorize if category not provided or is 'other' - let finalCategory = value.category; - let autoCategorized = false; - let categorySuggestions = []; - - if (!value.category || value.category === 'other') { - try { - categorySuggestions = await categorizationService.suggestCategory( - req.user._id, - value.description, - value.amount - ); - - if (categorySuggestions.length > 0) { - finalCategory = categorySuggestions[0].category; - autoCategorized = true; - - // Save training data for future ML improvement - const CategoryTraining = require('../models/CategoryTraining'); - await CategoryTraining.create({ - user: req.user._id, - description: value.description, - amount: value.amount, - category: finalCategory, - merchant: value.merchant, - source: 'auto_categorized' - }); - } - } catch (categorizationError) { - console.error('Auto-categorization failed:', categorizationError); - // Continue with original category or 'other' - } - } - - // Store original amount and currency - const expenseData = { - ...value, - category: finalCategory, - user: value.workspaceId ? req.user._id : req.user._id, // User still relevant for reporting - addedBy: req.user._id, - workspace: value.workspaceId || null, - originalAmount: value.amount, - originalCurrency: expenseCurrency, - amount: value.amount, // Keep original as primary amount - autoCategorized - }; - - // If expense currency differs from user preference, add conversion info - if (expenseCurrency !== user.preferredCurrency) { - try { - const conversion = await currencyService.convertCurrency( - value.amount, - expenseCurrency, - user.preferredCurrency - ); - expenseData.convertedAmount = conversion.convertedAmount; - expenseData.convertedCurrency = user.preferredCurrency; - expenseData.exchangeRate = conversion.exchangeRate; - } catch (conversionError) { - console.error('Currency conversion failed:', conversionError.message); - // Continue without conversion data - } - } - - const expense = new Expense(expenseData); - await expense.save(); - - // Check if expense requires approval - const approvalService = require('../services/approvalService'); - let requiresApproval = false; - let workflow = null; - - if (expenseData.workspace) { - requiresApproval = await approvalService.requiresApproval(expenseData, expenseData.workspace); - } - - if (requiresApproval) { - try { - workflow = await approvalService.submitForApproval(expense._id, req.user._id); - expense.status = 'pending_approval'; - expense.approvalWorkflow = workflow._id; - await expense.save(); - } catch (approvalError) { - console.error('Failed to submit for approval:', approvalError.message); - // Continue with normal flow if approval submission fails - } - } - - // Update budget and goal progress using converted amount if available - const amountForBudget = expenseData.convertedAmount || value.amount; - if (value.type === 'expense') { - await budgetService.checkBudgetAlerts(req.user._id); - } - await budgetService.updateGoalProgress(req.user._id, value.type === 'expense' ? -amountForBudget : amountForBudget, value.category); - - // Emit real-time update to all user's connected devices - const io = req.app.get('io'); - - // Prepare the expense object with display amounts for socket emission - const expenseForSocket = expense.toObject(); - if (expenseCurrency !== user.preferredCurrency) { - expenseForSocket.displayAmount = expenseData.convertedAmount; - expenseForSocket.displayCurrency = user.preferredCurrency; - } else { - expenseForSocket.displayAmount = expense.amount; - expenseForSocket.displayCurrency = expenseCurrency; - } - - io.to(`user_${req.user._id}`).emit('expense_created', expenseForSocket); - - const response = { - ...expense.toObject(), - requiresApproval, - workflow: workflow ? { _id: workflow._id, status: workflow.status } : null - }; - // Add display amounts to response - if (expenseCurrency !== user.preferredCurrency) { - response.displayAmount = expenseData.convertedAmount; - response.displayCurrency = user.preferredCurrency; - } else { - response.displayAmount = expense.amount; - response.displayCurrency = expenseCurrency; - } - - res.status(201).json(response); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -// PUT update expense for authenticated user -router.put('/:id', auth, async (req, res) => { - try { - const { error, value } = expenseSchema.validate(req.body); - if (error) return res.status(400).json({ error: error.details[0].message }); - - const user = await User.findById(req.user._id); - const expenseCurrency = value.currency || user.preferredCurrency; - - // Validate currency - if (!currencyService.isValidCurrency(expenseCurrency)) { - return res.status(400).json({ error: 'Invalid currency code' }); - } - - // Prepare update data - const updateData = { - ...value, - originalAmount: value.amount, - originalCurrency: expenseCurrency, - amount: value.amount - }; - - // If expense currency differs from user preference, add conversion info - if (expenseCurrency !== user.preferredCurrency) { - try { - const conversion = await currencyService.convertCurrency( - value.amount, - expenseCurrency, - user.preferredCurrency - ); - updateData.convertedAmount = conversion.convertedAmount; - updateData.convertedCurrency = user.preferredCurrency; - updateData.exchangeRate = conversion.exchangeRate; - } catch (conversionError) { - console.error('Currency conversion failed:', conversionError.message); - } - } - - const expense = await Expense.findOneAndUpdate( - { _id: req.params.id, user: req.user._id }, - updateData, - { new: true } - ); - if (!expense) return res.status(404).json({ error: 'Expense not found' }); - // Update budget calculations - await budgetService.checkBudgetAlerts(req.user._id); - // Emit real-time update - const io = req.app.get('io'); - - // Prepare the expense object with display amounts for socket emission - const expenseForSocket = expense.toObject(); - if (expenseCurrency !== user.preferredCurrency) { - expenseForSocket.displayAmount = updateData.convertedAmount || expense.amount; - expenseForSocket.displayCurrency = user.preferredCurrency; - } else { - expenseForSocket.displayAmount = expense.amount; - expenseForSocket.displayCurrency = expenseCurrency; - } - - io.to(`user_${req.user._id}`).emit('expense_updated', expenseForSocket); - - const response = expense.toObject(); - - // Add display amounts to response - if (expenseCurrency !== user.preferredCurrency) { - response.displayAmount = updateData.convertedAmount || expense.amount; - response.displayCurrency = user.preferredCurrency; - } else { - response.displayAmount = expense.amount; - response.displayCurrency = expenseCurrency; - } - - res.json(response); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); // DELETE expense for authenticated user router.delete('/:id', auth, async (req, res) => { diff --git a/server.js b/server.js index 7164ec8f..f8be8eb7 100644 --- a/server.js +++ b/server.js @@ -13,6 +13,9 @@ require('dotenv').config(); const authRoutes = require('./routes/auth'); const expenseRoutes = require('./routes/expenses'); +const expenseCreationRoutes = require('./routes/expenseCreation'); +const expenseUpdateRoutes = require('./routes/expenseUpdate'); +const expenseExportRoutes = require('./routes/expenseExport'); const syncRoutes = require('./routes/sync'); const splitsRoutes = require('./routes/splits'); const groupsRoutes = require('./routes/groups'); @@ -156,6 +159,9 @@ io.on('connection', (socket) => { // Routes app.use('/api/auth', require('./middleware/rateLimiter').authLimiter, authRoutes); app.use('/api/expenses', require('./middleware/rateLimiter').expenseLimiter, expenseRoutes); +app.use('/api/expenses', require('./middleware/rateLimiter').expenseLimiter, expenseCreationRoutes); +app.use('/api/expenses', require('./middleware/rateLimiter').expenseLimiter, expenseUpdateRoutes); +app.use('/api/expenses', require('./middleware/rateLimiter').expenseLimiter, expenseExportRoutes); app.use('/api/sync', syncRoutes); app.use('/api/notifications', require('./routes/notifications')); app.use('/api/receipts', require('./middleware/rateLimiter').uploadLimiter, require('./routes/receipts')); diff --git a/services/expenseService.js b/services/expenseService.js new file mode 100644 index 00000000..f1375dd8 --- /dev/null +++ b/services/expenseService.js @@ -0,0 +1,103 @@ +const Expense = require('../models/Expense'); +const User = require('../models/User'); +const CategoryTraining = require('../models/CategoryTraining'); +const categorizationService = require('./categorizationService'); +const approvalService = require('./approvalService'); +const budgetService = require('./budgetService'); +const { convertExpenseAmount, prepareExpenseWithDisplayAmounts } = require('../utils/currencyUtils'); + +const handleAutoCategorization = async (expenseData, userId) => { + let finalCategory = expenseData.category; + let autoCategorized = false; + let categorySuggestions = []; + + if (!expenseData.category || expenseData.category === 'other') { + try { + categorySuggestions = await categorizationService.suggestCategory( + userId, + expenseData.description, + expenseData.amount + ); + + if (categorySuggestions.length > 0) { + finalCategory = categorySuggestions[0].category; + autoCategorized = true; + + await CategoryTraining.create({ + user: userId, + description: expenseData.description, + amount: expenseData.amount, + category: finalCategory, + merchant: expenseData.merchant, + source: 'auto_categorized' + }); + } + } catch (error) { + console.error('Auto-categorization failed:', error); + } + } + + return { finalCategory, autoCategorized, categorySuggestions }; +}; + +const handleCurrencyConversion = async (amount, fromCurrency, toCurrency) => { + if (fromCurrency === toCurrency) { + return { convertedAmount: amount, convertedCurrency: toCurrency, exchangeRate: 1 }; + } + const conversion = await convertExpenseAmount(amount, fromCurrency, toCurrency); + if (conversion) { + return { + convertedAmount: conversion.convertedAmount, + convertedCurrency: toCurrency, + exchangeRate: conversion.exchangeRate + }; + } + return {}; +}; + +const handleApprovalSubmission = async (expense, userId) => { + let requiresApproval = false; + let workflow = null; + + if (expense.workspace) { + requiresApproval = await approvalService.requiresApproval(expense, expense.workspace); + } + + if (requiresApproval) { + try { + workflow = await approvalService.submitForApproval(expense._id, userId); + expense.status = 'pending_approval'; + expense.approvalWorkflow = workflow._id; + await expense.save(); + } catch (error) { + console.error('Failed to submit for approval:', error.message); + } + } + + return { requiresApproval, workflow }; +}; + +const handleBudgetUpdate = async (userId, type, amount, category) => { + if (type === 'expense') { + await budgetService.checkBudgetAlerts(userId); + } + await budgetService.updateGoalProgress(userId, type === 'expense' ? -amount : amount, category); +}; + +const emitRealTimeUpdate = (io, userId, event, data) => { + io.to(`user_${userId}`).emit(event, data); +}; + +const prepareExpenseResponse = (expense, userPreferredCurrency) => { + const response = prepareExpenseWithDisplayAmounts(expense, userPreferredCurrency); + return response; +}; + +module.exports = { + handleAutoCategorization, + handleCurrencyConversion, + handleApprovalSubmission, + handleBudgetUpdate, + emitRealTimeUpdate, + prepareExpenseResponse +}; diff --git a/utils/currencyUtils.js b/utils/currencyUtils.js new file mode 100644 index 00000000..9c0caca4 --- /dev/null +++ b/utils/currencyUtils.js @@ -0,0 +1,32 @@ +const currencyService = require('../services/currencyService'); + +const convertExpenseAmount = async (amount, fromCurrency, toCurrency) => { + if (fromCurrency === toCurrency) { + return { convertedAmount: amount, exchangeRate: 1 }; + } + try { + const conversion = await currencyService.convertCurrency(amount, fromCurrency, toCurrency); + return { convertedAmount: conversion.convertedAmount, exchangeRate: conversion.exchangeRate }; + } catch (error) { + console.error('Currency conversion failed:', error.message); + return null; // Or throw error, but for now return null to handle gracefully + } +}; + +const prepareExpenseWithDisplayAmounts = (expense, userPreferredCurrency) => { + const expenseObj = expense.toObject ? expense.toObject() : expense; + if (expenseObj.originalCurrency !== userPreferredCurrency) { + // Assuming convertedAmount is already set if conversion happened + expenseObj.displayAmount = expenseObj.convertedAmount || expenseObj.amount; + expenseObj.displayCurrency = userPreferredCurrency; + } else { + expenseObj.displayAmount = expenseObj.amount; + expenseObj.displayCurrency = expenseObj.originalCurrency; + } + return expenseObj; +}; + +module.exports = { + convertExpenseAmount, + prepareExpenseWithDisplayAmounts +};