diff --git a/models/BudgetForecast.js b/models/BudgetForecast.js deleted file mode 100644 index 7e1dff84..00000000 --- a/models/BudgetForecast.js +++ /dev/null @@ -1,248 +0,0 @@ -const mongoose = require('mongoose'); - -const budgetForecastSchema = new mongoose.Schema({ - user: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: true, - index: true - }, - forecast_period: { - start_date: { - type: Date, - required: true - }, - end_date: { - type: Date, - required: true - }, - period_type: { - type: String, - enum: ['weekly', 'monthly', 'quarterly', 'yearly'], - required: true - } - }, - category: { - type: String, - index: true - }, - predictions: [{ - date: Date, - predicted_amount: Number, - confidence_lower: Number, - confidence_upper: Number, - confidence_level: { - type: Number, - default: 95 - } - }], - aggregate_forecast: { - total_predicted: Number, - average_monthly: Number, - trend: { - type: String, - enum: ['increasing', 'decreasing', 'stable', 'volatile'] - }, - trend_percentage: Number - }, - seasonal_factors: [{ - month: Number, - factor: Number, - event: String - }], - model_metadata: { - algorithm: { - type: String, - enum: ['linear_regression', 'moving_average', 'exponential_smoothing', 'arima', 'prophet'] - }, - accuracy_score: Number, - rmse: Number, - mae: Number, - training_data_points: Number, - last_trained: Date - }, - comparison: { - vs_last_period: { - amount_change: Number, - percentage_change: Number - }, - vs_budget: { - budget_amount: Number, - forecast_vs_budget: Number, - will_exceed: Boolean - } - }, - alerts: [{ - alert_type: { - type: String, - enum: ['forecast_exceeds_budget', 'unusual_spike', 'trend_reversal', 'seasonal_peak'] - }, - severity: { - type: String, - enum: ['low', 'medium', 'high', 'critical'] - }, - message: String, - triggered_at: { - type: Date, - default: Date.now - }, - acknowledged: { - type: Boolean, - default: false - } - }], - recommendations: [{ - recommendation_type: { - type: String, - enum: ['increase_budget', 'decrease_budget', 'adjust_spending', 'save_more', 'review_category'] - }, - title: String, - description: String, - impact_amount: Number, - priority: { - type: String, - enum: ['low', 'medium', 'high'] - } - }], - accuracy_tracking: [{ - prediction_date: Date, - predicted_amount: Number, - actual_amount: Number, - error_percentage: Number, - recorded_at: Date - }], - status: { - type: String, - enum: ['active', 'archived', 'expired'], - default: 'active' - } -}, { - timestamps: true -}); - -// Indexes -budgetForecastSchema.index({ user: 1, 'forecast_period.start_date': 1, category: 1 }); -budgetForecastSchema.index({ user: 1, status: 1 }); -budgetForecastSchema.index({ 'forecast_period.end_date': 1 }); - -// Virtuals -budgetForecastSchema.virtual('is_expired').get(function() { - return new Date() > this.forecast_period.end_date; -}); - -budgetForecastSchema.virtual('days_remaining').get(function() { - const diff = this.forecast_period.end_date - new Date(); - return Math.ceil(diff / (1000 * 60 * 60 * 24)); -}); - -budgetForecastSchema.virtual('forecast_accuracy').get(function() { - if (this.accuracy_tracking.length === 0) return null; - - const totalError = this.accuracy_tracking.reduce((sum, track) => - sum + Math.abs(track.error_percentage), 0 - ); - - return 100 - (totalError / this.accuracy_tracking.length); -}); - -// Methods -budgetForecastSchema.methods.addPrediction = function(date, amount, confidenceLower, confidenceUpper) { - this.predictions.push({ - date, - predicted_amount: amount, - confidence_lower: confidenceLower, - confidence_upper: confidenceUpper - }); - return this.save(); -}; - -budgetForecastSchema.methods.addAlert = function(alertType, severity, message) { - this.alerts.push({ - alert_type: alertType, - severity, - message, - triggered_at: new Date() - }); - return this.save(); -}; - -budgetForecastSchema.methods.acknowledgeAlert = function(alertId) { - const alert = this.alerts.id(alertId); - if (alert) { - alert.acknowledged = true; - } - return this.save(); -}; - -budgetForecastSchema.methods.trackAccuracy = function(predictionDate, predictedAmount, actualAmount) { - const errorPercentage = ((actualAmount - predictedAmount) / predictedAmount) * 100; - - this.accuracy_tracking.push({ - prediction_date: predictionDate, - predicted_amount: predictedAmount, - actual_amount: actualAmount, - error_percentage: errorPercentage, - recorded_at: new Date() - }); - - // Update model accuracy score - if (this.model_metadata) { - const recentTracking = this.accuracy_tracking.slice(-10); - const avgError = recentTracking.reduce((sum, t) => - sum + Math.abs(t.error_percentage), 0) / recentTracking.length; - - this.model_metadata.accuracy_score = 100 - avgError; - } - - return this.save(); -}; - -budgetForecastSchema.methods.addRecommendation = function(type, title, description, impactAmount, priority) { - this.recommendations.push({ - recommendation_type: type, - title, - description, - impact_amount: impactAmount, - priority - }); - return this.save(); -}; - -// Static methods -budgetForecastSchema.statics.getUserForecasts = function(userId, status = 'active') { - return this.find({ user: userId, status }) - .sort({ 'forecast_period.start_date': -1 }); -}; - -budgetForecastSchema.statics.getCurrentForecasts = function(userId) { - const now = new Date(); - return this.find({ - user: userId, - status: 'active', - 'forecast_period.start_date': { $lte: now }, - 'forecast_period.end_date': { $gte: now } - }); -}; - -budgetForecastSchema.statics.getUnacknowledgedAlerts = function(userId) { - return this.find({ - user: userId, - status: 'active', - 'alerts': { - $elemMatch: { - acknowledged: false, - severity: { $in: ['high', 'critical'] } - } - } - }); -}; - -// Auto-expire old forecasts -budgetForecastSchema.pre('save', function(next) { - if (this.is_expired && this.status === 'active') { - this.status = 'expired'; - } - next(); -}); - -module.exports = mongoose.model('BudgetForecast', budgetForecastSchema); diff --git a/models/FXRevaluation.js b/models/FXRevaluation.js new file mode 100644 index 00000000..54fc2378 --- /dev/null +++ b/models/FXRevaluation.js @@ -0,0 +1,142 @@ +const mongoose = require('mongoose'); + +/** + * FXRevaluation Model + * Tracks foreign exchange revaluation history for compliance and audit + */ +const revaluationItemSchema = new mongoose.Schema({ + accountId: { + type: mongoose.Schema.Types.ObjectId, + refPath: 'accountType' + }, + accountType: { + type: String, + enum: ['Account', 'DebtAccount', 'TreasuryVault'] + }, + accountName: String, + currency: { + type: String, + required: true + }, + originalAmount: { + type: Number, + required: true + }, + originalRate: { + type: Number, + required: true + }, + newRate: { + type: Number, + required: true + }, + baseAmount: { + type: Number, + required: true + }, + revaluedAmount: { + type: Number, + required: true + }, + gainLoss: { + type: Number, + required: true + }, + gainLossType: { + type: String, + enum: ['gain', 'loss'], + required: true + } +}, { _id: false }); + +const fxRevaluationSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + revaluationId: { + type: String, + unique: true, + required: true + }, + revaluationDate: { + type: Date, + required: true, + default: Date.now + }, + baseCurrency: { + type: String, + required: true, + default: 'INR' + }, + revaluationType: { + type: String, + enum: ['manual', 'automated', 'scheduled'], + default: 'automated' + }, + items: [revaluationItemSchema], + summary: { + totalAccounts: { + type: Number, + default: 0 + }, + totalGain: { + type: Number, + default: 0 + }, + totalLoss: { + type: Number, + default: 0 + }, + netGainLoss: { + type: Number, + default: 0 + }, + currenciesRevalued: [String] + }, + exchangeRates: [{ + currency: String, + rate: Number, + source: String, + timestamp: Date + }], + journalEntryId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Transaction' + }, + status: { + type: String, + enum: ['pending', 'completed', 'failed', 'reversed'], + default: 'pending' + }, + performedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + notes: String +}, { + timestamps: true +}); + +// Pre-save hook to calculate summary +fxRevaluationSchema.pre('save', function (next) { + this.summary.totalAccounts = this.items.length; + this.summary.totalGain = this.items + .filter(i => i.gainLossType === 'gain') + .reduce((sum, i) => sum + Math.abs(i.gainLoss), 0); + this.summary.totalLoss = this.items + .filter(i => i.gainLossType === 'loss') + .reduce((sum, i) => sum + Math.abs(i.gainLoss), 0); + this.summary.netGainLoss = this.summary.totalGain - this.summary.totalLoss; + this.summary.currenciesRevalued = [...new Set(this.items.map(i => i.currency))]; + + next(); +}); + +// Indexes +fxRevaluationSchema.index({ userId: 1, revaluationDate: -1 }); +fxRevaluationSchema.index({ status: 1, revaluationType: 1 }); + +module.exports = mongoose.model('FXRevaluation', fxRevaluationSchema); diff --git a/models/UnrealizedGainLoss.js b/models/UnrealizedGainLoss.js new file mode 100644 index 00000000..0e63452e --- /dev/null +++ b/models/UnrealizedGainLoss.js @@ -0,0 +1,123 @@ +const mongoose = require('mongoose'); + +/** + * UnrealizedGainLoss Model + * Stores unrealized FX positions for foreign currency assets and liabilities + */ +const unrealizedGainLossSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + accountId: { + type: mongoose.Schema.Types.ObjectId, + refPath: 'accountType', + required: true + }, + accountType: { + type: String, + enum: ['Account', 'DebtAccount', 'TreasuryVault', 'Transaction'], + required: true + }, + accountName: String, + currency: { + type: String, + required: true + }, + baseCurrency: { + type: String, + default: 'INR' + }, + originalAmount: { + type: Number, + required: true + }, + originalRate: { + type: Number, + required: true + }, + currentRate: { + type: Number, + required: true + }, + baseAmountOriginal: { + type: Number, + required: true + }, + baseAmountCurrent: { + type: Number, + required: true + }, + unrealizedGainLoss: { + type: Number, + required: true + }, + gainLossType: { + type: String, + enum: ['gain', 'loss', 'neutral'], + required: true + }, + gainLossPercentage: { + type: Number, + default: 0 + }, + asOfDate: { + type: Date, + required: true, + default: Date.now + }, + lastRevaluationDate: Date, + isRealized: { + type: Boolean, + default: false + }, + realizedDate: Date, + realizedAmount: Number, + rateHistory: [{ + rate: Number, + date: Date, + source: String + }], + status: { + type: String, + enum: ['active', 'realized', 'closed'], + default: 'active' + } +}, { + timestamps: true +}); + +// Pre-save hook to calculate gain/loss +unrealizedGainLossSchema.pre('save', function (next) { + // Calculate base amounts + this.baseAmountOriginal = this.originalAmount * this.originalRate; + this.baseAmountCurrent = this.originalAmount * this.currentRate; + + // Calculate unrealized gain/loss + this.unrealizedGainLoss = this.baseAmountCurrent - this.baseAmountOriginal; + + // Determine gain/loss type + if (this.unrealizedGainLoss > 0) { + this.gainLossType = 'gain'; + } else if (this.unrealizedGainLoss < 0) { + this.gainLossType = 'loss'; + } else { + this.gainLossType = 'neutral'; + } + + // Calculate percentage + if (this.baseAmountOriginal !== 0) { + this.gainLossPercentage = (this.unrealizedGainLoss / this.baseAmountOriginal) * 100; + } + + next(); +}); + +// Indexes +unrealizedGainLossSchema.index({ userId: 1, currency: 1, status: 1 }); +unrealizedGainLossSchema.index({ accountId: 1, accountType: 1 }); +unrealizedGainLossSchema.index({ asOfDate: -1 }); + +module.exports = mongoose.model('UnrealizedGainLoss', unrealizedGainLossSchema); diff --git a/public/expensetracker.css b/public/expensetracker.css index dab507ef..7a51cdd9 100644 --- a/public/expensetracker.css +++ b/public/expensetracker.css @@ -9914,3 +9914,299 @@ input:checked + .toggle-slider::before { background: linear-gradient(135deg, #ff9f43, #ff6b6b); color: white; } + +/* ============================================ + FX REVALUATION & GAIN/LOSS TRACKER + Issue #605: Multi-Currency Revaluation Engine + ============================================ */ + +.fx-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; +} + +.fx-grid { + display: grid; + grid-template-columns: 1fr 400px; + gap: 25px; + margin-bottom: 25px; +} + +.positions-list { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 600px; + overflow-y: auto; +} + +.position-card { + padding: 15px; + cursor: pointer; + transition: transform 0.2s; + border-left: 4px solid transparent; +} + +.position-card.gain { + border-left-color: #64ffda; +} + +.position-card.loss { + border-left-color: #ff6b6b; +} + +.position-card:hover { + transform: translateY(-2px); +} + +.position-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.position-info strong { + display: block; + font-size: 0.95rem; +} + +.currency-badge { + font-size: 0.7rem; + color: var(--text-secondary); + font-family: monospace; + display: block; + margin-top: 2px; +} + +.gl-badge { + padding: 4px 12px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: bold; +} + +.gl-badge.gain { + background: rgba(100, 255, 218, 0.2); + color: #64ffda; +} + +.gl-badge.loss { + background: rgba(255, 107, 107, 0.2); + color: #ff6b6b; +} + +.position-details { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 10px; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.gl-amount { + font-weight: bold; +} + +.gl-amount.gain { + color: #64ffda; +} + +.gl-amount.loss { + color: #ff6b6b; +} + +.revaluation-history { + display: flex; + flex-direction: column; + gap: 12px; +} + +.revaluation-card { + padding: 15px; +} + +.rev-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.rev-info strong { + display: block; + font-size: 0.95rem; +} + +.rev-id { + font-size: 0.7rem; + color: var(--text-secondary); + font-family: monospace; + display: block; + margin-top: 2px; +} + +.rev-type { + padding: 3px 10px; + background: rgba(72, 219, 251, 0.2); + color: #48dbfb; + border-radius: 12px; + font-size: 0.7rem; + text-transform: uppercase; +} + +.rev-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 15px; +} + +.summary-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.summary-item label { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.summary-item span { + font-size: 0.9rem; + font-weight: 500; +} + +.summary-item span.gain { + color: #64ffda; +} + +.summary-item span.loss { + color: #ff6b6b; +} + +.var-display { + padding: 20px; +} + +.var-summary { + text-align: center; +} + +.var-value h2 { + font-size: 2rem; + color: var(--accent-primary); + margin: 10px 0; +} + +.var-info { + margin-top: 15px; +} + +.var-info p { + margin: 5px 0; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.var-note { + font-size: 0.75rem !important; + font-style: italic; +} + +.top-positions-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 25px; +} + +.top-positions-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.top-position-item { + padding: 12px; + background: rgba(255,255,255,0.02); + border-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; + border-left: 3px solid; +} + +.top-position-item.gain { + border-left-color: #64ffda; +} + +.top-position-item.loss { + border-left-color: #ff6b6b; +} + +.position-name { + font-weight: 500; + flex: 1; +} + +.position-currency { + font-size: 0.75rem; + color: var(--text-secondary); + margin: 0 10px; +} + +.position-amount { + font-weight: bold; + font-size: 0.9rem; +} + +.top-position-item.gain .position-amount { + color: #64ffda; +} + +.top-position-item.loss .position-amount { + color: #ff6b6b; +} + +.compliance-report { + padding: 20px 0; +} + +.compliance-report h4 { + margin-bottom: 20px; + color: var(--accent-primary); +} + +.report-summary { + display: flex; + flex-direction: column; + gap: 12px; +} + +.summary-row { + display: flex; + justify-content: space-between; + padding: 10px; + background: rgba(255,255,255,0.02); + border-radius: 6px; +} + +.summary-row.total { + background: rgba(72, 219, 251, 0.1); + border: 1px solid rgba(72, 219, 251, 0.3); + font-weight: bold; +} + +.summary-row label { + color: var(--text-secondary); +} + +.summary-row span.gain { + color: #64ffda; +} + +.summary-row span.loss { + color: #ff6b6b; +} diff --git a/public/fx-revaluation-dashboard.html b/public/fx-revaluation-dashboard.html new file mode 100644 index 00000000..75eb7ba8 --- /dev/null +++ b/public/fx-revaluation-dashboard.html @@ -0,0 +1,214 @@ + + + + + + + FX Revaluation Dashboard - ExpenseFlow + + + + + + + + +
+ +
+
+

