From b586df50c8f134bc707d9f510c822f283bc5d121 Mon Sep 17 00:00:00 2001 From: Gupta-02 Date: Fri, 23 Jan 2026 16:48:32 +0530 Subject: [PATCH] Implemeneted Expense Approval --- models/Expense.js | 9 + public/expensetracker.css | 394 +++++++++++++++++++++++++++ public/index.html | 103 ++++++++ public/trackerscript.js | 16 +- public/workspace-feature.js | 515 ++++++++++++++++++++++++++++++++++++ routes/approvals.js | 137 ++++++++++ routes/expenses.js | 31 ++- server.js | 3 +- services/approvalService.js | 307 +++++++++++++++++++++ 9 files changed, 1510 insertions(+), 5 deletions(-) create mode 100644 public/workspace-feature.js create mode 100644 routes/approvals.js create mode 100644 services/approvalService.js diff --git a/models/Expense.js b/models/Expense.js index 39c28cb..dde44ab 100644 --- a/models/Expense.js +++ b/models/Expense.js @@ -72,6 +72,15 @@ const expenseSchema = new mongoose.Schema({ isPrivate: { type: Boolean, default: false + }, + status: { + type: String, + enum: ['draft', 'pending_approval', 'approved', 'rejected'], + default: 'approved' // Default to approved for backward compatibility + }, + approvalWorkflow: { + type: mongoose.Schema.Types.ObjectId, + ref: 'ApprovalWorkflow' } }, { timestamps: true diff --git a/public/expensetracker.css b/public/expensetracker.css index 9035e9d..0514cc4 100644 --- a/public/expensetracker.css +++ b/public/expensetracker.css @@ -5254,4 +5254,398 @@ button { .widget-loading i { font-size: 2rem; color: var(--accent-primary); +} + +/* Settings Section Styles */ +.settings-section { + padding: 2rem 0; + background: var(--bg-secondary); +} + +.settings-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; +} + +.settings-tabs { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + border-bottom: 1px solid var(--bg-glass); + padding-bottom: 1rem; +} + +.settings-tab { + background: none; + border: none; + color: var(--text-secondary); + padding: 0.75rem 1.5rem; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + font-size: 1rem; + font-weight: 500; +} + +.settings-tab:hover { + background: var(--bg-glass); + color: var(--text-primary); +} + +.settings-tab.active { + background: var(--primary-gradient); + color: white; +} + +.settings-content { + display: none; +} + +.settings-content.active { + display: block; +} + +.settings-card { + background: var(--bg-tertiary); + border-radius: 12px; + padding: 2rem; + margin-bottom: 2rem; + backdrop-filter: blur(10px); + border: 1px solid var(--bg-glass); + box-shadow: var(--shadow-card); +} + +.settings-card h4 { + color: var(--text-primary); + margin-bottom: 1.5rem; + font-size: 1.25rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.settings-card h4 i { + color: var(--accent-primary); +} + +/* Workspace Settings */ +.workspace-info { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-bottom: 2rem; +} + +.workspace-info label { + display: block; + color: var(--text-secondary); + margin-bottom: 0.5rem; + font-weight: 500; +} + +.workspace-info span, +.workspace-info select { + color: var(--text-primary); + font-weight: 600; +} + +.workspace-actions { + margin-top: 1rem; +} + +.members-list { + margin-bottom: 1.5rem; +} + +.member-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: var(--bg-glass); + border-radius: 8px; + margin-bottom: 0.5rem; +} + +.member-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.member-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--primary-gradient); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; +} + +.member-details h5 { + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.member-role { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.member-actions { + display: flex; + gap: 0.5rem; +} + +.btn-role { + background: var(--bg-glass); + border: 1px solid var(--accent-primary); + color: var(--accent-primary); + padding: 0.25rem 0.75rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; +} + +.btn-role:hover { + background: var(--accent-primary); + color: white; +} + +.btn-remove { + background: var(--error); + color: white; + border: none; + padding: 0.25rem 0.75rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; +} + +.btn-remove:hover { + opacity: 0.8; +} + +/* Approvals Settings */ +.approval-config { + margin-bottom: 2rem; +} + +.approval-config .form-control { + margin-bottom: 1rem; +} + +.approval-config small { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.approvals-list { + max-height: 400px; + overflow-y: auto; +} + +.approval-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: var(--bg-glass); + border-radius: 8px; + margin-bottom: 0.5rem; +} + +.approval-info { + flex: 1; +} + +.approval-info h5 { + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.approval-details { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.approval-status { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.status-pending { + background: var(--warning); + color: #333; +} + +.status-approved { + background: var(--success); + color: white; +} + +.status-rejected { + background: var(--error); + color: white; +} + +.approval-actions { + display: flex; + gap: 0.5rem; +} + +.btn-approve { + background: var(--success); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; +} + +.btn-reject { + background: var(--error); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; +} + +/* Profile Settings */ +.profile-form .form-control { + margin-bottom: 1rem; +} + +.profile-form input[readonly] { + background: var(--bg-glass); + cursor: not-allowed; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .settings-container { + padding: 0 1rem; + } + + .workspace-info { + grid-template-columns: 1fr; + gap: 1rem; + } + + .settings-tabs { + flex-wrap: wrap; + } + + .settings-tab { + flex: 1; + min-width: 120px; + } + + .member-item { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .member-actions { + width: 100%; + justify-content: flex-end; + } + + .approval-item { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .approval-actions { + width: 100%; + justify-content: flex-end; + } +} + +/* Approval Status Badges */ +.approval-badge { + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + display: inline-block; + margin-left: 0.5rem; +} + +.status-approved { + background: var(--success); + color: white; +} + +.status-pending { + background: var(--warning); + color: #333; +} + +.status-rejected { + background: var(--error); + color: white; +} + +.transaction-meta { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Notification Styles */ +.notification { + position: fixed; + top: 20px; + right: 20px; + background: var(--bg-tertiary); + color: var(--text-primary); + padding: 1rem 1.5rem; + border-radius: 8px; + box-shadow: var(--shadow-card); + border-left: 4px solid var(--accent-primary); + z-index: 10000; + transform: translateX(100%); + transition: transform 0.3s ease; + max-width: 400px; +} + +.notification.show { + transform: translateX(0); +} + +.notification-success { + border-left-color: var(--success); +} + +.notification-error { + border-left-color: var(--error); +} + +.notification-info { + border-left-color: var(--accent-primary); +} + +.notification i { + margin-right: 0.5rem; +} + +.no-approvals { + text-align: center; + color: var(--text-secondary); + padding: 2rem; + font-style: italic; } \ No newline at end of file diff --git a/public/index.html b/public/index.html index fb17b53..61056a9 100644 --- a/public/index.html +++ b/public/index.html @@ -887,6 +887,108 @@

Financial Analytics & Forecasting

+ + +
+
+

Settings

+
+ +
+ +
+ + + +
+ + +
+
+

Workspace Management

+
+
+ + Personal +
+
+ + +
+
+ +
+ +
+
+ +
+

Members

+
+ +
+ +
+
+ + +
+
+

Expense Approvals

+
+
+ + + Set to 0 to disable approval workflow +
+ +
+
+ +
+

Pending Approvals

+
+ +
+
+ +
+

Approval History

+
+ +
+
+
+ + +
+
+

Profile Information

+
+
+ + +
+
+ + +
+ +
+
+
+
+
@@ -1580,6 +1682,7 @@

Install ExpenseFlow

+ diff --git a/public/trackerscript.js b/public/trackerscript.js index 647ddc8..ee4b43f 100644 --- a/public/trackerscript.js +++ b/public/trackerscript.js @@ -115,7 +115,8 @@ document.addEventListener("DOMContentLoaded", () => { amount: expense.type === 'expense' ? -expense.amount : expense.amount, category: expense.category, type: expense.type, - date: expense.date + date: expense.date, + approvalStatus: expense.approvalStatus || 'approved' // Default to approved for backward compatibility })); } catch (error) { console.error('Error fetching expenses:', error); @@ -552,6 +553,14 @@ document.addEventListener("DOMContentLoaded", () => { const formattedDate = date.toLocaleDateString('en-IN'); const categoryInfo = categories[transaction.category] || categories.other; + // Determine approval status + let statusBadge = ''; + if (transaction.approvalStatus) { + const status = transaction.approvalStatus.toLowerCase(); + const statusText = transaction.approvalStatus.charAt(0).toUpperCase() + transaction.approvalStatus.slice(1); + statusBadge = `${statusText}`; + } + item.innerHTML = `
@@ -562,7 +571,10 @@ document.addEventListener("DOMContentLoaded", () => { ${categoryInfo.name} -
${formattedDate}
+
+
${formattedDate}
+ ${statusBadge} +
+ ` : ''} + ` : ''} + + `; + + membersList.appendChild(memberItem); + }); +} + +function canManageMember(member) { + if (!currentUser || !currentWorkspace) return false; + + // Owner can manage everyone + if (currentWorkspace.owner.toString() === currentUser._id.toString()) { + return true; + } + + // Admins can manage members and viewers + if (currentUser.role === 'admin' && ['member', 'viewer'].includes(member.role)) { + return true; + } + + return false; +} + +async function changeMemberRole(memberId, newRole) { + try { + const token = localStorage.getItem('authToken'); + if (!token || !currentWorkspace) return; + + const response = await fetch(`/api/workspaces/${currentWorkspace._id}/members/${memberId}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ role: newRole }) + }); + + const data = await response.json(); + if (data.success) { + loadMembers(); // Reload members list + showNotification('Member role updated successfully', 'success'); + } else { + showNotification(data.message || 'Failed to update member role', 'error'); + } + } catch (error) { + console.error('Error updating member role:', error); + showNotification('Error updating member role', 'error'); + } +} + +async function removeMember(memberId) { + if (!confirm('Are you sure you want to remove this member from the workspace?')) { + return; + } + + try { + const token = localStorage.getItem('authToken'); + if (!token || !currentWorkspace) return; + + const response = await fetch(`/api/workspaces/${currentWorkspace._id}/members/${memberId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const data = await response.json(); + if (data.success) { + loadMembers(); // Reload members list + showNotification('Member removed successfully', 'success'); + } else { + showNotification(data.message || 'Failed to remove member', 'error'); + } + } catch (error) { + console.error('Error removing member:', error); + showNotification('Error removing member', 'error'); + } +} + +function switchSettingsTab(tabName) { + // Hide all settings content + const contents = document.querySelectorAll('.settings-content'); + contents.forEach(content => content.classList.remove('active')); + + // Remove active class from all tabs + const tabs = document.querySelectorAll('.settings-tab'); + tabs.forEach(tab => tab.classList.remove('active')); + + // Show selected content and activate tab + const selectedContent = document.getElementById(`${tabName}-settings`); + const selectedTab = Array.from(tabs).find(tab => tab.textContent.toLowerCase() === tabName); + + if (selectedContent) selectedContent.classList.add('active'); + if (selectedTab) selectedTab.classList.add('active'); +} + +function switchWorkspace() { + const select = document.getElementById('workspace-select'); + if (!select) return; + + const workspaceId = select.value; + // TODO: Implement workspace switching + showNotification('Workspace switching not yet implemented', 'info'); +} + +function openCreateWorkspaceModal() { + // TODO: Implement create workspace modal + showNotification('Create workspace feature not yet implemented', 'info'); +} + +function openInviteModal() { + // TODO: Implement invite modal + showNotification('Invite member feature not yet implemented', 'info'); +} + +// Approval Settings Functions +async function loadApprovalSettings() { + try { + const token = localStorage.getItem('authToken'); + if (!token || !currentWorkspace) return; + + const response = await fetch(`/api/workspaces/${currentWorkspace._id}/settings`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const data = await response.json(); + if (data.success) { + const approvalRequired = document.getElementById('approval-required'); + if (approvalRequired && data.settings.approvalThreshold) { + approvalRequired.value = data.settings.approvalThreshold; + } + } + } catch (error) { + console.error('Error loading approval settings:', error); + } +} + +async function saveApprovalSettings() { + try { + const token = localStorage.getItem('authToken'); + if (!token || !currentWorkspace) return; + + const approvalRequired = document.getElementById('approval-required'); + if (!approvalRequired) return; + + const threshold = parseFloat(approvalRequired.value) || 0; + + const response = await fetch(`/api/workspaces/${currentWorkspace._id}/settings`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + approvalThreshold: threshold + }) + }); + + const data = await response.json(); + if (data.success) { + showNotification('Approval settings saved successfully', 'success'); + } else { + showNotification(data.message || 'Failed to save approval settings', 'error'); + } + } catch (error) { + console.error('Error saving approval settings:', error); + showNotification('Error saving approval settings', 'error'); + } +} + +async function loadPendingApprovals() { + try { + const token = localStorage.getItem('authToken'); + if (!token) return; + + const response = await fetch('/api/approvals/pending', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const data = await response.json(); + if (data.success) { + renderApprovalsList('pending-approvals', data.approvals, true); + } + } catch (error) { + console.error('Error loading pending approvals:', error); + } +} + +async function loadApprovalHistory() { + try { + const token = localStorage.getItem('authToken'); + if (!token) return; + + const response = await fetch('/api/approvals/history', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const data = await response.json(); + if (data.success) { + renderApprovalsList('approval-history', data.approvals, false); + } + } catch (error) { + console.error('Error loading approval history:', error); + } +} + +function renderApprovalsList(containerId, approvals, showActions = false) { + const container = document.getElementById(containerId); + if (!container) return; + + container.innerHTML = ''; + + if (approvals.length === 0) { + container.innerHTML = '

No approvals found

'; + return; + } + + approvals.forEach(approval => { + const approvalItem = document.createElement('div'); + approvalItem.className = 'approval-item'; + + const statusClass = `status-${approval.status.toLowerCase()}`; + + approvalItem.innerHTML = ` +
+
${approval.expense.description}
+
+ Amount: ₹${approval.expense.amount} | + Submitted by: ${approval.submittedBy.name || approval.submittedBy.email} | + Date: ${new Date(approval.createdAt).toLocaleDateString()} +
+
+
${approval.status}
+ ${showActions ? ` +
+ + +
+ ` : ''} + `; + + container.appendChild(approvalItem); + }); +} + +async function approveExpense(approvalId) { + await processApproval(approvalId, 'approved'); +} + +async function rejectExpense(approvalId) { + await processApproval(approvalId, 'rejected'); +} + +async function processApproval(approvalId, status) { + try { + const token = localStorage.getItem('authToken'); + if (!token) return; + + const response = await fetch(`/api/approvals/${approvalId}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ status }) + }); + + const data = await response.json(); + if (data.success) { + showNotification(`Expense ${status} successfully`, 'success'); + loadPendingApprovals(); + loadApprovalHistory(); + // Refresh expense list to show updated status + if (typeof loadExpenses === 'function') { + loadExpenses(); + } + } else { + showNotification(data.message || `Failed to ${status} expense`, 'error'); + } + } catch (error) { + console.error(`Error ${status} expense:`, error); + showNotification(`Error ${status} expense`, 'error'); + } +} + +// Profile Functions +async function updateProfile() { + try { + const token = localStorage.getItem('authToken'); + if (!token) return; + + const nameInput = document.getElementById('profile-name'); + if (!nameInput) return; + + const newName = nameInput.value.trim(); + if (!newName) { + showNotification('Name cannot be empty', 'error'); + return; + } + + const response = await fetch('/api/auth/profile', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: newName }) + }); + + const data = await response.json(); + if (data.success) { + currentUser.name = newName; + updateUserDisplay(); + showNotification('Profile updated successfully', 'success'); + } else { + showNotification(data.message || 'Failed to update profile', 'error'); + } + } catch (error) { + console.error('Error updating profile:', error); + showNotification('Error updating profile', 'error'); + } +} + +// Utility function for notifications +function showNotification(message, type = 'info') { + // Create notification element + const notification = document.createElement('div'); + notification.className = `notification notification-${type}`; + notification.innerHTML = ` + + ${message} + `; + + // Add to page + document.body.appendChild(notification); + + // Show notification + setTimeout(() => notification.classList.add('show'), 100); + + // Remove notification after 3 seconds + setTimeout(() => { + notification.classList.remove('show'); + setTimeout(() => document.body.removeChild(notification), 300); + }, 3000); +} \ No newline at end of file diff --git a/routes/approvals.js b/routes/approvals.js new file mode 100644 index 0000000..491aabb --- /dev/null +++ b/routes/approvals.js @@ -0,0 +1,137 @@ +const express = require('express'); +const auth = require('../middleware/auth'); +const { checkRole, ROLES } = require('../middleware/rbac'); +const approvalService = require('../services/approvalService'); +const router = express.Router(); + +/** + * @route POST /api/approvals/submit/:expenseId + * @desc Submit expense for approval + * @access Private + */ +router.post('/submit/:expenseId', auth, async (req, res) => { + try { + const workflow = await approvalService.submitForApproval(req.params.expenseId, req.user._id); + res.json({ + success: true, + message: 'Expense submitted for approval', + data: workflow + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +/** + * @route GET /api/approvals/pending + * @desc Get pending approvals for current user + * @access Private + */ +router.get('/pending', auth, async (req, res) => { + try { + const approvals = await approvalService.getPendingApprovals(req.user._id); + res.json({ success: true, data: approvals }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route GET /api/approvals/history + * @desc Get approval history for current user + * @access Private + */ +router.get('/history', auth, async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 20; + const history = await approvalService.getApprovalHistory(req.user._id, page, limit); + res.json({ success: true, data: history }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * @route GET /api/approvals/:workflowId + * @desc Get workflow details + * @access Private + */ +router.get('/:workflowId', auth, async (req, res) => { + try { + const workflow = await approvalService.getWorkflowById(req.params.workflowId, req.user._id); + res.json({ success: true, data: workflow }); + } catch (error) { + res.status(403).json({ error: error.message }); + } +}); + +/** + * @route POST /api/approvals/:workflowId/approve + * @desc Approve a workflow step + * @access Private + */ +router.post('/:workflowId/approve', auth, async (req, res) => { + try { + const { comments } = req.body; + const workflow = await approvalService.processApproval( + req.params.workflowId, + req.user._id, + 'approved', + comments + ); + res.json({ + success: true, + message: 'Expense approved successfully', + data: workflow + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +/** + * @route POST /api/approvals/:workflowId/reject + * @desc Reject a workflow step + * @access Private + */ +router.post('/:workflowId/reject', auth, async (req, res) => { + try { + const { comments } = req.body; + const workflow = await approvalService.processApproval( + req.params.workflowId, + req.user._id, + 'rejected', + comments + ); + res.json({ + success: true, + message: 'Expense rejected', + data: workflow + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +/** + * @route GET /api/approvals/workspace/:workspaceId + * @desc Get all approval workflows for a workspace (admin only) + * @access Private (Admin only) + */ +router.get('/workspace/:workspaceId', auth, checkRole([ROLES.OWNER, ROLES.ADMIN]), async (req, res) => { + try { + const ApprovalWorkflow = require('../models/ApprovalWorkflow'); + const workflows = await ApprovalWorkflow.find({ workspaceId: req.params.workspaceId }) + .populate('expenseId') + .populate('submittedBy', 'name email') + .populate('steps.approver', 'name email') + .sort({ createdAt: -1 }); + + res.json({ success: true, data: workflows }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/routes/expenses.js b/routes/expenses.js index e0260ec..f835731 100644 --- a/routes/expenses.js +++ b/routes/expenses.js @@ -131,10 +131,31 @@ router.post('/', auth, async (req, res) => { 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.checkBudgetAlerts(req.user._id); } await budgetService.updateGoalProgress(req.user._id, value.type === 'expense' ? -amountForBudget : amountForBudget, value.category); @@ -142,7 +163,13 @@ router.post('/', auth, async (req, res) => { const io = req.app.get('io'); io.to(`user_${req.user._id}`).emit('expense_created', expense); - res.status(201).json(expense); + const response = { + ...expense.toObject(), + requiresApproval, + workflow: workflow ? { _id: workflow._id, status: workflow.status } : null + }; + + res.status(201).json(response); } catch (error) { res.status(500).json({ error: error.message }); } diff --git a/server.js b/server.js index 42cad4c..285b858 100644 --- a/server.js +++ b/server.js @@ -160,7 +160,7 @@ mongoose.connect(process.env.MONGODB_URI) io.use(socketAuth); // Socket.IO connection handling -io.on('connection', (socket) => { +io.on('connection', async (socket) => { console.log(`User ${socket.user.name} connected`); // Join user-specific room @@ -206,6 +206,7 @@ app.use('/api/currency', require('./routes/currency')); app.use('/api/groups', require('./routes/groups')); app.use('/api/splits', require('./routes/splits')); app.use('/api/workspaces', require('./routes/workspaces')); +app.use('/api/approvals', require('./routes/approvals')); app.use('/api/investments', require('./routes/investments')); app.use('/api/ai', require('./routes/ai')); app.use('/api/multicurrency', require('./routes/multicurrency')); diff --git a/services/approvalService.js b/services/approvalService.js new file mode 100644 index 0000000..e977475 --- /dev/null +++ b/services/approvalService.js @@ -0,0 +1,307 @@ +const ApprovalWorkflow = require('../models/ApprovalWorkflow'); +const Expense = require('../models/Expense'); +const Workspace = require('../models/Workspace'); +const User = require('../models/User'); +const notificationService = require('./notificationService'); +const emailService = require('./emailService'); + +class ApprovalService { + /** + * Check if an expense requires approval + */ + async requiresApproval(expenseData, workspaceId = null) { + if (!workspaceId) return false; + + const workspace = await Workspace.findById(workspaceId); + if (!workspace || !workspace.settings.approvalRequired) return false; + + // Check if expense amount exceeds threshold + return expenseData.amount >= workspace.settings.approvalThreshold; + } + + /** + * Submit expense for approval + */ + async submitForApproval(expenseId, submittedBy) { + const expense = await Expense.findById(expenseId).populate('workspace'); + if (!expense) throw new Error('Expense not found'); + + if (!expense.workspace) throw new Error('Expense must belong to a workspace for approval'); + + // Check if approval is required + const requiresApproval = await this.requiresApproval(expense, expense.workspace._id); + if (!requiresApproval) { + throw new Error('This expense does not require approval'); + } + + // Get workspace managers and admins for approval chain + const workspace = await Workspace.findById(expense.workspace._id) + .populate('members.user', 'name email') + .populate('owner', 'name email'); + + const approvers = this.getApprovers(workspace); + + if (approvers.length === 0) { + throw new Error('No approvers available for this workspace'); + } + + // Create approval workflow + const workflow = new ApprovalWorkflow({ + workspaceId: expense.workspace._id, + expenseId: expense._id, + submittedBy: submittedBy, + steps: approvers.map((approver, index) => ({ + stepNumber: index + 1, + approver: approver._id, + status: index === 0 ? 'pending' : 'pending' + })), + priority: this.calculatePriority(expense.amount, workspace.settings.approvalThreshold), + dueDate: this.calculateDueDate(expense.amount, workspace.settings.approvalThreshold) + }); + + await workflow.save(); + + // Update expense status + expense.status = 'pending_approval'; + await expense.save(); + + // Notify first approver + await this.notifyApprover(workflow, approvers[0]); + + return workflow; + } + + /** + * Get list of approvers for a workspace + */ + getApprovers(workspace) { + const approvers = []; + + // Add owner as first approver + approvers.push(workspace.owner); + + // Add admins and managers + workspace.members.forEach(member => { + if (['admin', 'manager'].includes(member.role)) { + approvers.push(member.user); + } + }); + + // Remove duplicates + return [...new Set(approvers.map(a => a._id.toString()))] + .map(id => approvers.find(a => a._id.toString() === id)); + } + + /** + * Calculate approval priority based on amount + */ + calculatePriority(amount, threshold) { + const ratio = amount / threshold; + if (ratio >= 5) return 'urgent'; + if (ratio >= 2) return 'high'; + if (ratio >= 1.5) return 'medium'; + return 'low'; + } + + /** + * Calculate due date based on priority + */ + calculateDueDate(amount, threshold) { + const priority = this.calculatePriority(amount, threshold); + const now = new Date(); + + switch (priority) { + case 'urgent': return new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day + case 'high': return new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000); // 3 days + case 'medium': return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 1 week + default: return new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); // 2 weeks + } + } + + /** + * Process approval decision + */ + async processApproval(workflowId, approverId, decision, comments = '') { + const workflow = await ApprovalWorkflow.findById(workflowId) + .populate('expenseId') + .populate('submittedBy', 'name email') + .populate('steps.approver', 'name email'); + + if (!workflow) throw new Error('Approval workflow not found'); + + if (workflow.status !== 'pending') { + throw new Error('Workflow is no longer pending'); + } + + const currentStep = workflow.steps[workflow.currentStep]; + if (!currentStep || currentStep.approver._id.toString() !== approverId.toString()) { + throw new Error('You are not authorized to approve this request'); + } + + // Update current step + currentStep.status = decision; + currentStep.action = decision; + currentStep.comments = comments; + currentStep.actionDate = new Date(); + + if (decision === 'approved') { + // Move to next step or complete + if (workflow.currentStep < workflow.steps.length - 1) { + workflow.currentStep++; + const nextStep = workflow.steps[workflow.currentStep]; + nextStep.status = 'pending'; + await this.notifyApprover(workflow, nextStep.approver); + } else { + // All steps approved + workflow.status = 'approved'; + workflow.completedAt = new Date(); + await this.finalizeApproval(workflow); + } + } else if (decision === 'rejected') { + // Reject the entire workflow + workflow.status = 'rejected'; + workflow.finalComments = comments; + workflow.completedAt = new Date(); + await this.finalizeRejection(workflow); + } + + await workflow.save(); + + // Notify submitter + await this.notifySubmitter(workflow, decision, comments); + + return workflow; + } + + /** + * Finalize approved expense + */ + async finalizeApproval(workflow) { + const expense = await Expense.findById(workflow.expenseId); + if (expense) { + expense.status = 'approved'; + await expense.save(); + } + + // Send notification to submitter + await notificationService.createNotification( + workflow.submittedBy, + 'expense_approved', + `Your expense "${expense.description}" has been approved`, + { expenseId: expense._id, workflowId: workflow._id } + ); + } + + /** + * Finalize rejected expense + */ + async finalizeRejection(workflow) { + const expense = await Expense.findById(workflow.expenseId); + if (expense) { + expense.status = 'rejected'; + await expense.save(); + } + + // Send notification to submitter + await notificationService.createNotification( + workflow.submittedBy, + 'expense_rejected', + `Your expense "${expense.description}" has been rejected`, + { expenseId: expense._id, workflowId: workflow._id } + ); + } + + /** + * Notify approver of pending approval + */ + async notifyApprover(workflow, approver) { + const expense = await Expense.findById(workflow.expenseId); + + await notificationService.createNotification( + approver._id, + 'approval_request', + `Approval needed for expense: ${expense.description} (₹${expense.amount})`, + { expenseId: expense._id, workflowId: workflow._id } + ); + + // Send email + await emailService.sendApprovalRequest(approver.email, { + expense: expense, + workflow: workflow, + approver: approver + }); + } + + /** + * Notify submitter of approval decision + */ + async notifySubmitter(workflow, decision, comments) { + const expense = await Expense.findById(workflow.expenseId); + + await notificationService.createNotification( + workflow.submittedBy, + `expense_${decision}`, + `Your expense "${expense.description}" has been ${decision}`, + { expenseId: expense._id, workflowId: workflow._id, comments } + ); + } + + /** + * Get pending approvals for a user + */ + async getPendingApprovals(userId) { + return await ApprovalWorkflow.find({ + 'steps.approver': userId, + 'steps.status': 'pending', + status: 'pending' + }) + .populate('expenseId') + .populate('submittedBy', 'name email') + .populate('workspaceId', 'name') + .sort({ createdAt: -1 }); + } + + /** + * Get approval history for a user + */ + async getApprovalHistory(userId, page = 1, limit = 20) { + const skip = (page - 1) * limit; + + return await ApprovalWorkflow.find({ + $or: [ + { submittedBy: userId }, + { 'steps.approver': userId } + ] + }) + .populate('expenseId') + .populate('submittedBy', 'name email') + .populate('workspaceId', 'name') + .populate('steps.approver', 'name email') + .sort({ updatedAt: -1 }) + .skip(skip) + .limit(limit); + } + + /** + * Get workflow by ID + */ + async getWorkflowById(workflowId, userId) { + const workflow = await ApprovalWorkflow.findById(workflowId) + .populate('expenseId') + .populate('submittedBy', 'name email') + .populate('workspaceId', 'name') + .populate('steps.approver', 'name email'); + + if (!workflow) throw new Error('Workflow not found'); + + // Check if user has access + const hasAccess = workflow.submittedBy._id.toString() === userId.toString() || + workflow.steps.some(step => step.approver._id.toString() === userId.toString()); + + if (!hasAccess) throw new Error('Access denied'); + + return workflow; + } +} + +module.exports = new ApprovalService(); \ No newline at end of file