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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
₹0
+
+
+
+
+
+
+
+
+
₹0
+
+
+
+
+
+
+
+
+
₹0
+
+
+
+
+
+
+
+
+
0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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.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 => `
+
+
+
+
+
+ ${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();