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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0%
+
+
+
+
+
+
+
+
+
0
+
+
+
+
+
+
+
+
+
₹0
+
+
+
+
+
+
+
+
+
0
+
+
+
+
+
+
+
+
+
+
+
Loading variance data...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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.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.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.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();