Multi-Currency Revaluation Engine

+

Automated foreign exchange revaluation with unrealized gain/loss tracking

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

₹0

+
+
+
+
+ +
+
+ +

₹0

+
+
+
+
+ +
+
+ +

₹0

+
+
+
+
+ +
+
+ +

0

+
+
+
+ + +
+ +
+
+

Unrealized Positions

+ +
+
+
Loading positions...
+
+
+ + +
+
+
+

Currency Exposure

+
+
+ +
+
+ +
+
+

Value at Risk

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

Revaluation History

+
+
+ +
+
+ + +
+
+
+

Gain/Loss Trend (12 Months)

+
+
+ +
+
+ +
+
+

Sensitivity Analysis

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

Top Gains

+
+
+ +
+
+ +
+
+

Top Losses

+
+
+ +
+
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/public/js/fx-revaluation-controller.js b/public/js/fx-revaluation-controller.js new file mode 100644 index 00000000..7de0cb6b --- /dev/null +++ b/public/js/fx-revaluation-controller.js @@ -0,0 +1,532 @@ +/** + * FX Revaluation Controller + * Handles all FX revaluation and gain/loss tracking UI logic + */ + +let exposureChart = null; +let trendChart = null; +let sensitivityChart = null; +let currentPositions = []; + +document.addEventListener('DOMContentLoaded', () => { + loadDashboard(); + loadUnrealizedPositions(); + loadRevaluationHistory(); + loadGainLossTrend(); + loadTopPositions(); + loadVaR(); + loadSensitivityAnalysis(); + setupForms(); +}); + +async function loadDashboard() { + try { + const res = await fetch('/api/fx-revaluation/dashboard', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + updateDashboardStats(data); + renderCurrencyExposure(data.currencyExposure); + } catch (err) { + console.error('Failed to load dashboard:', err); + } +} + +function updateDashboardStats(data) { + const unrealized = data.unrealizedPositions; + + document.getElementById('unrealized-gain').textContent = `₹${unrealized.totalGain.toLocaleString()}`; + document.getElementById('unrealized-loss').textContent = `₹${unrealized.totalLoss.toLocaleString()}`; + + const netPosition = unrealized.netPosition; + const netElement = document.getElementById('net-position'); + netElement.textContent = `₹${Math.abs(netPosition).toLocaleString()}`; + netElement.style.color = netPosition >= 0 ? '#64ffda' : '#ff6b6b'; + + document.getElementById('active-positions').textContent = unrealized.total; +} + +async function loadUnrealizedPositions() { + try { + const res = await fetch('/api/fx-revaluation/unrealized-positions', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + currentPositions = data; + renderPositions(data); + populateCurrencyFilter(data); + } catch (err) { + console.error('Failed to load positions:', err); + } +} + +function renderPositions(positions) { + const list = document.getElementById('positions-list'); + + if (!positions || positions.length === 0) { + list.innerHTML = '
No unrealized positions found.
'; + return; + } + + list.innerHTML = positions.map(pos => ` +
+
+
+ ${pos.accountName} + ${pos.currency} +
+ + ${pos.gainLossType === 'gain' ? '+' : ''}${pos.gainLossPercentage.toFixed(2)}% + +
+
+
+ + ${pos.originalAmount.toLocaleString()} ${pos.currency} +
+
+ + ${pos.originalRate.toFixed(4)} +
+
+ + ${pos.currentRate.toFixed(4)} +
+
+ + + ₹${Math.abs(pos.unrealizedGainLoss).toLocaleString()} + +
+
+
+ `).join(''); +} + +function populateCurrencyFilter(positions) { + const currencies = [...new Set(positions.map(p => p.currency))]; + const select = document.getElementById('currency-filter'); + + const options = currencies.map(curr => + `` + ).join(''); + + select.innerHTML = '' + options; +} + +function filterPositions() { + const currency = document.getElementById('currency-filter').value; + + if (!currency) { + renderPositions(currentPositions); + } else { + const filtered = currentPositions.filter(p => p.currency === currency); + renderPositions(filtered); + } +} + +function renderCurrencyExposure(exposure) { + if (!exposure || exposure.length === 0) return; + + const ctx = document.getElementById('exposureChart').getContext('2d'); + + if (exposureChart) { + exposureChart.destroy(); + } + + exposureChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: exposure.map(e => e.currency), + datasets: [{ + data: exposure.map(e => Math.abs(e.totalExposure)), + backgroundColor: [ + '#64ffda', '#48dbfb', '#ff9f43', '#ff6b6b', '#a29bfe', '#fd79a8' + ], + borderWidth: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { color: '#8892b0', font: { size: 10 } } + }, + tooltip: { + callbacks: { + label: function (context) { + return `${context.label}: ₹${context.parsed.toLocaleString()}`; + } + } + } + } + } + }); +} + +async function loadRevaluationHistory() { + try { + const res = await fetch('/api/fx-revaluation/revaluations?limit=10', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderRevaluationHistory(data); + } catch (err) { + console.error('Failed to load revaluation history:', err); + } +} + +function renderRevaluationHistory(revaluations) { + const container = document.getElementById('revaluation-history'); + + if (!revaluations || revaluations.length === 0) { + container.innerHTML = '
No revaluation history found.
'; + return; + } + + container.innerHTML = revaluations.map(rev => ` +
+
+
+ ${new Date(rev.revaluationDate).toLocaleDateString()} + ${rev.revaluationId} +
+ ${rev.revaluationType} +
+
+
+ + ${rev.summary.totalAccounts} +
+
+ + + ₹${Math.abs(rev.summary.netGainLoss).toLocaleString()} + +
+
+ + ${rev.summary.currenciesRevalued.join(', ')} +
+
+
+ `).join(''); +} + +async function loadGainLossTrend() { + try { + const res = await fetch('/api/fx-revaluation/gain-loss/trend?months=12', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderTrendChart(data); + } catch (err) { + console.error('Failed to load trend:', err); + } +} + +function renderTrendChart(trend) { + if (!trend || trend.length === 0) return; + + const ctx = document.getElementById('trendChart').getContext('2d'); + + if (trendChart) { + trendChart.destroy(); + } + + trendChart = new Chart(ctx, { + type: 'line', + data: { + labels: trend.map(t => new Date(t.date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })), + datasets: [ + { + label: 'Gain', + data: trend.map(t => t.gain), + borderColor: '#64ffda', + backgroundColor: 'rgba(100, 255, 218, 0.1)', + fill: true, + tension: 0.4 + }, + { + label: 'Loss', + data: trend.map(t => t.loss), + borderColor: '#ff6b6b', + backgroundColor: 'rgba(255, 107, 107, 0.1)', + fill: true, + tension: 0.4 + }, + { + label: 'Net', + data: trend.map(t => t.net), + borderColor: '#48dbfb', + backgroundColor: 'rgba(72, 219, 251, 0.1)', + fill: false, + tension: 0.4, + borderWidth: 2 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + labels: { color: '#8892b0' } + } + }, + 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)' } + } + } + } + }); +} + +async function loadTopPositions() { + try { + const res = await fetch('/api/fx-revaluation/gain-loss/top-positions?limit=5', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderTopGains(data.topGains); + renderTopLosses(data.topLosses); + } catch (err) { + console.error('Failed to load top positions:', err); + } +} + +function renderTopGains(gains) { + const container = document.getElementById('top-gains'); + + if (!gains || gains.length === 0) { + container.innerHTML = '
No gains to display.
'; + return; + } + + container.innerHTML = gains.map(pos => ` +
+
${pos.accountName}
+
${pos.currency}
+
+₹${Math.abs(pos.unrealizedGainLoss).toLocaleString()}
+
+ `).join(''); +} + +function renderTopLosses(losses) { + const container = document.getElementById('top-losses'); + + if (!losses || losses.length === 0) { + container.innerHTML = '
No losses to display.
'; + return; + } + + container.innerHTML = losses.map(pos => ` +
+
${pos.accountName}
+
${pos.currency}
+
-₹${Math.abs(pos.unrealizedGainLoss).toLocaleString()}
+
+ `).join(''); +} + +async function loadVaR() { + try { + const res = await fetch('/api/fx-revaluation/risk/var?confidenceLevel=0.95', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderVaR(data); + } catch (err) { + console.error('Failed to load VaR:', err); + } +} + +function renderVaR(varData) { + const container = document.getElementById('var-display'); + + container.innerHTML = ` +
+
+ +

₹${varData.var.toLocaleString()}

+
+
+

Based on ${varData.positions} active positions

+

Maximum expected loss with 95% confidence over 1 day

+
+
+ `; +} + +async function loadSensitivityAnalysis() { + try { + const res = await fetch('/api/fx-revaluation/risk/sensitivity', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderSensitivityChart(data); + } catch (err) { + console.error('Failed to load sensitivity analysis:', err); + } +} + +function renderSensitivityChart(scenarios) { + if (!scenarios || scenarios.length === 0) return; + + const ctx = document.getElementById('sensitivityChart').getContext('2d'); + + if (sensitivityChart) { + sensitivityChart.destroy(); + } + + sensitivityChart = new Chart(ctx, { + type: 'bar', + data: { + labels: scenarios.map(s => `${s.rateChange > 0 ? '+' : ''}${s.rateChange}%`), + datasets: [{ + label: 'Impact on P&L', + data: scenarios.map(s => s.impact), + backgroundColor: scenarios.map(s => s.impact >= 0 ? 'rgba(100, 255, 218, 0.6)' : 'rgba(255, 107, 107, 0.6)'), + borderColor: scenarios.map(s => s.impact >= 0 ? '#64ffda' : '#ff6b6b'), + borderWidth: 1 + }] + }, + 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)' } + } + } + } + }); +} + +async function runRevaluation() { + if (!confirm('Run FX revaluation for all foreign currency accounts?')) return; + + try { + const res = await fetch('/api/fx-revaluation/run', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify({ + baseCurrency: 'INR', + revaluationType: 'manual' + }) + }); + + const { data } = await res.json(); + + alert(`Revaluation completed!\nNet G/L: ₹${data.summary.netGainLoss.toLocaleString()}\nAccounts: ${data.summary.totalAccounts}`); + + // Reload dashboard + loadDashboard(); + loadUnrealizedPositions(); + loadRevaluationHistory(); + } catch (err) { + console.error('Failed to run revaluation:', err); + alert('Failed to run revaluation'); + } +} + +function generateComplianceReport() { + document.getElementById('compliance-modal').classList.remove('hidden'); +} + +function closeComplianceModal() { + document.getElementById('compliance-modal').classList.add('hidden'); +} + +function closePositionModal() { + document.getElementById('position-details-modal').classList.add('hidden'); +} + +async function viewPositionDetails(positionId) { + // Implementation for viewing detailed position information + console.log('View position:', positionId); +} + +function setupForms() { + document.getElementById('compliance-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const startDate = document.getElementById('report-start-date').value; + const endDate = document.getElementById('report-end-date').value; + + try { + const res = await fetch('/api/fx-revaluation/reports/compliance', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify({ startDate, endDate }) + }); + + const { data } = await res.json(); + + // Display report + const reportContent = document.getElementById('compliance-report-content'); + reportContent.innerHTML = ` +
+

Compliance Report: ${new Date(startDate).toLocaleDateString()} - ${new Date(endDate).toLocaleDateString()}

+
+
+ + ₹${data.summary.unrealizedGain.toLocaleString()} +
+
+ + ₹${data.summary.unrealizedLoss.toLocaleString()} +
+
+ + ₹${data.summary.realizedGain.toLocaleString()} +
+
+ + ₹${data.summary.realizedLoss.toLocaleString()} +
+
+ + + ₹${Math.abs(data.summary.totalNet).toLocaleString()} + +
+
+
+ `; + reportContent.classList.remove('hidden'); + } catch (err) { + console.error('Failed to generate report:', err); + } + }); +} diff --git a/routes/fx-revaluation.js b/routes/fx-revaluation.js new file mode 100644 index 00000000..a7544b93 --- /dev/null +++ b/routes/fx-revaluation.js @@ -0,0 +1,282 @@ +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth'); +const revaluationEngine = require('../services/revaluationEngine'); +const fxGainLossService = require('../services/fxGainLossService'); +const FXRevaluation = require('../models/FXRevaluation'); +const UnrealizedGainLoss = require('../models/UnrealizedGainLoss'); + +/** + * Get FX Revaluation Dashboard + */ +router.get('/dashboard', auth, async (req, res) => { + try { + const dashboard = await revaluationEngine.getRevaluationDashboard(req.user._id); + res.json({ success: true, data: dashboard }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Run FX Revaluation + */ +router.post('/run', auth, async (req, res) => { + try { + const { baseCurrency, revaluationType } = req.body; + + const revaluation = await revaluationEngine.runRevaluation( + req.user._id, + baseCurrency || 'INR', + revaluationType || 'manual' + ); + + res.json({ success: true, data: revaluation }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Revaluations + */ +router.get('/revaluations', auth, async (req, res) => { + try { + const { startDate, endDate, limit } = req.query; + + const query = { userId: req.user._id, status: 'completed' }; + + if (startDate && endDate) { + query.revaluationDate = { + $gte: new Date(startDate), + $lte: new Date(endDate) + }; + } + + const revaluations = await FXRevaluation.find(query) + .sort({ revaluationDate: -1 }) + .limit(parseInt(limit) || 50); + + res.json({ success: true, data: revaluations }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Specific Revaluation + */ +router.get('/revaluations/:id', auth, async (req, res) => { + try { + const revaluation = await FXRevaluation.findOne({ + _id: req.params.id, + userId: req.user._id + }); + + if (!revaluation) { + return res.status(404).json({ success: false, error: 'Revaluation not found' }); + } + + res.json({ success: true, data: revaluation }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Unrealized Positions + */ +router.get('/unrealized-positions', auth, async (req, res) => { + try { + const { currency, status } = req.query; + + const query = { userId: req.user._id }; + + if (currency) query.currency = currency; + if (status) query.status = status; + else query.status = 'active'; + + const positions = await UnrealizedGainLoss.find(query) + .sort({ unrealizedGainLoss: -1 }); + + res.json({ success: true, data: positions }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Total Gain/Loss + */ +router.get('/gain-loss/total', auth, async (req, res) => { + try { + const { asOfDate } = req.query; + + const totals = await fxGainLossService.calculateTotalGainLoss( + req.user._id, + asOfDate ? new Date(asOfDate) : new Date() + ); + + res.json({ success: true, data: totals }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Gain/Loss by Currency + */ +router.get('/gain-loss/by-currency', auth, async (req, res) => { + try { + const byCurrency = await fxGainLossService.getGainLossByCurrency(req.user._id); + res.json({ success: true, data: byCurrency }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Gain/Loss Trend + */ +router.get('/gain-loss/trend', auth, async (req, res) => { + try { + const { months } = req.query; + + const trend = await fxGainLossService.getGainLossTrend( + req.user._id, + parseInt(months) || 12 + ); + + res.json({ success: true, data: trend }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Top Positions + */ +router.get('/gain-loss/top-positions', auth, async (req, res) => { + try { + const { limit } = req.query; + + const topPositions = await fxGainLossService.getTopPositions( + req.user._id, + parseInt(limit) || 10 + ); + + res.json({ success: true, data: topPositions }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Calculate Value at Risk (VaR) + */ +router.get('/risk/var', auth, async (req, res) => { + try { + const { confidenceLevel, timeHorizon } = req.query; + + const var95 = await fxGainLossService.calculateVaR( + req.user._id, + parseFloat(confidenceLevel) || 0.95, + parseInt(timeHorizon) || 1 + ); + + res.json({ success: true, data: var95 }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Sensitivity Analysis + */ +router.get('/risk/sensitivity', auth, async (req, res) => { + try { + const scenarios = await fxGainLossService.getSensitivityAnalysis(req.user._id); + res.json({ success: true, data: scenarios }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Generate Compliance Report + */ +router.post('/reports/compliance', auth, async (req, res) => { + try { + const { startDate, endDate } = req.body; + + if (!startDate || !endDate) { + return res.status(400).json({ + success: false, + error: 'Start date and end date are required' + }); + } + + const report = await fxGainLossService.generateComplianceReport( + req.user._id, + { startDate, endDate } + ); + + res.json({ success: true, data: report }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Revaluation Report + */ +router.get('/reports/revaluation', auth, async (req, res) => { + try { + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + return res.status(400).json({ + success: false, + error: 'Start date and end date are required' + }); + } + + const report = await revaluationEngine.getRevaluationReport( + req.user._id, + startDate, + endDate + ); + + res.json({ success: true, data: report }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Realize Gain/Loss + */ +router.post('/realize/:accountId', auth, async (req, res) => { + try { + const { accountType } = req.body; + + const position = await revaluationEngine.realizeGainLoss( + req.user._id, + req.params.accountId, + accountType + ); + + if (!position) { + return res.status(404).json({ + success: false, + error: 'No active position found for this account' + }); + } + + res.json({ success: true, data: position }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 0db54b9e..af2494d3 100644 --- a/server.js +++ b/server.js @@ -279,6 +279,7 @@ app.use('/api/expenses', expenseRoutes); // Expense management 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/splits', require('./routes/splits')); app.use('/api/workspaces', require('./routes/workspaces')); app.use('/api/tax', require('./routes/tax')); diff --git a/services/budgetForecastingService.js b/services/budgetForecastingService.js deleted file mode 100644 index fee4194b..00000000 --- a/services/budgetForecastingService.js +++ /dev/null @@ -1,626 +0,0 @@ -const BudgetForecast = require('../models/BudgetForecast'); -const Expense = require('../models/Expense'); -const Budget = require('../models/Budget'); - -/** - * Budget Forecasting Service - * Implements time-series forecasting and predictive analytics - */ - -class BudgetForecastingService { - - /** - * Generate forecast for a specific period - */ - async generateForecast(userId, options = {}) { - const { - periodType = 'monthly', - category = null, - algorithm = 'moving_average', - confidenceLevel = 95 - } = options; - - // Calculate period dates - const periods = this._calculatePeriods(periodType); - - // Get historical data - const historicalData = await this._getHistoricalData( - userId, - category, - periods.historicalStart, - periods.historicalEnd - ); - - if (historicalData.length < 3) { - throw new Error('Insufficient historical data for forecasting (minimum 3 periods required)'); - } - - // Generate predictions based on algorithm - let predictions, modelMetadata; - - switch (algorithm) { - case 'linear_regression': - ({ predictions, modelMetadata } = this._linearRegressionForecast(historicalData, periods, confidenceLevel)); - break; - case 'exponential_smoothing': - ({ predictions, modelMetadata } = this._exponentialSmoothingForecast(historicalData, periods, confidenceLevel)); - break; - case 'moving_average': - default: - ({ predictions, modelMetadata } = this._movingAverageForecast(historicalData, periods, confidenceLevel)); - } - - // Analyze trend - const aggregateForecast = this._calculateAggregateForecast(predictions, historicalData); - - // Detect seasonal factors - const seasonalFactors = this._detectSeasonalFactors(historicalData); - - // Compare with budget - const comparison = await this._compareForecastToBudget(userId, category, predictions, periodType); - - // Generate recommendations - const recommendations = this._generateRecommendations(predictions, comparison, aggregateForecast); - - // Generate alerts - const alerts = this._generateAlerts(predictions, comparison, aggregateForecast); - - // Create forecast record - const forecast = new BudgetForecast({ - user: userId, - forecast_period: { - start_date: periods.forecastStart, - end_date: periods.forecastEnd, - period_type: periodType - }, - category, - predictions, - aggregate_forecast: aggregateForecast, - seasonal_factors: seasonalFactors, - model_metadata: { - ...modelMetadata, - algorithm, - training_data_points: historicalData.length, - last_trained: new Date() - }, - comparison, - recommendations, - alerts - }); - - await forecast.save(); - return forecast; - } - - /** - * Get forecast by ID - */ - async getForecastById(forecastId, userId) { - const forecast = await BudgetForecast.findOne({ - _id: forecastId, - user: userId - }); - - if (!forecast) { - throw new Error('Forecast not found'); - } - - return forecast; - } - - /** - * Get all active forecasts for user - */ - async getUserForecasts(userId, filters = {}) { - const query = { user: userId, status: 'active' }; - - if (filters.category) { - query.category = filters.category; - } - - if (filters.periodType) { - query['forecast_period.period_type'] = filters.periodType; - } - - return await BudgetForecast.find(query) - .sort({ 'forecast_period.start_date': -1 }); - } - - /** - * Update forecast accuracy with actual data - */ - async updateForecastAccuracy(userId) { - const activeForecasts = await BudgetForecast.find({ - user: userId, - status: 'active' - }); - - for (const forecast of activeForecasts) { - for (const prediction of forecast.predictions) { - // Skip future predictions - if (prediction.date > new Date()) continue; - - // Check if already tracked - const alreadyTracked = forecast.accuracy_tracking.some( - t => t.prediction_date.toDateString() === prediction.date.toDateString() - ); - - if (alreadyTracked) continue; - - // Get actual spending for prediction date - const actualAmount = await this._getActualSpending( - userId, - forecast.category, - prediction.date - ); - - if (actualAmount !== null) { - await forecast.trackAccuracy( - prediction.date, - prediction.predicted_amount, - actualAmount - ); - } - } - } - - return { message: 'Forecast accuracy updated' }; - } - - /** - * Get forecast summary for dashboard - */ - async getForecastSummary(userId) { - const currentForecasts = await BudgetForecast.getCurrentForecasts(userId); - - const summary = { - total_forecasts: currentForecasts.length, - total_predicted_spending: 0, - categories: [], - alerts: { - critical: 0, - high: 0, - total_unacknowledged: 0 - }, - accuracy: { - overall: 0, - by_category: {} - } - }; - - for (const forecast of currentForecasts) { - summary.total_predicted_spending += forecast.aggregate_forecast.total_predicted || 0; - - summary.categories.push({ - category: forecast.category || 'All Categories', - predicted: forecast.aggregate_forecast.total_predicted, - trend: forecast.aggregate_forecast.trend, - accuracy: forecast.forecast_accuracy - }); - - // Count alerts - forecast.alerts.forEach(alert => { - if (!alert.acknowledged) { - summary.alerts.total_unacknowledged++; - if (alert.severity === 'critical') summary.alerts.critical++; - if (alert.severity === 'high') summary.alerts.high++; - } - }); - - // Track accuracy - if (forecast.forecast_accuracy) { - summary.accuracy.overall += forecast.forecast_accuracy; - if (forecast.category) { - summary.accuracy.by_category[forecast.category] = forecast.forecast_accuracy; - } - } - } - - if (currentForecasts.length > 0) { - summary.accuracy.overall /= currentForecasts.length; - } - - return summary; - } - - /** - * PRIVATE METHODS - Forecasting Algorithms - */ - - _calculatePeriods(periodType) { - const now = new Date(); - const periods = {}; - - switch (periodType) { - case 'weekly': - periods.forecastStart = new Date(now); - periods.forecastEnd = new Date(now.setDate(now.getDate() + 7)); - periods.historicalStart = new Date(now.setDate(now.getDate() - 90)); - periods.historicalEnd = new Date(); - break; - case 'quarterly': - periods.forecastStart = new Date(now); - periods.forecastEnd = new Date(now.setMonth(now.getMonth() + 3)); - periods.historicalStart = new Date(now.setFullYear(now.getFullYear() - 2)); - periods.historicalEnd = new Date(); - break; - case 'yearly': - periods.forecastStart = new Date(now); - periods.forecastEnd = new Date(now.setFullYear(now.getFullYear() + 1)); - periods.historicalStart = new Date(now.setFullYear(now.getFullYear() - 3)); - periods.historicalEnd = new Date(); - break; - case 'monthly': - default: - periods.forecastStart = new Date(now); - periods.forecastEnd = new Date(now.setMonth(now.getMonth() + 1)); - periods.historicalStart = new Date(now.setMonth(now.getMonth() - 12)); - periods.historicalEnd = new Date(); - } - - return periods; - } - - async _getHistoricalData(userId, category, startDate, endDate) { - const query = { - user: userId, - date: { $gte: startDate, $lte: endDate } - }; - - if (category) { - query.category = category; - } - - const expenses = await Expense.find(query).sort({ date: 1 }); - - // Group by month - const monthlyData = {}; - - expenses.forEach(expense => { - const monthKey = `${expense.date.getFullYear()}-${expense.date.getMonth() + 1}`; - if (!monthlyData[monthKey]) { - monthlyData[monthKey] = { - date: new Date(expense.date.getFullYear(), expense.date.getMonth(), 1), - amount: 0 - }; - } - monthlyData[monthKey].amount += expense.amount; - }); - - return Object.values(monthlyData).sort((a, b) => a.date - b.date); - } - - _movingAverageForecast(historicalData, periods, confidenceLevel) { - const windowSize = Math.min(3, historicalData.length); - const predictions = []; - - // Calculate moving average - const recentData = historicalData.slice(-windowSize); - const average = recentData.reduce((sum, d) => sum + d.amount, 0) / windowSize; - - // Calculate standard deviation for confidence intervals - const variance = recentData.reduce((sum, d) => - sum + Math.pow(d.amount - average, 2), 0) / windowSize; - const stdDev = Math.sqrt(variance); - - // Generate predictions - const currentDate = new Date(periods.forecastStart); - while (currentDate < periods.forecastEnd) { - predictions.push({ - date: new Date(currentDate), - predicted_amount: average, - confidence_lower: average - (1.96 * stdDev), - confidence_upper: average + (1.96 * stdDev), - confidence_level: confidenceLevel - }); - currentDate.setMonth(currentDate.getMonth() + 1); - } - - // Calculate RMSE and MAE - const errors = recentData.map(d => d.amount - average); - const rmse = Math.sqrt(errors.reduce((sum, e) => sum + e * e, 0) / errors.length); - const mae = errors.reduce((sum, e) => sum + Math.abs(e), 0) / errors.length; - - return { - predictions, - modelMetadata: { - accuracy_score: Math.max(0, 100 - (mae / average * 100)), - rmse, - mae - } - }; - } - - _linearRegressionForecast(historicalData, periods, confidenceLevel) { - const n = historicalData.length; - const x = Array.from({ length: n }, (_, 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; - - // Calculate residuals for confidence interval - const predictions = []; - const residuals = y.map((yi, i) => yi - (slope * i + intercept)); - const residualStdDev = Math.sqrt( - residuals.reduce((sum, r) => sum + r * r, 0) / (n - 2) - ); - - // Generate future predictions - const monthsDiff = Math.ceil((periods.forecastEnd - periods.forecastStart) / (30 * 24 * 60 * 60 * 1000)); - const currentDate = new Date(periods.forecastStart); - - for (let i = 0; i < monthsDiff; i++) { - const futureX = n + i; - const predictedAmount = slope * futureX + intercept; - - predictions.push({ - date: new Date(currentDate), - predicted_amount: Math.max(0, predictedAmount), - confidence_lower: Math.max(0, predictedAmount - 1.96 * residualStdDev), - confidence_upper: predictedAmount + 1.96 * residualStdDev, - confidence_level: confidenceLevel - }); - - currentDate.setMonth(currentDate.getMonth() + 1); - } - - // Calculate metrics - const rmse = Math.sqrt(residuals.reduce((sum, r) => sum + r * r, 0) / n); - const mae = residuals.reduce((sum, r) => sum + Math.abs(r), 0) / n; - const meanY = sumY / n; - - return { - predictions, - modelMetadata: { - accuracy_score: Math.max(0, 100 - (mae / meanY * 100)), - rmse, - mae - } - }; - } - - _exponentialSmoothingForecast(historicalData, periods, confidenceLevel) { - const alpha = 0.3; // Smoothing parameter - const predictions = []; - - // Initialize with first value - let smoothed = historicalData[0].amount; - const smoothedValues = [smoothed]; - - // Calculate smoothed values - for (let i = 1; i < historicalData.length; i++) { - smoothed = alpha * historicalData[i].amount + (1 - alpha) * smoothed; - smoothedValues.push(smoothed); - } - - // Calculate error variance - const errors = historicalData.map((d, i) => d.amount - smoothedValues[i]); - const variance = errors.reduce((sum, e) => sum + e * e, 0) / errors.length; - const stdDev = Math.sqrt(variance); - - // Use last smoothed value for predictions - const lastSmoothed = smoothedValues[smoothedValues.length - 1]; - - // Generate future predictions - const currentDate = new Date(periods.forecastStart); - while (currentDate < periods.forecastEnd) { - predictions.push({ - date: new Date(currentDate), - predicted_amount: lastSmoothed, - confidence_lower: Math.max(0, lastSmoothed - 1.96 * stdDev), - confidence_upper: lastSmoothed + 1.96 * stdDev, - confidence_level: confidenceLevel - }); - currentDate.setMonth(currentDate.getMonth() + 1); - } - - const rmse = Math.sqrt(variance); - const mae = errors.reduce((sum, e) => sum + Math.abs(e), 0) / errors.length; - const meanY = historicalData.reduce((sum, d) => sum + d.amount, 0) / historicalData.length; - - return { - predictions, - modelMetadata: { - accuracy_score: Math.max(0, 100 - (mae / meanY * 100)), - rmse, - mae - } - }; - } - - _calculateAggregateForecast(predictions, historicalData) { - const totalPredicted = predictions.reduce((sum, p) => sum + p.predicted_amount, 0); - const averageMonthly = totalPredicted / predictions.length; - - // Calculate historical average for comparison - const historicalAvg = historicalData.reduce((sum, d) => sum + d.amount, 0) / historicalData.length; - - // Determine trend - let trend = 'stable'; - let trendPercentage = ((averageMonthly - historicalAvg) / historicalAvg) * 100; - - if (trendPercentage > 10) { - trend = 'increasing'; - } else if (trendPercentage < -10) { - trend = 'decreasing'; - } else if (Math.abs(trendPercentage) < 5) { - trend = 'stable'; - } else { - trend = 'volatile'; - } - - return { - total_predicted: totalPredicted, - average_monthly: averageMonthly, - trend, - trend_percentage: trendPercentage - }; - } - - _detectSeasonalFactors(historicalData) { - const monthlyAverages = {}; - const monthlyCounts = {}; - - historicalData.forEach(data => { - const month = data.date.getMonth() + 1; - if (!monthlyAverages[month]) { - monthlyAverages[month] = 0; - monthlyCounts[month] = 0; - } - monthlyAverages[month] += data.amount; - monthlyCounts[month]++; - }); - - // Calculate average for each month - const overallAverage = historicalData.reduce((sum, d) => sum + d.amount, 0) / historicalData.length; - - const seasonalFactors = []; - for (let month = 1; month <= 12; month++) { - if (monthlyAverages[month]) { - const monthAverage = monthlyAverages[month] / monthlyCounts[month]; - const factor = monthAverage / overallAverage; - - // Identify significant seasonal events - let event = null; - if (factor > 1.2) { - event = 'High spending period'; - } else if (factor < 0.8) { - event = 'Low spending period'; - } - - seasonalFactors.push({ month, factor, event }); - } - } - - return seasonalFactors; - } - - async _compareForecastToBudget(userId, category, predictions, periodType) { - const totalPredicted = predictions.reduce((sum, p) => sum + p.predicted_amount, 0); - - // Get current budget - const budgetQuery = { user: userId, is_active: true }; - if (category) { - budgetQuery.category = category; - } - - const budget = await Budget.findOne(budgetQuery); - - if (!budget) { - return { - vs_budget: { - budget_amount: null, - forecast_vs_budget: null, - will_exceed: false - } - }; - } - - const budgetAmount = budget.amount; - const forecastVsBudget = totalPredicted - budgetAmount; - const willExceed = forecastVsBudget > 0; - - return { - vs_budget: { - budget_amount: budgetAmount, - forecast_vs_budget: forecastVsBudget, - will_exceed: willExceed - } - }; - } - - _generateRecommendations(predictions, comparison, aggregateForecast) { - const recommendations = []; - - // Budget recommendations - if (comparison.vs_budget && comparison.vs_budget.will_exceed) { - const exceededBy = comparison.vs_budget.forecast_vs_budget; - recommendations.push({ - recommendation_type: 'increase_budget', - title: 'Budget Increase Recommended', - description: `Forecast indicates spending will exceed budget by $${exceededBy.toFixed(2)}. Consider increasing budget or reducing spending.`, - impact_amount: exceededBy, - priority: 'high' - }); - } - - // Trend recommendations - if (aggregateForecast.trend === 'increasing' && aggregateForecast.trend_percentage > 15) { - recommendations.push({ - recommendation_type: 'review_category', - title: 'Rising Spending Trend Detected', - description: `Spending is projected to increase by ${aggregateForecast.trend_percentage.toFixed(1)}%. Review expenses to identify cost-saving opportunities.`, - impact_amount: null, - priority: 'medium' - }); - } - - if (aggregateForecast.trend === 'decreasing' && comparison.vs_budget && !comparison.vs_budget.will_exceed) { - const savings = Math.abs(comparison.vs_budget.forecast_vs_budget); - recommendations.push({ - recommendation_type: 'save_more', - title: 'Savings Opportunity', - description: `Spending is decreasing. Consider allocating $${savings.toFixed(2)} to savings or investments.`, - impact_amount: savings, - priority: 'low' - }); - } - - return recommendations; - } - - _generateAlerts(predictions, comparison, aggregateForecast) { - const alerts = []; - - // Budget alert - if (comparison.vs_budget && comparison.vs_budget.will_exceed) { - alerts.push({ - alert_type: 'forecast_exceeds_budget', - severity: 'high', - message: `Your forecast indicates you will exceed your budget by $${comparison.vs_budget.forecast_vs_budget.toFixed(2)}`, - triggered_at: new Date() - }); - } - - // Trend alert - if (aggregateForecast.trend === 'increasing' && aggregateForecast.trend_percentage > 20) { - alerts.push({ - alert_type: 'unusual_spike', - severity: 'medium', - message: `Spending is projected to increase significantly by ${aggregateForecast.trend_percentage.toFixed(1)}%`, - triggered_at: new Date() - }); - } - - return alerts; - } - - async _getActualSpending(userId, category, date) { - const startOfMonth = new Date(date.getFullYear(), date.getMonth(), 1); - const endOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0); - - const query = { - user: userId, - date: { $gte: startOfMonth, $lte: endOfMonth } - }; - - if (category) { - query.category = category; - } - - const expenses = await Expense.find(query); - - if (expenses.length === 0) return null; - - return expenses.reduce((sum, e) => sum + e.amount, 0); - } -} - -module.exports = new BudgetForecastingService(); diff --git a/services/cronJobs.js b/services/cronJobs.js index 26d7d710..1adbfc3a 100644 --- a/services/cronJobs.js +++ b/services/cronJobs.js @@ -378,6 +378,35 @@ class CronJobs { } }); + // Daily FX revaluation - Every day at 6 AM UTC + cron.schedule('0 6 * * *', async () => { + try { + console.log('[CronJobs] Running automated FX revaluation...'); + const revaluationEngine = require('./revaluationEngine'); + const User = require('../models/User'); + + // Get all users with foreign currency accounts + const users = await User.find({ isActive: true }); + + let successCount = 0; + let failCount = 0; + + for (const user of users) { + try { + await revaluationEngine.runRevaluation(user._id, 'INR', 'automated'); + successCount++; + } catch (err) { + console.error(`[CronJobs] Failed to run revaluation for user ${user._id}:`, err.message); + failCount++; + } + } + + console.log(`[CronJobs] FX revaluation completed: ${successCount} success, ${failCount} failed`); + } catch (err) { + console.error('[CronJobs] Error in FX revaluation:', err); + } + }); + console.log('Cron jobs initialized successfully'); } diff --git a/services/fxGainLossService.js b/services/fxGainLossService.js new file mode 100644 index 00000000..8df54666 --- /dev/null +++ b/services/fxGainLossService.js @@ -0,0 +1,331 @@ +const UnrealizedGainLoss = require('../models/UnrealizedGainLoss'); +const FXRevaluation = require('../models/FXRevaluation'); +const Transaction = require('../models/Transaction'); + +class FXGainLossService { + /** + * Calculate total realized and unrealized gains/losses + */ + async calculateTotalGainLoss(userId, asOfDate = new Date()) { + // Get unrealized positions + const unrealizedPositions = await UnrealizedGainLoss.find({ + userId, + status: 'active', + asOfDate: { $lte: asOfDate } + }); + + const unrealizedGain = unrealizedPositions + .filter(p => p.gainLossType === 'gain') + .reduce((sum, p) => sum + Math.abs(p.unrealizedGainLoss), 0); + + const unrealizedLoss = unrealizedPositions + .filter(p => p.gainLossType === 'loss') + .reduce((sum, p) => sum + Math.abs(p.unrealizedGainLoss), 0); + + // Get realized positions + const realizedPositions = await UnrealizedGainLoss.find({ + userId, + status: 'realized', + realizedDate: { $lte: asOfDate } + }); + + const realizedGain = realizedPositions + .filter(p => p.gainLossType === 'gain') + .reduce((sum, p) => sum + Math.abs(p.realizedAmount), 0); + + const realizedLoss = realizedPositions + .filter(p => p.gainLossType === 'loss') + .reduce((sum, p) => sum + Math.abs(p.realizedAmount), 0); + + return { + unrealized: { + gain: unrealizedGain, + loss: unrealizedLoss, + net: unrealizedGain - unrealizedLoss, + positions: unrealizedPositions.length + }, + realized: { + gain: realizedGain, + loss: realizedLoss, + net: realizedGain - realizedLoss, + positions: realizedPositions.length + }, + total: { + gain: unrealizedGain + realizedGain, + loss: unrealizedLoss + realizedLoss, + net: (unrealizedGain - unrealizedLoss) + (realizedGain - realizedLoss) + } + }; + } + + /** + * Get gain/loss by currency + */ + async getGainLossByCurrency(userId) { + const positions = await UnrealizedGainLoss.find({ userId }); + + const byCurrency = {}; + + for (const position of positions) { + if (!byCurrency[position.currency]) { + byCurrency[position.currency] = { + currency: position.currency, + unrealizedGain: 0, + unrealizedLoss: 0, + realizedGain: 0, + realizedLoss: 0, + totalPositions: 0 + }; + } + + byCurrency[position.currency].totalPositions++; + + if (position.status === 'active') { + if (position.gainLossType === 'gain') { + byCurrency[position.currency].unrealizedGain += Math.abs(position.unrealizedGainLoss); + } else if (position.gainLossType === 'loss') { + byCurrency[position.currency].unrealizedLoss += Math.abs(position.unrealizedGainLoss); + } + } else if (position.status === 'realized') { + if (position.gainLossType === 'gain') { + byCurrency[position.currency].realizedGain += Math.abs(position.realizedAmount); + } else if (position.gainLossType === 'loss') { + byCurrency[position.currency].realizedLoss += Math.abs(position.realizedAmount); + } + } + } + + // Calculate net for each currency + Object.values(byCurrency).forEach(curr => { + curr.unrealizedNet = curr.unrealizedGain - curr.unrealizedLoss; + curr.realizedNet = curr.realizedGain - curr.realizedLoss; + curr.totalNet = curr.unrealizedNet + curr.realizedNet; + }); + + return Object.values(byCurrency); + } + + /** + * Get gain/loss trend over time + */ + async getGainLossTrend(userId, months = 12) { + const startDate = new Date(); + startDate.setMonth(startDate.getMonth() - months); + + const revaluations = await FXRevaluation.find({ + userId, + status: 'completed', + revaluationDate: { $gte: startDate } + }).sort({ revaluationDate: 1 }); + + const trend = revaluations.map(r => ({ + date: r.revaluationDate, + gain: r.summary.totalGain, + loss: r.summary.totalLoss, + net: r.summary.netGainLoss, + accountsRevalued: r.summary.totalAccounts + })); + + return trend; + } + + /** + * Get top gaining and losing positions + */ + async getTopPositions(userId, limit = 10) { + const activePositions = await UnrealizedGainLoss.find({ + userId, + status: 'active' + }).sort({ unrealizedGainLoss: -1 }); + + const topGains = activePositions + .filter(p => p.gainLossType === 'gain') + .slice(0, limit); + + const topLosses = activePositions + .filter(p => p.gainLossType === 'loss') + .sort((a, b) => a.unrealizedGainLoss - b.unrealizedGainLoss) + .slice(0, limit); + + return { + topGains, + topLosses + }; + } + + /** + * Calculate Value at Risk (VaR) for FX positions + */ + async calculateVaR(userId, confidenceLevel = 0.95, timeHorizon = 1) { + const positions = await UnrealizedGainLoss.find({ + userId, + status: 'active' + }); + + if (positions.length === 0) { + return { var: 0, positions: 0 }; + } + + // Calculate historical volatility for each position + const positionRisks = positions.map(position => { + const rateHistory = position.rateHistory || []; + + if (rateHistory.length < 2) { + return { + position, + volatility: 0, + var: 0 + }; + } + + // Calculate daily returns + const returns = []; + for (let i = 1; i < rateHistory.length; i++) { + const dailyReturn = (rateHistory[i].rate - rateHistory[i - 1].rate) / rateHistory[i - 1].rate; + returns.push(dailyReturn); + } + + // Calculate volatility (standard deviation) + const mean = returns.reduce((a, b) => a + b, 0) / returns.length; + const variance = returns.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / returns.length; + const volatility = Math.sqrt(variance); + + // Calculate VaR using parametric method + const zScore = this.getZScore(confidenceLevel); + const var95 = position.baseAmountCurrent * volatility * zScore * Math.sqrt(timeHorizon); + + return { + position, + volatility, + var: var95 + }; + }); + + // Total portfolio VaR (simplified - assumes independence) + const totalVaR = Math.sqrt( + positionRisks.reduce((sum, pr) => sum + Math.pow(pr.var, 2), 0) + ); + + return { + var: totalVaR, + positions: positionRisks.length, + positionRisks: positionRisks.sort((a, b) => b.var - a.var).slice(0, 10) + }; + } + + /** + * Get Z-score for confidence level + */ + getZScore(confidenceLevel) { + const zScores = { + 0.90: 1.28, + 0.95: 1.65, + 0.99: 2.33 + }; + return zScores[confidenceLevel] || 1.65; + } + + /** + * Generate compliance report (IFRS/GAAP) + */ + async generateComplianceReport(userId, reportingPeriod) { + const { startDate, endDate } = reportingPeriod; + + // Get all revaluations in period + const revaluations = await FXRevaluation.find({ + userId, + status: 'completed', + revaluationDate: { + $gte: new Date(startDate), + $lte: new Date(endDate) + } + }).sort({ revaluationDate: 1 }); + + // Get unrealized positions at period end + const unrealizedPositions = await UnrealizedGainLoss.find({ + userId, + status: 'active', + asOfDate: { $lte: new Date(endDate) } + }); + + // Get realized positions in period + const realizedPositions = await UnrealizedGainLoss.find({ + userId, + status: 'realized', + realizedDate: { + $gte: new Date(startDate), + $lte: new Date(endDate) + } + }); + + // Calculate totals + const totalUnrealizedGain = unrealizedPositions + .filter(p => p.gainLossType === 'gain') + .reduce((sum, p) => sum + Math.abs(p.unrealizedGainLoss), 0); + + const totalUnrealizedLoss = unrealizedPositions + .filter(p => p.gainLossType === 'loss') + .reduce((sum, p) => sum + Math.abs(p.unrealizedGainLoss), 0); + + const totalRealizedGain = realizedPositions + .filter(p => p.gainLossType === 'gain') + .reduce((sum, p) => sum + Math.abs(p.realizedAmount), 0); + + const totalRealizedLoss = realizedPositions + .filter(p => p.gainLossType === 'loss') + .reduce((sum, p) => sum + Math.abs(p.realizedAmount), 0); + + return { + reportingPeriod: { + startDate, + endDate + }, + summary: { + unrealizedGain: totalUnrealizedGain, + unrealizedLoss: totalUnrealizedLoss, + unrealizedNet: totalUnrealizedGain - totalUnrealizedLoss, + realizedGain: totalRealizedGain, + realizedLoss: totalRealizedLoss, + realizedNet: totalRealizedGain - totalRealizedLoss, + totalNet: (totalUnrealizedGain - totalUnrealizedLoss) + (totalRealizedGain - totalRealizedLoss) + }, + revaluationCount: revaluations.length, + unrealizedPositions: unrealizedPositions.length, + realizedPositions: realizedPositions.length, + detailedRevaluations: revaluations, + detailedUnrealized: unrealizedPositions, + detailedRealized: realizedPositions + }; + } + + /** + * Get sensitivity analysis for rate changes + */ + async getSensitivityAnalysis(userId, rateChangePercentages = [-10, -5, 0, 5, 10]) { + const positions = await UnrealizedGainLoss.find({ + userId, + status: 'active' + }); + + const scenarios = rateChangePercentages.map(changePercent => { + let totalImpact = 0; + + for (const position of positions) { + const newRate = position.currentRate * (1 + changePercent / 100); + const newBaseAmount = position.originalAmount * newRate; + const newGainLoss = newBaseAmount - position.baseAmountOriginal; + totalImpact += newGainLoss; + } + + return { + rateChange: changePercent, + impact: totalImpact, + currentNet: positions.reduce((sum, p) => sum + p.unrealizedGainLoss, 0) + }; + }); + + return scenarios; + } +} + +module.exports = new FXGainLossService(); diff --git a/services/revaluationEngine.js b/services/revaluationEngine.js new file mode 100644 index 00000000..5961798b --- /dev/null +++ b/services/revaluationEngine.js @@ -0,0 +1,388 @@ +const FXRevaluation = require('../models/FXRevaluation'); +const UnrealizedGainLoss = require('../models/UnrealizedGainLoss'); +const Account = require('../models/Account'); +const DebtAccount = require('../models/DebtAccount'); +const TreasuryVault = require('../models/TreasuryVault'); +const Transaction = require('../models/Transaction'); +const currencyService = require('./currencyService'); + +class RevaluationEngine { + /** + * Run comprehensive FX revaluation across all foreign currency accounts + */ + async runRevaluation(userId, baseCurrency = 'INR', revaluationType = 'automated') { + const revaluationId = `REV-${Date.now()}`; + const revaluationDate = new Date(); + + // Get all foreign currency accounts + const accounts = await this.getForeignCurrencyAccounts(userId, baseCurrency); + + if (accounts.length === 0) { + throw new Error('No foreign currency accounts found for revaluation'); + } + + // Get current exchange rates + const currencies = [...new Set(accounts.map(a => a.currency))]; + const exchangeRates = await this.getCurrentExchangeRates(currencies, baseCurrency); + + // Perform revaluation for each account + const revaluationItems = []; + + for (const account of accounts) { + const currentRate = exchangeRates.find(r => r.currency === account.currency); + + if (!currentRate) { + console.warn(`No exchange rate found for ${account.currency}`); + continue; + } + + const item = await this.revalueAccount(account, currentRate.rate, baseCurrency); + if (item) { + revaluationItems.push(item); + } + } + + // Create revaluation record + const revaluation = new FXRevaluation({ + userId, + revaluationId, + revaluationDate, + baseCurrency, + revaluationType, + items: revaluationItems, + exchangeRates, + performedBy: userId, + status: 'completed' + }); + + await revaluation.save(); + + // Update unrealized gain/loss positions + await this.updateUnrealizedPositions(userId, revaluationItems, revaluationDate); + + return revaluation; + } + + /** + * Get all foreign currency accounts for a user + */ + async getForeignCurrencyAccounts(userId, baseCurrency) { + const accounts = []; + + // Get regular accounts + const regularAccounts = await Account.find({ + userId, + currency: { $ne: baseCurrency }, + balance: { $ne: 0 } + }); + + accounts.push(...regularAccounts.map(a => ({ + accountId: a._id, + accountType: 'Account', + accountName: a.accountName, + currency: a.currency, + balance: a.balance, + originalRate: a.exchangeRate || 1 + }))); + + // Get debt accounts + const debtAccounts = await DebtAccount.find({ + userId, + currency: { $ne: baseCurrency }, + outstandingBalance: { $ne: 0 } + }); + + accounts.push(...debtAccounts.map(a => ({ + accountId: a._id, + accountType: 'DebtAccount', + accountName: a.accountName, + currency: a.currency, + balance: a.outstandingBalance, + originalRate: a.exchangeRate || 1 + }))); + + // Get treasury vaults if they exist + try { + const vaults = await TreasuryVault.find({ + userId, + currency: { $ne: baseCurrency }, + balance: { $ne: 0 } + }); + + accounts.push(...vaults.map(v => ({ + accountId: v._id, + accountType: 'TreasuryVault', + accountName: v.vaultName, + currency: v.currency, + balance: v.balance, + originalRate: v.exchangeRate || 1 + }))); + } catch (err) { + // TreasuryVault model might not exist + console.log('TreasuryVault model not available'); + } + + return accounts; + } + + /** + * Get current exchange rates for multiple currencies + */ + async getCurrentExchangeRates(currencies, baseCurrency) { + const rates = []; + + for (const currency of currencies) { + try { + const rate = await currencyService.getExchangeRate(currency, baseCurrency); + rates.push({ + currency, + rate: rate.rate, + source: rate.source || 'currencyService', + timestamp: new Date() + }); + } catch (err) { + console.error(`Failed to get rate for ${currency}:`, err.message); + // Use fallback rate of 1 + rates.push({ + currency, + rate: 1, + source: 'fallback', + timestamp: new Date() + }); + } + } + + return rates; + } + + /** + * Revalue a single account + */ + async revalueAccount(account, newRate, baseCurrency) { + const originalAmount = account.balance; + const originalRate = account.originalRate; + + // Calculate base currency amounts + const baseAmountOriginal = originalAmount * originalRate; + const baseAmountNew = originalAmount * newRate; + + // Calculate gain/loss + const gainLoss = baseAmountNew - baseAmountOriginal; + + // Skip if no change + if (Math.abs(gainLoss) < 0.01) { + return null; + } + + return { + accountId: account.accountId, + accountType: account.accountType, + accountName: account.accountName, + currency: account.currency, + originalAmount, + originalRate, + newRate, + baseAmount: baseAmountOriginal, + revaluedAmount: baseAmountNew, + gainLoss, + gainLossType: gainLoss >= 0 ? 'gain' : 'loss' + }; + } + + /** + * Update unrealized gain/loss positions + */ + async updateUnrealizedPositions(userId, revaluationItems, asOfDate) { + for (const item of revaluationItems) { + // Find existing position + let position = await UnrealizedGainLoss.findOne({ + userId, + accountId: item.accountId, + accountType: item.accountType, + status: 'active' + }); + + if (position) { + // Update existing position + position.currentRate = item.newRate; + position.asOfDate = asOfDate; + position.lastRevaluationDate = asOfDate; + + // Add to rate history + position.rateHistory.push({ + rate: item.newRate, + date: asOfDate, + source: 'revaluation' + }); + + await position.save(); + } else { + // Create new position + position = new UnrealizedGainLoss({ + userId, + accountId: item.accountId, + accountType: item.accountType, + accountName: item.accountName, + currency: item.currency, + originalAmount: item.originalAmount, + originalRate: item.originalRate, + currentRate: item.newRate, + asOfDate, + lastRevaluationDate: asOfDate, + rateHistory: [{ + rate: item.newRate, + date: asOfDate, + source: 'revaluation' + }] + }); + + await position.save(); + } + } + } + + /** + * Get revaluation dashboard data + */ + async getRevaluationDashboard(userId) { + // Get latest revaluation + const latestRevaluation = await FXRevaluation.findOne({ + userId, + status: 'completed' + }).sort({ revaluationDate: -1 }); + + // Get all active unrealized positions + const unrealizedPositions = await UnrealizedGainLoss.find({ + userId, + status: 'active' + }).sort({ unrealizedGainLoss: -1 }); + + // Calculate totals + const totalUnrealizedGain = unrealizedPositions + .filter(p => p.gainLossType === 'gain') + .reduce((sum, p) => sum + Math.abs(p.unrealizedGainLoss), 0); + + const totalUnrealizedLoss = unrealizedPositions + .filter(p => p.gainLossType === 'loss') + .reduce((sum, p) => sum + Math.abs(p.unrealizedGainLoss), 0); + + // Get revaluation history (last 12 months) + const twelveMonthsAgo = new Date(); + twelveMonthsAgo.setMonth(twelveMonthsAgo.getMonth() - 12); + + const revaluationHistory = await FXRevaluation.find({ + userId, + status: 'completed', + revaluationDate: { $gte: twelveMonthsAgo } + }).sort({ revaluationDate: 1 }); + + // Currency exposure breakdown + const currencyExposure = this.calculateCurrencyExposure(unrealizedPositions); + + return { + latestRevaluation: latestRevaluation ? { + date: latestRevaluation.revaluationDate, + netGainLoss: latestRevaluation.summary.netGainLoss, + totalGain: latestRevaluation.summary.totalGain, + totalLoss: latestRevaluation.summary.totalLoss, + accountsRevalued: latestRevaluation.summary.totalAccounts + } : null, + unrealizedPositions: { + total: unrealizedPositions.length, + totalGain: totalUnrealizedGain, + totalLoss: totalUnrealizedLoss, + netPosition: totalUnrealizedGain - totalUnrealizedLoss, + positions: unrealizedPositions + }, + revaluationHistory: revaluationHistory.map(r => ({ + date: r.revaluationDate, + netGainLoss: r.summary.netGainLoss, + accountsRevalued: r.summary.totalAccounts + })), + currencyExposure + }; + } + + /** + * Calculate currency exposure breakdown + */ + calculateCurrencyExposure(positions) { + const exposure = {}; + + for (const position of positions) { + if (!exposure[position.currency]) { + exposure[position.currency] = { + currency: position.currency, + totalExposure: 0, + unrealizedGain: 0, + unrealizedLoss: 0, + accountCount: 0 + }; + } + + exposure[position.currency].totalExposure += position.baseAmountCurrent; + exposure[position.currency].accountCount++; + + if (position.gainLossType === 'gain') { + exposure[position.currency].unrealizedGain += Math.abs(position.unrealizedGainLoss); + } else if (position.gainLossType === 'loss') { + exposure[position.currency].unrealizedLoss += Math.abs(position.unrealizedGainLoss); + } + } + + return Object.values(exposure); + } + + /** + * Get historical revaluation report + */ + async getRevaluationReport(userId, startDate, endDate) { + const revaluations = await FXRevaluation.find({ + userId, + status: 'completed', + revaluationDate: { + $gte: new Date(startDate), + $lte: new Date(endDate) + } + }).sort({ revaluationDate: 1 }); + + const totalGain = revaluations.reduce((sum, r) => sum + r.summary.totalGain, 0); + const totalLoss = revaluations.reduce((sum, r) => sum + r.summary.totalLoss, 0); + + return { + period: { startDate, endDate }, + revaluationCount: revaluations.length, + totalGain, + totalLoss, + netGainLoss: totalGain - totalLoss, + revaluations + }; + } + + /** + * Realize gain/loss when account is closed or settled + */ + async realizeGainLoss(userId, accountId, accountType) { + const position = await UnrealizedGainLoss.findOne({ + userId, + accountId, + accountType, + status: 'active' + }); + + if (!position) { + return null; + } + + position.isRealized = true; + position.realizedDate = new Date(); + position.realizedAmount = position.unrealizedGainLoss; + position.status = 'realized'; + + await position.save(); + + return position; + } +} + +module.exports = new RevaluationEngine();