diff --git a/models/BudgetVariance.js b/models/BudgetVariance.js new file mode 100644 index 00000000..13f9c38a --- /dev/null +++ b/models/BudgetVariance.js @@ -0,0 +1,178 @@ +const mongoose = require('mongoose'); + +/** + * BudgetVariance Model + * Stores detailed variance analysis results for budget monitoring + */ +const varianceItemSchema = new mongoose.Schema({ + category: String, + subcategory: String, + budgetedAmount: { + type: Number, + required: true + }, + actualAmount: { + type: Number, + required: true + }, + variance: { + type: Number, + required: true + }, + variancePercentage: { + type: Number, + required: true + }, + varianceType: { + type: String, + enum: ['favorable', 'unfavorable', 'neutral'], + required: true + }, + anomalyScore: { + type: Number, + default: 0, + min: 0, + max: 100 + }, + isAnomaly: { + type: Boolean, + default: false + }, + transactionCount: { + type: Number, + default: 0 + } +}, { _id: false }); + +const budgetVarianceSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + budgetId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Budget', + required: true, + index: true + }, + budgetName: String, + analysisDate: { + type: Date, + required: true, + default: Date.now + }, + period: { + startDate: { + type: Date, + required: true + }, + endDate: { + type: Date, + required: true + }, + periodType: { + type: String, + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + default: 'monthly' + } + }, + items: [varianceItemSchema], + summary: { + totalBudgeted: { + type: Number, + default: 0 + }, + totalActual: { + type: Number, + default: 0 + }, + totalVariance: { + type: Number, + default: 0 + }, + variancePercentage: { + type: Number, + default: 0 + }, + favorableVariances: { + type: Number, + default: 0 + }, + unfavorableVariances: { + type: Number, + default: 0 + }, + anomaliesDetected: { + type: Number, + default: 0 + }, + utilizationRate: { + type: Number, + default: 0 + } + }, + alerts: [{ + severity: { + type: String, + enum: ['low', 'medium', 'high', 'critical'] + }, + category: String, + message: String, + recommendedAction: String, + createdAt: { + type: Date, + default: Date.now + } + }], + trends: { + isIncreasing: Boolean, + trendPercentage: Number, + projectedOverrun: Number, + daysUntilOverrun: Number + }, + status: { + type: String, + enum: ['on_track', 'warning', 'critical', 'exceeded'], + default: 'on_track' + } +}, { + timestamps: true +}); + +// Pre-save hook to calculate summary +budgetVarianceSchema.pre('save', function (next) { + this.summary.totalBudgeted = this.items.reduce((sum, i) => sum + i.budgetedAmount, 0); + this.summary.totalActual = this.items.reduce((sum, i) => sum + i.actualAmount, 0); + this.summary.totalVariance = this.summary.totalActual - this.summary.totalBudgeted; + + if (this.summary.totalBudgeted > 0) { + this.summary.variancePercentage = (this.summary.totalVariance / this.summary.totalBudgeted) * 100; + this.summary.utilizationRate = (this.summary.totalActual / this.summary.totalBudgeted) * 100; + } + + this.summary.favorableVariances = this.items.filter(i => i.varianceType === 'favorable').length; + this.summary.unfavorableVariances = this.items.filter(i => i.varianceType === 'unfavorable').length; + this.summary.anomaliesDetected = this.items.filter(i => i.isAnomaly).length; + + // Determine status + if (this.summary.utilizationRate >= 100) { + this.status = 'exceeded'; + } else if (this.summary.utilizationRate >= 90) { + this.status = 'critical'; + } else if (this.summary.utilizationRate >= 75) { + this.status = 'warning'; + } else { + this.status = 'on_track'; + } + + next(); +}); + +// Indexes +budgetVarianceSchema.index({ userId: 1, analysisDate: -1 }); +budgetVarianceSchema.index({ budgetId: 1, 'period.startDate': 1 }); +budgetVarianceSchema.index({ status: 1 }); + +module.exports = mongoose.model('BudgetVariance', budgetVarianceSchema); diff --git a/models/SpendForecast.js b/models/SpendForecast.js new file mode 100644 index 00000000..87fcc121 --- /dev/null +++ b/models/SpendForecast.js @@ -0,0 +1,184 @@ +const mongoose = require('mongoose'); + +/** + * SpendForecast Model + * Stores predictive spend projections with confidence intervals + */ +const forecastDataPointSchema = new mongoose.Schema({ + date: { + type: Date, + required: true + }, + predictedAmount: { + type: Number, + required: true + }, + lowerBound: { + type: Number, + required: true + }, + upperBound: { + type: Number, + required: true + }, + confidence: { + type: Number, + default: 95, + min: 0, + max: 100 + }, + actualAmount: Number, + variance: Number +}, { _id: false }); + +const spendForecastSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + budgetId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Budget', + index: true + }, + category: String, + forecastId: { + type: String, + unique: true, + required: true + }, + forecastDate: { + type: Date, + required: true, + default: Date.now + }, + forecastPeriod: { + startDate: { + type: Date, + required: true + }, + endDate: { + type: Date, + required: true + }, + periodType: { + type: String, + enum: ['daily', 'weekly', 'monthly', 'quarterly'], + default: 'monthly' + } + }, + historicalPeriod: { + startDate: Date, + endDate: Date, + dataPoints: Number + }, + forecastMethod: { + type: String, + enum: ['linear', 'exponential', 'seasonal', 'moving_average', 'ensemble'], + required: true + }, + dataPoints: [forecastDataPointSchema], + summary: { + totalPredicted: { + type: Number, + default: 0 + }, + averageDaily: { + type: Number, + default: 0 + }, + peakPredicted: { + type: Number, + default: 0 + }, + peakDate: Date, + trend: { + type: String, + enum: ['increasing', 'decreasing', 'stable'] + }, + trendStrength: { + type: Number, + min: 0, + max: 1 + }, + seasonalityDetected: { + type: Boolean, + default: false + }, + seasonalPattern: String + }, + accuracy: { + mape: Number, // Mean Absolute Percentage Error + rmse: Number, // Root Mean Square Error + mae: Number, // Mean Absolute Error + r2Score: Number // R-squared score + }, + alerts: [{ + type: { + type: String, + enum: ['budget_overrun', 'unusual_spike', 'trend_change'] + }, + severity: { + type: String, + enum: ['low', 'medium', 'high'] + }, + message: String, + date: Date, + amount: Number + }], + status: { + type: String, + enum: ['active', 'expired', 'superseded'], + default: 'active' + } +}, { + timestamps: true +}); + +// Pre-save hook to calculate summary +spendForecastSchema.pre('save', function (next) { + if (this.dataPoints.length > 0) { + this.summary.totalPredicted = this.dataPoints.reduce((sum, dp) => sum + dp.predictedAmount, 0); + this.summary.averageDaily = this.summary.totalPredicted / this.dataPoints.length; + + // Find peak + const peak = this.dataPoints.reduce((max, dp) => + dp.predictedAmount > max.predictedAmount ? dp : max + ); + this.summary.peakPredicted = peak.predictedAmount; + this.summary.peakDate = peak.date; + + // Determine trend + if (this.dataPoints.length >= 3) { + const firstThird = this.dataPoints.slice(0, Math.floor(this.dataPoints.length / 3)); + const lastThird = this.dataPoints.slice(-Math.floor(this.dataPoints.length / 3)); + + const firstAvg = firstThird.reduce((sum, dp) => sum + dp.predictedAmount, 0) / firstThird.length; + const lastAvg = lastThird.reduce((sum, dp) => sum + dp.predictedAmount, 0) / lastThird.length; + + const change = ((lastAvg - firstAvg) / firstAvg) * 100; + + if (change > 5) { + this.summary.trend = 'increasing'; + this.summary.trendStrength = Math.min(change / 100, 1); + } else if (change < -5) { + this.summary.trend = 'decreasing'; + this.summary.trendStrength = Math.min(Math.abs(change) / 100, 1); + } else { + this.summary.trend = 'stable'; + this.summary.trendStrength = 0; + } + } + } + + next(); +}); + +// Indexes +spendForecastSchema.index({ userId: 1, forecastDate: -1 }); +spendForecastSchema.index({ budgetId: 1, status: 1 }); +spendForecastSchema.index({ category: 1, status: 1 }); + +module.exports = mongoose.model('SpendForecast', spendForecastSchema); diff --git a/public/budget-variance-dashboard.html b/public/budget-variance-dashboard.html new file mode 100644 index 00000000..fdab1b21 --- /dev/null +++ b/public/budget-variance-dashboard.html @@ -0,0 +1,228 @@ + + + + + + + Budget Variance & Forecasting - ExpenseFlow + + + + + + + + +
+ +
+
+

Advanced Budget Analytics

+

ML-powered variance analysis and predictive spend forecasting

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

0%

+
+
+
+
+ +
+
+ +

0

+
+
+
+
+ +
+
+ +

₹0

+
+
+
+
+ +
+
+ +

0

+
+
+
+ + +
+ +
+
+

Variance Heatmap

+
+ +
+
+
+
Loading variance data...
+
+
+ + +
+
+

Spend Forecast

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

Optimization Recommendations

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

Utilization Trend

+
+
+ +
+
+ +
+
+

Category Distribution

+
+
+ +
+
+
+ + +
+
+

Critical Alerts

+
+
+ +
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/public/js/budget-analytics-controller.js b/public/js/budget-analytics-controller.js new file mode 100644 index 00000000..ebecbb4b --- /dev/null +++ b/public/js/budget-analytics-controller.js @@ -0,0 +1,616 @@ +/** + * Budget Analytics Controller + * Handles all budget variance and forecasting UI logic + */ + +let forecastChart = null; +let utilizationTrendChart = null; +let categoryDistChart = null; +let currentBudgetId = null; +let currentVariance = null; +let selectedRecommendations = []; + +document.addEventListener('DOMContentLoaded', () => { + loadBudgets(); + loadDashboard(); + setupForms(); +}); + +async function loadBudgets() { + try { + const res = await fetch('/api/budgets', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { budgets } = await res.json(); + + const select = document.getElementById('budget-selector'); + select.innerHTML = '' + + budgets.map(b => ``).join(''); + } catch (err) { + console.error('Failed to load budgets:', err); + } +} + +async function loadDashboard() { + try { + const res = await fetch('/api/budget-analytics/variance/dashboard', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + updateDashboardStats(data); + renderCriticalAlerts(data.criticalAlerts); + } catch (err) { + console.error('Failed to load dashboard:', err); + } +} + +function updateDashboardStats(data) { + const { summary, latestVariances } = data; + + document.getElementById('anomalies-count').textContent = summary.totalAnomalies; + document.getElementById('critical-alerts').textContent = summary.criticalAlerts; + + if (latestVariances.length > 0) { + const latest = latestVariances[0]; + document.getElementById('utilization-rate').textContent = + `${latest.summary.utilizationRate.toFixed(1)}%`; + + const variance = latest.summary.totalVariance; + const varianceEl = document.getElementById('total-variance'); + varianceEl.textContent = `₹${Math.abs(variance).toLocaleString()}`; + varianceEl.style.color = variance >= 0 ? '#ff6b6b' : '#64ffda'; + } +} + +async function loadBudgetAnalytics() { + const budgetId = document.getElementById('budget-selector').value; + if (!budgetId) return; + + currentBudgetId = budgetId; + + // Load latest variance + await loadLatestVariance(budgetId); + + // Load forecast + await loadLatestForecast(budgetId); + + // Load optimization recommendations + await loadOptimizationRecommendations(budgetId); + + // Load trend + await loadUtilizationTrend(budgetId); +} + +async function loadLatestVariance(budgetId) { + try { + const res = await fetch(`/api/budget-analytics/variances?budgetId=${budgetId}&limit=1`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + if (data.length > 0) { + currentVariance = data[0]; + renderVarianceHeatmap(currentVariance.items); + renderCategoryDistribution(currentVariance.items); + } + } catch (err) { + console.error('Failed to load variance:', err); + } +} + +function renderVarianceHeatmap(items) { + const container = document.getElementById('variance-heatmap'); + + if (!items || items.length === 0) { + container.innerHTML = '
No variance data available.
'; + return; + } + + container.innerHTML = items.map(item => { + const intensity = Math.min(Math.abs(item.variancePercentage) / 100, 1); + const color = item.varianceType === 'unfavorable' + ? `rgba(255, 107, 107, ${intensity})` + : `rgba(100, 255, 218, ${intensity})`; + + return ` +
+
+ ${item.category} + ${item.isAnomaly ? '' : ''} +
+
+
+ + ₹${item.budgetedAmount.toLocaleString()} +
+
+ + ₹${item.actualAmount.toLocaleString()} +
+
+ + + ${item.variancePercentage > 0 ? '+' : ''}${item.variancePercentage.toFixed(1)}% + +
+ ${item.isAnomaly ? ` +
+ + ${item.anomalyScore.toFixed(0)}/100 +
+ ` : ''} +
+
+ `; + }).join(''); +} + +function filterVariances() { + const filter = document.getElementById('variance-filter').value; + + if (!currentVariance) return; + + let filtered = currentVariance.items; + + if (filter === 'unfavorable') { + filtered = currentVariance.items.filter(i => i.varianceType === 'unfavorable'); + } else if (filter === 'anomalies') { + filtered = currentVariance.items.filter(i => i.isAnomaly); + } + + renderVarianceHeatmap(filtered); +} + +async function loadLatestForecast(budgetId) { + try { + const res = await fetch(`/api/budget-analytics/forecasts?budgetId=${budgetId}&limit=1`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + if (data.length > 0) { + renderForecastChart(data[0]); + renderForecastSummary(data[0]); + } + } catch (err) { + console.error('Failed to load forecast:', err); + } +} + +function renderForecastChart(forecast) { + const ctx = document.getElementById('forecastChart').getContext('2d'); + + if (forecastChart) { + forecastChart.destroy(); + } + + const labels = forecast.dataPoints.map(dp => + new Date(dp.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ); + + forecastChart = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets: [ + { + label: 'Predicted Spend', + data: forecast.dataPoints.map(dp => dp.predictedAmount), + borderColor: '#48dbfb', + backgroundColor: 'rgba(72, 219, 251, 0.1)', + fill: false, + tension: 0.4, + borderWidth: 2 + }, + { + label: 'Upper Bound (95% CI)', + data: forecast.dataPoints.map(dp => dp.upperBound), + borderColor: 'rgba(255, 159, 67, 0.5)', + backgroundColor: 'rgba(255, 159, 67, 0.05)', + fill: '+1', + tension: 0.4, + borderWidth: 1, + borderDash: [5, 5] + }, + { + label: 'Lower Bound (95% CI)', + data: forecast.dataPoints.map(dp => dp.lowerBound), + borderColor: 'rgba(100, 255, 218, 0.5)', + backgroundColor: 'rgba(100, 255, 218, 0.05)', + fill: false, + tension: 0.4, + borderWidth: 1, + borderDash: [5, 5] + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + labels: { color: '#8892b0', font: { size: 11 } } + }, + tooltip: { + callbacks: { + label: function (context) { + return `${context.dataset.label}: ₹${context.parsed.y.toLocaleString()}`; + } + } + } + }, + scales: { + x: { + ticks: { color: '#8892b0', font: { size: 10 } }, + grid: { color: 'rgba(255,255,255,0.05)' } + }, + y: { + ticks: { + color: '#8892b0', + callback: function (value) { + return '₹' + value.toLocaleString(); + } + }, + grid: { color: 'rgba(255,255,255,0.05)' } + } + } + } + }); +} + +function renderForecastSummary(forecast) { + const container = document.getElementById('forecast-summary'); + + container.innerHTML = ` +
+
+ + ₹${forecast.summary.totalPredicted.toLocaleString()} +
+
+ + ₹${forecast.summary.averageDaily.toLocaleString()} +
+
+ + + ${forecast.summary.trend} + ${forecast.summary.trendStrength ? `(${(forecast.summary.trendStrength * 100).toFixed(0)}%)` : ''} + +
+
+ + ${forecast.forecastMethod.replace('_', ' ')} +
+
+ ${forecast.alerts.length > 0 ? ` +
+ Forecast Alerts: + ${forecast.alerts.map(a => ` +
+ + ${a.message} +
+ `).join('')} +
+ ` : ''} + `; +} + +async function loadOptimizationRecommendations(budgetId) { + try { + const res = await fetch(`/api/budget-analytics/optimize/${budgetId}`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderRecommendations(data.recommendations, data.optimizationScore); + } catch (err) { + console.error('Failed to load recommendations:', err); + } +} + +function renderRecommendations(recommendations, score) { + const container = document.getElementById('recommendations-list'); + + if (!recommendations || recommendations.length === 0) { + container.innerHTML = '
No optimization recommendations available.
'; + return; + } + + container.innerHTML = ` +
+ +
+
+
+ ${score.toFixed(1)}% +
+
+ ${recommendations.map((rec, index) => ` +
+
+ + ${rec.priority} + ${rec.type.replace('_', ' ')} +
+
+
+ ${rec.action.replace('_', ' ')} + ${rec.category ? `${rec.category}` : ''} +
+ ${rec.from && rec.to ? ` +
+ ${rec.from} + + ${rec.to} +
+ ` : ''} +
+ Amount: ₹${rec.amount.toLocaleString()} +
+
+ ${rec.rationale} +
+
+ ${rec.expectedImpact} +
+
+
+ `).join('')} +
+ `; + + // Add event listeners to checkboxes + document.querySelectorAll('.rec-checkbox').forEach(cb => { + cb.addEventListener('change', (e) => { + const index = parseInt(e.target.dataset.index); + if (e.target.checked) { + selectedRecommendations.push(index); + } else { + selectedRecommendations = selectedRecommendations.filter(i => i !== index); + } + }); + }); +} + +async function loadUtilizationTrend(budgetId) { + try { + const res = await fetch(`/api/budget-analytics/variance/trend/${budgetId}?months=6`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderUtilizationTrend(data); + } catch (err) { + console.error('Failed to load trend:', err); + } +} + +function renderUtilizationTrend(trend) { + if (!trend || trend.length === 0) return; + + const ctx = document.getElementById('utilizationTrendChart').getContext('2d'); + + if (utilizationTrendChart) { + utilizationTrendChart.destroy(); + } + + utilizationTrendChart = new Chart(ctx, { + type: 'line', + data: { + labels: trend.map(t => new Date(t.date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })), + datasets: [{ + label: 'Utilization Rate (%)', + data: trend.map(t => t.utilizationRate), + borderColor: '#48dbfb', + backgroundColor: 'rgba(72, 219, 251, 0.1)', + fill: true, + tension: 0.4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false } + }, + scales: { + x: { + ticks: { color: '#8892b0' }, + grid: { color: 'rgba(255,255,255,0.05)' } + }, + y: { + ticks: { color: '#8892b0' }, + grid: { color: 'rgba(255,255,255,0.05)' }, + min: 0, + max: 150 + } + } + } + }); +} + +function renderCategoryDistribution(items) { + if (!items || items.length === 0) return; + + const ctx = document.getElementById('categoryDistChart').getContext('2d'); + + if (categoryDistChart) { + categoryDistChart.destroy(); + } + + categoryDistChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: items.map(i => i.category), + datasets: [{ + data: items.map(i => i.actualAmount), + backgroundColor: [ + '#64ffda', '#48dbfb', '#ff9f43', '#ff6b6b', '#a29bfe', '#fd79a8' + ], + borderWidth: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { color: '#8892b0', font: { size: 10 } } + } + } + } + }); +} + +function renderCriticalAlerts(alerts) { + const container = document.getElementById('alerts-list'); + + if (!alerts || alerts.length === 0) { + container.innerHTML = '
No critical alerts.
'; + return; + } + + container.innerHTML = alerts.slice(0, 10).map(alert => ` +
+
+ +
+
+
+ ${alert.category} + ${alert.severity} +
+

${alert.message}

+
+ ${alert.recommendedAction} +
+
+
+ `).join(''); +} + +function runVarianceAnalysis() { + if (!currentBudgetId) { + alert('Please select a budget first'); + return; + } + document.getElementById('variance-modal').classList.remove('hidden'); +} + +function closeVarianceModal() { + document.getElementById('variance-modal').classList.add('hidden'); +} + +function generateForecast() { + if (!currentBudgetId) { + alert('Please select a budget first'); + return; + } + document.getElementById('forecast-modal').classList.remove('hidden'); +} + +function closeForecastModal() { + document.getElementById('forecast-modal').classList.add('hidden'); +} + +async function applySelectedRecommendations() { + if (!currentBudgetId || selectedRecommendations.length === 0) { + alert('Please select recommendations to apply'); + return; + } + + if (!confirm(`Apply ${selectedRecommendations.length} recommendation(s)?`)) return; + + try { + const res = await fetch(`/api/budget-analytics/optimize/${currentBudgetId}/apply`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify({ recommendationIds: selectedRecommendations }) + }); + + const { data } = await res.json(); + + alert(`Successfully applied ${data.appliedCount} recommendation(s)`); + selectedRecommendations = []; + loadBudgetAnalytics(); + } catch (err) { + console.error('Failed to apply recommendations:', err); + alert('Failed to apply recommendations'); + } +} + +function setupForms() { + document.getElementById('variance-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const startDate = document.getElementById('variance-start-date').value; + const endDate = document.getElementById('variance-end-date').value; + + try { + const res = await fetch('/api/budget-analytics/variance/analyze', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify({ + budgetId: currentBudgetId, + startDate, + endDate + }) + }); + + const { data } = await res.json(); + + alert('Variance analysis completed!'); + closeVarianceModal(); + loadBudgetAnalytics(); + } catch (err) { + console.error('Failed to run analysis:', err); + alert('Failed to run variance analysis'); + } + }); + + document.getElementById('forecast-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const category = document.getElementById('forecast-category').value; + const forecastDays = document.getElementById('forecast-days').value; + const historicalDays = document.getElementById('historical-days').value; + const method = document.getElementById('forecast-method').value; + + try { + const res = await fetch('/api/budget-analytics/forecast/generate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify({ + budgetId: currentBudgetId, + category: category || undefined, + forecastDays: parseInt(forecastDays), + historicalDays: parseInt(historicalDays), + method + }) + }); + + const { data } = await res.json(); + + alert('Forecast generated successfully!'); + closeForecastModal(); + loadBudgetAnalytics(); + } catch (err) { + console.error('Failed to generate forecast:', err); + alert('Failed to generate forecast: ' + err.message); + } + }); +} diff --git a/routes/budget-analytics.js b/routes/budget-analytics.js new file mode 100644 index 00000000..d3e8568b --- /dev/null +++ b/routes/budget-analytics.js @@ -0,0 +1,273 @@ +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth'); +const varianceAnalysisService = require('../services/varianceAnalysisService'); +const spendForecaster = require('../services/spendForecaster'); +const budgetOptimizer = require('../services/budgetOptimizer'); +const BudgetVariance = require('../models/BudgetVariance'); +const SpendForecast = require('../models/SpendForecast'); + +/** + * Get Variance Dashboard + */ +router.get('/variance/dashboard', auth, async (req, res) => { + try { + const dashboard = await varianceAnalysisService.getVarianceDashboard(req.user._id); + res.json({ success: true, data: dashboard }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Run Variance Analysis + */ +router.post('/variance/analyze', auth, async (req, res) => { + try { + const { budgetId, startDate, endDate } = req.body; + + if (!budgetId || !startDate || !endDate) { + return res.status(400).json({ + success: false, + error: 'budgetId, startDate, and endDate are required' + }); + } + + const variance = await varianceAnalysisService.analyzeVariance( + req.user._id, + budgetId, + { startDate: new Date(startDate), endDate: new Date(endDate) } + ); + + res.json({ success: true, data: variance }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Variance Trend + */ +router.get('/variance/trend/:budgetId', auth, async (req, res) => { + try { + const { months } = req.query; + + const trend = await varianceAnalysisService.getVarianceTrend( + req.user._id, + req.params.budgetId, + parseInt(months) || 6 + ); + + res.json({ success: true, data: trend }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Variances + */ +router.get('/variances', auth, async (req, res) => { + try { + const { budgetId, status, limit } = req.query; + + const query = { userId: req.user._id }; + if (budgetId) query.budgetId = budgetId; + if (status) query.status = status; + + const variances = await BudgetVariance.find(query) + .sort({ analysisDate: -1 }) + .limit(parseInt(limit) || 50); + + res.json({ success: true, data: variances }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Specific Variance + */ +router.get('/variances/:id', auth, async (req, res) => { + try { + const variance = await BudgetVariance.findOne({ + _id: req.params.id, + userId: req.user._id + }); + + if (!variance) { + return res.status(404).json({ success: false, error: 'Variance not found' }); + } + + res.json({ success: true, data: variance }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Generate Spend Forecast + */ +router.post('/forecast/generate', auth, async (req, res) => { + try { + const { budgetId, category, forecastDays, method, historicalDays } = req.body; + + const forecast = await spendForecaster.generateForecast(req.user._id, { + budgetId, + category, + forecastDays: parseInt(forecastDays) || 30, + method: method || 'ensemble', + historicalDays: parseInt(historicalDays) || 90 + }); + + res.json({ success: true, data: forecast }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Forecasts + */ +router.get('/forecasts', auth, async (req, res) => { + try { + const { budgetId, category, status, limit } = req.query; + + const query = { userId: req.user._id }; + if (budgetId) query.budgetId = budgetId; + if (category) query.category = category; + if (status) query.status = status; + else query.status = 'active'; + + const forecasts = await SpendForecast.find(query) + .sort({ forecastDate: -1 }) + .limit(parseInt(limit) || 20); + + res.json({ success: true, data: forecasts }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Specific Forecast + */ +router.get('/forecasts/:id', auth, async (req, res) => { + try { + const forecast = await SpendForecast.findOne({ + _id: req.params.id, + userId: req.user._id + }); + + if (!forecast) { + return res.status(404).json({ success: false, error: 'Forecast not found' }); + } + + res.json({ success: true, data: forecast }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Calculate Forecast Accuracy + */ +router.post('/forecast/accuracy/:forecastId', auth, async (req, res) => { + try { + const accuracy = await spendForecaster.calculateAccuracy(req.params.forecastId); + + if (!accuracy) { + return res.status(404).json({ success: false, error: 'Forecast not found' }); + } + + res.json({ success: true, data: accuracy }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Budget Optimization Recommendations + */ +router.get('/optimize/:budgetId', auth, async (req, res) => { + try { + const recommendations = await budgetOptimizer.generateRecommendations( + req.user._id, + req.params.budgetId + ); + + res.json({ success: true, data: recommendations }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Apply Optimization Recommendations + */ +router.post('/optimize/:budgetId/apply', auth, async (req, res) => { + try { + const { recommendationIds } = req.body; + + if (!Array.isArray(recommendationIds)) { + return res.status(400).json({ + success: false, + error: 'recommendationIds must be an array' + }); + } + + const result = await budgetOptimizer.applyRecommendations( + req.user._id, + req.params.budgetId, + recommendationIds + ); + + res.json({ success: true, data: result }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Optimization History + */ +router.get('/optimize/:budgetId/history', auth, async (req, res) => { + try { + const history = await budgetOptimizer.getOptimizationHistory( + req.user._id, + req.params.budgetId + ); + + res.json({ success: true, data: history }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Critical Alerts + */ +router.get('/alerts/critical', auth, async (req, res) => { + try { + const variances = await BudgetVariance.find({ + userId: req.user._id, + 'alerts.severity': { $in: ['critical', 'high'] } + }).sort({ analysisDate: -1 }).limit(20); + + const alerts = variances.flatMap(v => + v.alerts + .filter(a => a.severity === 'critical' || a.severity === 'high') + .map(a => ({ + ...a.toObject(), + budgetName: v.budgetName, + analysisDate: v.analysisDate + })) + ); + + res.json({ success: true, data: alerts }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index af2494d3..1e15952e 100644 --- a/server.js +++ b/server.js @@ -280,6 +280,7 @@ app.use('/api/currency', require('./routes/currency')); app.use('/api/payroll', require('./routes/payroll')); app.use('/api/inventory', require('./routes/inventory')); app.use('/api/fx-revaluation', require('./routes/fx-revaluation')); +app.use('/api/budget-analytics', require('./routes/budget-analytics')); app.use('/api/splits', require('./routes/splits')); app.use('/api/workspaces', require('./routes/workspaces')); app.use('/api/tax', require('./routes/tax')); diff --git a/services/budgetOptimizer.js b/services/budgetOptimizer.js new file mode 100644 index 00000000..f5f8a3a3 --- /dev/null +++ b/services/budgetOptimizer.js @@ -0,0 +1,321 @@ +const Budget = require('../models/Budget'); +const BudgetVariance = require('../models/BudgetVariance'); +const SpendForecast = require('../models/SpendForecast'); + +class BudgetOptimizer { + /** + * Generate budget reallocation recommendations + */ + async generateRecommendations(userId, budgetId) { + const budget = await Budget.findOne({ _id: budgetId, userId }); + if (!budget) { + throw new Error('Budget not found'); + } + + // Get latest variance analysis + const latestVariance = await BudgetVariance.findOne({ + userId, + budgetId + }).sort({ analysisDate: -1 }); + + if (!latestVariance) { + throw new Error('No variance data available for optimization'); + } + + // Get forecasts for categories + const forecasts = await SpendForecast.find({ + userId, + budgetId, + status: 'active' + }); + + // Analyze current allocation efficiency + const efficiency = this.analyzeAllocationEfficiency(latestVariance); + + // Generate reallocation recommendations + const recommendations = this.generateReallocations( + budget, + latestVariance, + forecasts, + efficiency + ); + + // Calculate potential savings + const savings = this.calculatePotentialSavings(recommendations); + + return { + currentAllocation: this.getCurrentAllocation(budget), + efficiency, + recommendations, + potentialSavings: savings, + optimizationScore: this.calculateOptimizationScore(efficiency) + }; + } + + /** + * Analyze allocation efficiency + */ + analyzeAllocationEfficiency(variance) { + const efficiency = { + overallocated: [], + underutilized: [], + optimal: [], + criticalOverruns: [] + }; + + for (const item of variance.items) { + const utilizationRate = item.budgetedAmount > 0 + ? (item.actualAmount / item.budgetedAmount) * 100 + : 0; + + if (utilizationRate > 100) { + efficiency.overallocated.push({ + category: item.category, + utilizationRate, + excess: item.variance, + severity: utilizationRate > 150 ? 'critical' : 'high' + }); + } else if (utilizationRate < 50) { + efficiency.underutilized.push({ + category: item.category, + utilizationRate, + surplus: item.budgetedAmount - item.actualAmount, + potential: 'reallocation_candidate' + }); + } else { + efficiency.optimal.push({ + category: item.category, + utilizationRate + }); + } + + if (item.isAnomaly && item.varianceType === 'unfavorable') { + efficiency.criticalOverruns.push({ + category: item.category, + anomalyScore: item.anomalyScore, + variance: item.variance + }); + } + } + + return efficiency; + } + + /** + * Generate reallocation recommendations + */ + generateReallocations(budget, variance, forecasts, efficiency) { + const recommendations = []; + + // Strategy 1: Reallocate from underutilized to overallocated + for (const overalloc of efficiency.overallocated) { + const deficit = overalloc.excess; + + // Find underutilized categories with surplus + const donors = efficiency.underutilized + .filter(u => u.surplus >= deficit * 0.5) + .sort((a, b) => b.surplus - a.surplus); + + if (donors.length > 0) { + const donor = donors[0]; + const transferAmount = Math.min(deficit, donor.surplus * 0.7); + + recommendations.push({ + type: 'reallocation', + priority: overalloc.severity === 'critical' ? 'high' : 'medium', + action: 'transfer', + from: donor.category, + to: overalloc.category, + amount: transferAmount, + rationale: `${donor.category} is underutilized (${donor.utilizationRate.toFixed(1)}%) while ${overalloc.category} is overallocated (${overalloc.utilizationRate.toFixed(1)}%)`, + expectedImpact: `Reduce ${overalloc.category} overrun by ${((transferAmount / deficit) * 100).toFixed(1)}%` + }); + } else { + // No suitable donor - recommend budget increase + recommendations.push({ + type: 'increase', + priority: 'high', + action: 'increase_budget', + category: overalloc.category, + amount: deficit * 0.5, + rationale: `${overalloc.category} consistently exceeds budget with no reallocation options`, + expectedImpact: 'Prevent future overruns' + }); + } + } + + // Strategy 2: Reduce underutilized categories + for (const underutil of efficiency.underutilized) { + if (underutil.utilizationRate < 30 && underutil.surplus > 1000) { + recommendations.push({ + type: 'reduction', + priority: 'low', + action: 'reduce_budget', + category: underutil.category, + amount: underutil.surplus * 0.5, + rationale: `${underutil.category} is significantly underutilized (${underutil.utilizationRate.toFixed(1)}%)`, + expectedImpact: 'Free up budget for critical categories' + }); + } + } + + // Strategy 3: Forecast-based adjustments + for (const forecast of forecasts) { + const totalPredicted = forecast.summary.totalPredicted; + const budgetCat = budget.categories?.find(c => c.category === forecast.category); + + if (budgetCat && totalPredicted > budgetCat.limit * 1.2) { + recommendations.push({ + type: 'forecast_adjustment', + priority: 'medium', + action: 'increase_budget', + category: forecast.category, + amount: totalPredicted - budgetCat.limit, + rationale: `Forecast predicts ${forecast.category} will exceed budget by ${((totalPredicted / budgetCat.limit - 1) * 100).toFixed(1)}%`, + expectedImpact: 'Prevent predicted overrun', + forecastConfidence: forecast.summary.trendStrength || 0.7 + }); + } + } + + // Sort by priority + const priorityOrder = { high: 1, medium: 2, low: 3 }; + recommendations.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); + + return recommendations; + } + + /** + * Calculate potential savings + */ + calculatePotentialSavings(recommendations) { + let totalSavings = 0; + let reallocations = 0; + let reductions = 0; + + for (const rec of recommendations) { + if (rec.type === 'reduction') { + totalSavings += rec.amount; + reductions++; + } else if (rec.type === 'reallocation') { + reallocations++; + } + } + + return { + totalSavings, + reallocations, + reductions, + averageSavingsPerReduction: reductions > 0 ? totalSavings / reductions : 0 + }; + } + + /** + * Calculate optimization score + */ + calculateOptimizationScore(efficiency) { + const total = efficiency.overallocated.length + + efficiency.underutilized.length + + efficiency.optimal.length; + + if (total === 0) return 0; + + const optimalRatio = efficiency.optimal.length / total; + const criticalPenalty = efficiency.criticalOverruns.length * 0.1; + + const score = Math.max(0, Math.min(100, (optimalRatio * 100) - (criticalPenalty * 10))); + + return score; + } + + /** + * Get current allocation + */ + getCurrentAllocation(budget) { + if (!budget.categories || budget.categories.length === 0) { + return { + total: budget.amount, + categories: [] + }; + } + + return { + total: budget.amount, + categories: budget.categories.map(c => ({ + category: c.category, + allocated: c.limit, + percentage: budget.amount > 0 ? (c.limit / budget.amount) * 100 : 0 + })) + }; + } + + /** + * Apply recommendations + */ + async applyRecommendations(userId, budgetId, recommendationIds) { + const budget = await Budget.findOne({ _id: budgetId, userId }); + if (!budget) { + throw new Error('Budget not found'); + } + + // Get recommendations + const optimization = await this.generateRecommendations(userId, budgetId); + const toApply = optimization.recommendations.filter((_, i) => recommendationIds.includes(i)); + + // Apply each recommendation + for (const rec of toApply) { + if (rec.type === 'reallocation') { + // Transfer budget between categories + const fromCat = budget.categories.find(c => c.category === rec.from); + const toCat = budget.categories.find(c => c.category === rec.to); + + if (fromCat && toCat) { + fromCat.limit -= rec.amount; + toCat.limit += rec.amount; + } + } else if (rec.type === 'increase' || rec.type === 'forecast_adjustment') { + // Increase category budget + const cat = budget.categories.find(c => c.category === rec.category); + if (cat) { + cat.limit += rec.amount; + budget.amount += rec.amount; + } + } else if (rec.type === 'reduction') { + // Reduce category budget + const cat = budget.categories.find(c => c.category === rec.category); + if (cat) { + cat.limit -= rec.amount; + budget.amount -= rec.amount; + } + } + } + + await budget.save(); + + return { + success: true, + appliedCount: toApply.length, + updatedBudget: budget + }; + } + + /** + * Get optimization history + */ + async getOptimizationHistory(userId, budgetId) { + const variances = await BudgetVariance.find({ + userId, + budgetId + }).sort({ analysisDate: -1 }).limit(12); + + return variances.map(v => ({ + date: v.analysisDate, + utilizationRate: v.summary.utilizationRate, + anomalies: v.summary.anomaliesDetected, + status: v.status, + alerts: v.alerts.length + })); + } +} + +module.exports = new BudgetOptimizer(); diff --git a/services/spendForecaster.js b/services/spendForecaster.js new file mode 100644 index 00000000..51d746e7 --- /dev/null +++ b/services/spendForecaster.js @@ -0,0 +1,433 @@ +const SpendForecast = require('../models/SpendForecast'); +const Transaction = require('../models/Transaction'); +const Expense = require('../models/Expense'); +const Budget = require('../models/Budget'); + +class SpendForecaster { + /** + * Generate spend forecast for a budget/category + */ + async generateForecast(userId, options = {}) { + const { + budgetId, + category, + forecastDays = 30, + method = 'ensemble', + historicalDays = 90 + } = options; + + const forecastId = `FC-${Date.now()}`; + + // Get historical data + const historicalData = await this.getHistoricalData(userId, { + budgetId, + category, + days: historicalDays + }); + + if (historicalData.length < 7) { + throw new Error('Insufficient historical data for forecasting (minimum 7 days required)'); + } + + // Generate forecast using selected method + let dataPoints; + + switch (method) { + case 'linear': + dataPoints = this.linearForecast(historicalData, forecastDays); + break; + case 'exponential': + dataPoints = this.exponentialForecast(historicalData, forecastDays); + break; + case 'seasonal': + dataPoints = this.seasonalForecast(historicalData, forecastDays); + break; + case 'moving_average': + dataPoints = this.movingAverageForecast(historicalData, forecastDays); + break; + case 'ensemble': + default: + dataPoints = this.ensembleForecast(historicalData, forecastDays); + break; + } + + // Add confidence intervals + const dataPointsWithCI = this.addConfidenceIntervals(dataPoints, historicalData); + + // Detect budget overrun alerts + const alerts = await this.detectBudgetAlerts(userId, budgetId, category, dataPointsWithCI); + + // Create forecast record + const forecast = new SpendForecast({ + userId, + budgetId, + category, + forecastId, + forecastDate: new Date(), + forecastPeriod: { + startDate: new Date(), + endDate: new Date(Date.now() + forecastDays * 24 * 60 * 60 * 1000), + periodType: forecastDays <= 7 ? 'daily' : forecastDays <= 31 ? 'weekly' : 'monthly' + }, + historicalPeriod: { + startDate: new Date(Date.now() - historicalDays * 24 * 60 * 60 * 1000), + endDate: new Date(), + dataPoints: historicalData.length + }, + forecastMethod: method, + dataPoints: dataPointsWithCI, + alerts + }); + + await forecast.save(); + + return forecast; + } + + /** + * Get historical spending data + */ + async getHistoricalData(userId, options) { + const { budgetId, category, days } = options; + const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const endDate = new Date(); + + const query = { + userId, + date: { $gte: startDate, $lte: endDate } + }; + + if (category) { + query.category = category; + } + + // Get transactions + const transactions = await Transaction.find({ + ...query, + type: 'expense' + }).sort({ date: 1 }); + + // Get expenses + const expenses = await Expense.find(query).sort({ date: 1 }); + + // Aggregate by day + const dailySpending = {}; + + for (const txn of transactions) { + const dateKey = txn.date.toISOString().split('T')[0]; + dailySpending[dateKey] = (dailySpending[dateKey] || 0) + Math.abs(txn.amount); + } + + for (const exp of expenses) { + const dateKey = exp.date.toISOString().split('T')[0]; + dailySpending[dateKey] = (dailySpending[dateKey] || 0) + Math.abs(exp.amount); + } + + // Convert to array + const data = []; + for (let i = 0; i < days; i++) { + const date = new Date(startDate); + date.setDate(date.getDate() + i); + const dateKey = date.toISOString().split('T')[0]; + + data.push({ + date, + amount: dailySpending[dateKey] || 0 + }); + } + + return data; + } + + /** + * Linear regression forecast + */ + linearForecast(historicalData, forecastDays) { + const n = historicalData.length; + const x = historicalData.map((_, i) => i); + const y = historicalData.map(d => d.amount); + + // Calculate linear regression coefficients + const sumX = x.reduce((a, b) => a + b, 0); + const sumY = y.reduce((a, b) => a + b, 0); + const sumXY = x.reduce((sum, xi, i) => sum + xi * y[i], 0); + const sumX2 = x.reduce((sum, xi) => sum + xi * xi, 0); + + const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); + const intercept = (sumY - slope * sumX) / n; + + // Generate forecast + const forecast = []; + for (let i = 0; i < forecastDays; i++) { + const date = new Date(); + date.setDate(date.getDate() + i); + + const predictedAmount = Math.max(0, slope * (n + i) + intercept); + + forecast.push({ + date, + predictedAmount + }); + } + + return forecast; + } + + /** + * Exponential smoothing forecast + */ + exponentialForecast(historicalData, forecastDays) { + const alpha = 0.3; // Smoothing factor + let smoothed = historicalData[0].amount; + + // Calculate smoothed values + for (let i = 1; i < historicalData.length; i++) { + smoothed = alpha * historicalData[i].amount + (1 - alpha) * smoothed; + } + + // Generate forecast + const forecast = []; + for (let i = 0; i < forecastDays; i++) { + const date = new Date(); + date.setDate(date.getDate() + i); + + forecast.push({ + date, + predictedAmount: Math.max(0, smoothed) + }); + } + + return forecast; + } + + /** + * Seasonal decomposition forecast + */ + seasonalForecast(historicalData, forecastDays) { + const seasonalPeriod = 7; // Weekly seasonality + + // Calculate seasonal indices + const seasonalIndices = new Array(seasonalPeriod).fill(0); + const counts = new Array(seasonalPeriod).fill(0); + + for (let i = 0; i < historicalData.length; i++) { + const dayOfWeek = i % seasonalPeriod; + seasonalIndices[dayOfWeek] += historicalData[i].amount; + counts[dayOfWeek]++; + } + + // Average seasonal indices + for (let i = 0; i < seasonalPeriod; i++) { + seasonalIndices[i] = counts[i] > 0 ? seasonalIndices[i] / counts[i] : 0; + } + + // Calculate trend + const avgAmount = historicalData.reduce((sum, d) => sum + d.amount, 0) / historicalData.length; + + // Generate forecast + const forecast = []; + for (let i = 0; i < forecastDays; i++) { + const date = new Date(); + date.setDate(date.getDate() + i); + const dayOfWeek = i % seasonalPeriod; + + const predictedAmount = Math.max(0, seasonalIndices[dayOfWeek]); + + forecast.push({ + date, + predictedAmount + }); + } + + return forecast; + } + + /** + * Moving average forecast + */ + movingAverageForecast(historicalData, forecastDays) { + const windowSize = Math.min(7, historicalData.length); + + // Calculate moving average + const recentData = historicalData.slice(-windowSize); + const avgAmount = recentData.reduce((sum, d) => sum + d.amount, 0) / windowSize; + + // Generate forecast + const forecast = []; + for (let i = 0; i < forecastDays; i++) { + const date = new Date(); + date.setDate(date.getDate() + i); + + forecast.push({ + date, + predictedAmount: Math.max(0, avgAmount) + }); + } + + return forecast; + } + + /** + * Ensemble forecast (combines multiple methods) + */ + ensembleForecast(historicalData, forecastDays) { + const linear = this.linearForecast(historicalData, forecastDays); + const exponential = this.exponentialForecast(historicalData, forecastDays); + const seasonal = this.seasonalForecast(historicalData, forecastDays); + const movingAvg = this.movingAverageForecast(historicalData, forecastDays); + + // Weighted average of all methods + const weights = { + linear: 0.25, + exponential: 0.25, + seasonal: 0.3, + movingAvg: 0.2 + }; + + const forecast = []; + for (let i = 0; i < forecastDays; i++) { + const date = new Date(); + date.setDate(date.getDate() + i); + + const predictedAmount = + linear[i].predictedAmount * weights.linear + + exponential[i].predictedAmount * weights.exponential + + seasonal[i].predictedAmount * weights.seasonal + + movingAvg[i].predictedAmount * weights.movingAvg; + + forecast.push({ + date, + predictedAmount: Math.max(0, predictedAmount) + }); + } + + return forecast; + } + + /** + * Add confidence intervals to forecast + */ + addConfidenceIntervals(dataPoints, historicalData) { + // Calculate historical volatility + const amounts = historicalData.map(d => d.amount); + const mean = amounts.reduce((a, b) => a + b, 0) / amounts.length; + const variance = amounts.reduce((sum, amt) => sum + Math.pow(amt - mean, 2), 0) / amounts.length; + const stdDev = Math.sqrt(variance); + + // Add confidence intervals (95% confidence) + const zScore = 1.96; // 95% confidence + + return dataPoints.map(dp => ({ + ...dp, + lowerBound: Math.max(0, dp.predictedAmount - zScore * stdDev), + upperBound: dp.predictedAmount + zScore * stdDev, + confidence: 95 + })); + } + + /** + * Detect budget overrun alerts + */ + async detectBudgetAlerts(userId, budgetId, category, dataPoints) { + const alerts = []; + + if (!budgetId) return alerts; + + const budget = await Budget.findOne({ _id: budgetId, userId }); + if (!budget) return alerts; + + let budgetLimit = budget.amount; + + // If category specified, get category limit + if (category && budget.categories) { + const budgetCat = budget.categories.find(c => c.category === category); + if (budgetCat) { + budgetLimit = budgetCat.limit; + } + } + + // Calculate cumulative spending + let cumulative = 0; + for (const dp of dataPoints) { + cumulative += dp.predictedAmount; + + if (cumulative > budgetLimit) { + alerts.push({ + type: 'budget_overrun', + severity: 'high', + message: `Predicted to exceed budget by ${new Date(dp.date).toLocaleDateString()}`, + date: dp.date, + amount: cumulative - budgetLimit + }); + break; + } + } + + // Detect unusual spikes + const avgPredicted = dataPoints.reduce((sum, dp) => sum + dp.predictedAmount, 0) / dataPoints.length; + for (const dp of dataPoints) { + if (dp.predictedAmount > avgPredicted * 2) { + alerts.push({ + type: 'unusual_spike', + severity: 'medium', + message: `Unusual spending spike predicted on ${new Date(dp.date).toLocaleDateString()}`, + date: dp.date, + amount: dp.predictedAmount + }); + } + } + + return alerts; + } + + /** + * Calculate forecast accuracy + */ + async calculateAccuracy(forecastId) { + const forecast = await SpendForecast.findOne({ forecastId }); + if (!forecast) return null; + + // Get actual data for the forecast period + const actualData = await this.getHistoricalData(forecast.userId, { + budgetId: forecast.budgetId, + category: forecast.category, + days: forecast.dataPoints.length + }); + + // Calculate error metrics + let mape = 0; + let mae = 0; + let mse = 0; + let count = 0; + + for (let i = 0; i < Math.min(forecast.dataPoints.length, actualData.length); i++) { + const predicted = forecast.dataPoints[i].predictedAmount; + const actual = actualData[i].amount; + + if (actual > 0) { + mape += Math.abs((actual - predicted) / actual); + } + mae += Math.abs(actual - predicted); + mse += Math.pow(actual - predicted, 2); + count++; + } + + if (count > 0) { + mape = (mape / count) * 100; + mae = mae / count; + const rmse = Math.sqrt(mse / count); + + forecast.accuracy = { + mape, + mae, + rmse + }; + + await forecast.save(); + } + + return forecast.accuracy; + } +} + +module.exports = new SpendForecaster(); diff --git a/services/varianceAnalysisService.js b/services/varianceAnalysisService.js new file mode 100644 index 00000000..9a5242e9 --- /dev/null +++ b/services/varianceAnalysisService.js @@ -0,0 +1,402 @@ +const BudgetVariance = require('../models/BudgetVariance'); +const Budget = require('../models/Budget'); +const Transaction = require('../models/Transaction'); +const Expense = require('../models/Expense'); + +class VarianceAnalysisService { + /** + * Run comprehensive variance analysis for a budget + */ + async analyzeVariance(userId, budgetId, period) { + const budget = await Budget.findOne({ _id: budgetId, userId }); + + if (!budget) { + throw new Error('Budget not found'); + } + + const { startDate, endDate } = period; + + // Get actual spending for the period + const actualSpending = await this.getActualSpending(userId, startDate, endDate, budget); + + // Calculate variances for each category + const varianceItems = await this.calculateCategoryVariances(budget, actualSpending); + + // Detect anomalies + const itemsWithAnomalies = await this.detectAnomalies(varianceItems, userId); + + // Generate alerts + const alerts = this.generateAlerts(itemsWithAnomalies, budget); + + // Calculate trends + const trends = await this.calculateTrends(userId, budgetId, actualSpending); + + // Create variance record + const variance = new BudgetVariance({ + userId, + budgetId, + budgetName: budget.name, + analysisDate: new Date(), + period: { + startDate, + endDate, + periodType: this.determinePeriodType(startDate, endDate) + }, + items: itemsWithAnomalies, + alerts, + trends + }); + + await variance.save(); + + return variance; + } + + /** + * Get actual spending for period + */ + async getActualSpending(userId, startDate, endDate, budget) { + const spending = {}; + + // Get transactions + const transactions = await Transaction.find({ + userId, + date: { $gte: startDate, $lte: endDate }, + type: 'expense' + }); + + // Get expenses + const expenses = await Expense.find({ + userId, + date: { $gte: startDate, $lte: endDate } + }); + + // Aggregate by category + for (const txn of transactions) { + const category = txn.category || 'Uncategorized'; + spending[category] = (spending[category] || 0) + Math.abs(txn.amount); + } + + for (const exp of expenses) { + const category = exp.category || 'Uncategorized'; + spending[category] = (spending[category] || 0) + Math.abs(exp.amount); + } + + return spending; + } + + /** + * Calculate variances for each category + */ + async calculateCategoryVariances(budget, actualSpending) { + const items = []; + + // Process budget categories + if (budget.categories && budget.categories.length > 0) { + for (const budgetCat of budget.categories) { + const category = budgetCat.category; + const budgetedAmount = budgetCat.limit || 0; + const actualAmount = actualSpending[category] || 0; + const variance = actualAmount - budgetedAmount; + const variancePercentage = budgetedAmount > 0 ? (variance / budgetedAmount) * 100 : 0; + + items.push({ + category, + subcategory: budgetCat.subcategory, + budgetedAmount, + actualAmount, + variance, + variancePercentage, + varianceType: this.determineVarianceType(variance, budgetedAmount), + transactionCount: await this.getTransactionCount(category) + }); + } + } else { + // If no categories, use overall budget + const totalActual = Object.values(actualSpending).reduce((sum, amt) => sum + amt, 0); + const budgetedAmount = budget.amount || 0; + const variance = totalActual - budgetedAmount; + const variancePercentage = budgetedAmount > 0 ? (variance / budgetedAmount) * 100 : 0; + + items.push({ + category: 'Overall', + budgetedAmount, + actualAmount: totalActual, + variance, + variancePercentage, + varianceType: this.determineVarianceType(variance, budgetedAmount), + transactionCount: 0 + }); + } + + return items; + } + + /** + * Detect anomalies in variance items + */ + async detectAnomalies(items, userId) { + const itemsWithScores = []; + + for (const item of items) { + const anomalyScore = await this.calculateAnomalyScore(item, userId); + const isAnomaly = anomalyScore > 70; // Threshold for anomaly + + itemsWithScores.push({ + ...item, + anomalyScore, + isAnomaly + }); + } + + return itemsWithScores; + } + + /** + * Calculate anomaly score for an item + */ + async calculateAnomalyScore(item, userId) { + let score = 0; + + // Factor 1: Variance percentage (0-40 points) + const absVariancePercent = Math.abs(item.variancePercentage); + if (absVariancePercent > 100) { + score += 40; + } else if (absVariancePercent > 50) { + score += 30; + } else if (absVariancePercent > 25) { + score += 20; + } else if (absVariancePercent > 10) { + score += 10; + } + + // Factor 2: Unfavorable variance (0-30 points) + if (item.varianceType === 'unfavorable') { + if (item.variance > item.budgetedAmount * 0.5) { + score += 30; + } else if (item.variance > item.budgetedAmount * 0.25) { + score += 20; + } else { + score += 10; + } + } + + // Factor 3: Historical comparison (0-30 points) + const historicalScore = await this.getHistoricalAnomalyScore(item, userId); + score += historicalScore; + + return Math.min(score, 100); + } + + /** + * Get historical anomaly score + */ + async getHistoricalAnomalyScore(item, userId) { + // Get historical variances for this category + const historicalVariances = await BudgetVariance.find({ + userId, + 'items.category': item.category + }).sort({ analysisDate: -1 }).limit(6); + + if (historicalVariances.length < 3) { + return 0; // Not enough data + } + + // Calculate average historical variance percentage + const historicalPercentages = historicalVariances + .map(v => v.items.find(i => i.category === item.category)) + .filter(i => i) + .map(i => i.variancePercentage); + + const avgHistorical = historicalPercentages.reduce((a, b) => a + b, 0) / historicalPercentages.length; + const stdDev = this.calculateStdDev(historicalPercentages); + + // Calculate Z-score + const zScore = stdDev > 0 ? Math.abs((item.variancePercentage - avgHistorical) / stdDev) : 0; + + // Convert Z-score to points (0-30) + if (zScore > 3) return 30; + if (zScore > 2) return 20; + if (zScore > 1) return 10; + return 0; + } + + /** + * Calculate standard deviation + */ + calculateStdDev(values) { + const avg = values.reduce((a, b) => a + b, 0) / values.length; + const squareDiffs = values.map(value => Math.pow(value - avg, 2)); + const avgSquareDiff = squareDiffs.reduce((a, b) => a + b, 0) / squareDiffs.length; + return Math.sqrt(avgSquareDiff); + } + + /** + * Generate alerts based on variance analysis + */ + generateAlerts(items, budget) { + const alerts = []; + + for (const item of items) { + // Critical overrun alert + if (item.variancePercentage > 100) { + alerts.push({ + severity: 'critical', + category: item.category, + message: `${item.category} has exceeded budget by ${item.variancePercentage.toFixed(1)}%`, + recommendedAction: 'Immediate review required. Consider reallocating funds or adjusting spending.' + }); + } + // High variance alert + else if (item.variancePercentage > 50) { + alerts.push({ + severity: 'high', + category: item.category, + message: `${item.category} is ${item.variancePercentage.toFixed(1)}% over budget`, + recommendedAction: 'Review spending patterns and implement cost controls.' + }); + } + // Warning alert + else if (item.variancePercentage > 25) { + alerts.push({ + severity: 'medium', + category: item.category, + message: `${item.category} is trending ${item.variancePercentage.toFixed(1)}% over budget`, + recommendedAction: 'Monitor closely and consider preventive measures.' + }); + } + + // Anomaly alert + if (item.isAnomaly) { + alerts.push({ + severity: item.anomalyScore > 85 ? 'high' : 'medium', + category: item.category, + message: `Unusual spending pattern detected in ${item.category} (Anomaly Score: ${item.anomalyScore.toFixed(0)})`, + recommendedAction: 'Investigate recent transactions for irregularities.' + }); + } + } + + return alerts; + } + + /** + * Calculate spending trends + */ + async calculateTrends(userId, budgetId, currentSpending) { + // Get previous period's spending + const previousVariances = await BudgetVariance.find({ + userId, + budgetId + }).sort({ analysisDate: -1 }).limit(3); + + if (previousVariances.length < 2) { + return { + isIncreasing: false, + trendPercentage: 0, + projectedOverrun: 0, + daysUntilOverrun: 0 + }; + } + + const currentTotal = Object.values(currentSpending).reduce((sum, amt) => sum + amt, 0); + const previousTotal = previousVariances[0].summary.totalActual; + + const trendPercentage = previousTotal > 0 ? ((currentTotal - previousTotal) / previousTotal) * 100 : 0; + const isIncreasing = trendPercentage > 5; + + return { + isIncreasing, + trendPercentage, + projectedOverrun: 0, // Calculated separately + daysUntilOverrun: 0 + }; + } + + /** + * Determine variance type + */ + determineVarianceType(variance, budgetedAmount) { + if (Math.abs(variance) < budgetedAmount * 0.05) { + return 'neutral'; + } + return variance > 0 ? 'unfavorable' : 'favorable'; + } + + /** + * Determine period type + */ + determinePeriodType(startDate, endDate) { + const days = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)); + + if (days <= 1) return 'daily'; + if (days <= 7) return 'weekly'; + if (days <= 31) return 'monthly'; + if (days <= 92) return 'quarterly'; + return 'yearly'; + } + + /** + * Get transaction count for category + */ + async getTransactionCount(category) { + // Simplified - would query actual transactions + return 0; + } + + /** + * Get variance dashboard + */ + async getVarianceDashboard(userId) { + // Get latest variances + const latestVariances = await BudgetVariance.find({ userId }) + .sort({ analysisDate: -1 }) + .limit(10); + + // Get critical alerts + const criticalAlerts = latestVariances + .flatMap(v => v.alerts) + .filter(a => a.severity === 'critical' || a.severity === 'high') + .slice(0, 10); + + // Calculate summary statistics + const totalBudgets = await Budget.countDocuments({ userId }); + const budgetsOverBudget = latestVariances.filter(v => v.status === 'exceeded' || v.status === 'critical').length; + const totalAnomalies = latestVariances.reduce((sum, v) => sum + v.summary.anomaliesDetected, 0); + + return { + summary: { + totalBudgets, + budgetsOverBudget, + totalAnomalies, + criticalAlerts: criticalAlerts.length + }, + latestVariances, + criticalAlerts + }; + } + + /** + * Get variance trend over time + */ + async getVarianceTrend(userId, budgetId, months = 6) { + const startDate = new Date(); + startDate.setMonth(startDate.getMonth() - months); + + const variances = await BudgetVariance.find({ + userId, + budgetId, + analysisDate: { $gte: startDate } + }).sort({ analysisDate: 1 }); + + return variances.map(v => ({ + date: v.analysisDate, + utilizationRate: v.summary.utilizationRate, + variancePercentage: v.summary.variancePercentage, + status: v.status, + anomalies: v.summary.anomaliesDetected + })); + } +} + +module.exports = new VarianceAnalysisService();