diff --git a/models/CategoryAnalytics.js b/models/CategoryAnalytics.js new file mode 100644 index 00000000..59592b6d --- /dev/null +++ b/models/CategoryAnalytics.js @@ -0,0 +1,228 @@ +const mongoose = require('mongoose'); + +const categoryAnalyticsSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + date: { + type: Date, + default: Date.now + }, + totalPredictions: { + type: Number, + default: 0 + }, + correctPredictions: { + type: Number, + default: 0 + }, + accuracy: { + type: Number, + default: 0, + min: 0, + max: 1 + }, + methodBreakdown: { + tensorflow: { + predictions: { type: Number, default: 0 }, + correct: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0 } + }, + pattern: { + predictions: { type: Number, default: 0 }, + correct: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0 } + }, + 'rule-based': { + predictions: { type: Number, default: 0 }, + correct: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0 } + } + }, + categoryBreakdown: { + food: { + predictions: { type: Number, default: 0 }, + correct: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0 } + }, + transport: { + predictions: { type: Number, default: 0 }, + correct: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0 } + }, + entertainment: { + predictions: { type: Number, default: 0 }, + correct: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0 } + }, + utilities: { + predictions: { type: Number, default: 0 }, + correct: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0 } + }, + healthcare: { + predictions: { type: Number, default: 0 }, + correct: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0 } + }, + shopping: { + predictions: { type: Number, default: 0 }, + correct: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0 } + }, + other: { + predictions: { type: Number, default: 0 }, + correct: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0 } + } + }, + averageConfidence: { + type: Number, + default: 0 + }, + trainingDataUsed: { + type: Number, + default: 0 + } +}, { + timestamps: true +}); + +// Indexes +categoryAnalyticsSchema.index({ user: 1, date: -1 }); +categoryAnalyticsSchema.index({ user: 1, date: 1 }); + +// Static method to record prediction +categoryAnalyticsSchema.statics.recordPrediction = async function(userId, prediction, actualCategory, confidence) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + let analytics = await this.findOne({ + user: userId, + date: today + }); + + if (!analytics) { + analytics = new this({ + user: userId, + date: today + }); + } + + analytics.totalPredictions += 1; + const isCorrect = prediction.category === actualCategory; + if (isCorrect) { + analytics.correctPredictions += 1; + } + + // Update method breakdown + if (analytics.methodBreakdown[prediction.method]) { + analytics.methodBreakdown[prediction.method].predictions += 1; + if (isCorrect) { + analytics.methodBreakdown[prediction.method].correct += 1; + } + analytics.methodBreakdown[prediction.method].accuracy = + analytics.methodBreakdown[prediction.method].correct / + analytics.methodBreakdown[prediction.method].predictions; + } + + // Update category breakdown + if (analytics.categoryBreakdown[actualCategory]) { + analytics.categoryBreakdown[actualCategory].predictions += 1; + if (isCorrect) { + analytics.categoryBreakdown[actualCategory].correct += 1; + } + analytics.categoryBreakdown[actualCategory].accuracy = + analytics.categoryBreakdown[actualCategory].correct / + analytics.categoryBreakdown[actualCategory].predictions; + } + + // Update overall accuracy + analytics.accuracy = analytics.correctPredictions / analytics.totalPredictions; + + // Update average confidence + analytics.averageConfidence = ( + (analytics.averageConfidence * (analytics.totalPredictions - 1)) + confidence + ) / analytics.totalPredictions; + + return await analytics.save(); +}; + +// Static method to get user analytics +categoryAnalyticsSchema.statics.getUserAnalytics = async function(userId, days = 30) { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + const analytics = await this.find({ + user: userId, + date: { $gte: startDate } + }).sort({ date: -1 }); + + if (analytics.length === 0) { + return { + totalPredictions: 0, + overallAccuracy: 0, + averageConfidence: 0, + methodBreakdown: {}, + categoryBreakdown: {}, + dailyStats: [] + }; + } + + // Aggregate stats + const totalPredictions = analytics.reduce((sum, day) => sum + day.totalPredictions, 0); + const totalCorrect = analytics.reduce((sum, day) => sum + day.correctPredictions, 0); + const overallAccuracy = totalCorrect / totalPredictions; + + const averageConfidence = analytics.reduce((sum, day) => sum + day.averageConfidence, 0) / analytics.length; + + // Aggregate method breakdown + const methodBreakdown = {}; + analytics.forEach(day => { + Object.keys(day.methodBreakdown).forEach(method => { + if (!methodBreakdown[method]) { + methodBreakdown[method] = { predictions: 0, correct: 0 }; + } + methodBreakdown[method].predictions += day.methodBreakdown[method].predictions; + methodBreakdown[method].correct += day.methodBreakdown[method].correct; + }); + }); + + Object.keys(methodBreakdown).forEach(method => { + methodBreakdown[method].accuracy = methodBreakdown[method].correct / methodBreakdown[method].predictions; + }); + + // Aggregate category breakdown + const categoryBreakdown = {}; + analytics.forEach(day => { + Object.keys(day.categoryBreakdown).forEach(category => { + if (!categoryBreakdown[category]) { + categoryBreakdown[category] = { predictions: 0, correct: 0 }; + } + categoryBreakdown[category].predictions += day.categoryBreakdown[category].predictions; + categoryBreakdown[category].correct += day.categoryBreakdown[category].correct; + }); + }); + + Object.keys(categoryBreakdown).forEach(category => { + categoryBreakdown[category].accuracy = categoryBreakdown[category].correct / categoryBreakdown[category].predictions; + }); + + return { + totalPredictions, + overallAccuracy, + averageConfidence, + methodBreakdown, + categoryBreakdown, + dailyStats: analytics.map(day => ({ + date: day.date, + predictions: day.totalPredictions, + accuracy: day.accuracy, + confidence: day.averageConfidence + })) + }; +}; + +module.exports = mongoose.model('CategoryAnalytics', categoryAnalyticsSchema); diff --git a/models/CategoryModel.js b/models/CategoryModel.js new file mode 100644 index 00000000..d03020de --- /dev/null +++ b/models/CategoryModel.js @@ -0,0 +1,93 @@ +const mongoose = require('mongoose'); + +const categoryModelSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + modelData: { + type: Buffer, + required: true + }, + modelType: { + type: String, + enum: ['tensorflow', 'brainjs'], + default: 'tensorflow' + }, + version: { + type: Number, + default: 1 + }, + accuracy: { + type: Number, + default: 0, + min: 0, + max: 1 + }, + trainingSamples: { + type: Number, + default: 0 + }, + lastTrained: { + type: Date, + default: Date.now + }, + isActive: { + type: Boolean, + default: true + }, + metadata: { + layers: Number, + inputSize: Number, + outputSize: Number, + trainingTime: Number, + epochs: Number + } +}, { + timestamps: true +}); + +// Indexes +categoryModelSchema.index({ user: 1, isActive: 1 }); +categoryModelSchema.index({ user: 1, version: -1 }); + +// Static method to get active model for user +categoryModelSchema.statics.getActiveModel = async function(userId) { + return await this.findOne({ + user: userId, + isActive: true + }).sort({ version: -1 }); +}; + +// Static method to save model +categoryModelSchema.statics.saveModel = async function(userId, modelData, metadata = {}) { + // Deactivate previous models + await this.updateMany( + { user: userId, isActive: true }, + { $set: { isActive: false } } + ); + + // Get next version + const lastModel = await this.findOne({ user: userId }).sort({ version: -1 }); + const nextVersion = lastModel ? lastModel.version + 1 : 1; + + const newModel = new this({ + user: userId, + modelData, + version: nextVersion, + metadata, + lastTrained: new Date() + }); + + return await newModel.save(); +}; + +// Static method to get model history +categoryModelSchema.statics.getModelHistory = async function(userId, limit = 10) { + return await this.find({ user: userId }) + .sort({ version: -1 }) + .limit(limit); +}; + +module.exports = mongoose.model('CategoryModel', categoryModelSchema); diff --git a/models/SharedBudget.js b/models/SharedBudget.js new file mode 100644 index 00000000..fa7e2d56 --- /dev/null +++ b/models/SharedBudget.js @@ -0,0 +1,131 @@ +const mongoose = require('mongoose'); + +const sharedBudgetSchema = new mongoose.Schema({ + group: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Group', + required: true + }, + name: { + type: String, + required: true, + trim: true, + maxlength: 100 + }, + totalAmount: { + type: Number, + required: true, + min: 0 + }, + categoryAllocations: [{ + category: { + type: String, + enum: ['food', 'transport', 'entertainment', 'utilities', 'healthcare', 'shopping', 'other', 'all'], + required: true + }, + amount: { + type: Number, + required: true, + min: 0 + } + }], + memberContributions: [{ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + amount: { + type: Number, + required: true, + min: 0 + } + }], + spent: { + type: Number, + default: 0 + }, + period: { + type: String, + enum: ['monthly', 'weekly', 'yearly'], + default: 'monthly' + }, + startDate: { + type: Date, + required: true + }, + endDate: { + type: Date, + required: true + }, + alertThreshold: { + type: Number, + default: 80, // Alert at 80% of budget + min: 0, + max: 100 + }, + isActive: { + type: Boolean, + default: true + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + lastCalculated: { + type: Date, + default: Date.now + } +}, { + timestamps: true +}); + +// Index for efficient queries +sharedBudgetSchema.index({ group: 1, isActive: 1 }); +sharedBudgetSchema.index({ 'memberContributions.user': 1 }); + +// Virtual for remaining amount +sharedBudgetSchema.virtual('remaining').get(function() { + return this.totalAmount - this.spent; +}); + +// Method to calculate total spent from group expenses +sharedBudgetSchema.methods.calculateSpent = async function() { + const Group = mongoose.model('Group'); + const Expense = mongoose.model('Expense'); + + const group = await Group.findById(this.group).populate('expenses.expense'); + if (!group) return 0; + + let totalSpent = 0; + for (const expenseRef of group.expenses) { + const expense = await Expense.findById(expenseRef.expense); + if (expense && expense.date >= this.startDate && expense.date <= this.endDate) { + // Check if expense category matches any allocation + const allocation = this.categoryAllocations.find(alloc => alloc.category === expense.category || alloc.category === 'all'); + if (allocation) { + totalSpent += expense.amount; + } + } + } + + this.spent = totalSpent; + this.lastCalculated = new Date(); + return totalSpent; +}; + +// Method to check if budget is exceeded +sharedBudgetSchema.methods.isExceeded = function() { + return this.spent > this.totalAmount; +}; + +// Method to get alert status +sharedBudgetSchema.methods.getAlertStatus = function() { + const percentage = (this.spent / this.totalAmount) * 100; + if (percentage >= 100) return 'exceeded'; + if (percentage >= this.alertThreshold) return 'warning'; + return 'normal'; +}; + +module.exports = mongoose.model('SharedBudget', sharedBudgetSchema); diff --git a/public/ai-insights-dashboard.js b/public/ai-insights-dashboard.js new file mode 100644 index 00000000..e69de29b diff --git a/routes/categorization.js b/routes/categorization.js index 37b5571a..93dff00f 100644 --- a/routes/categorization.js +++ b/routes/categorization.js @@ -217,7 +217,7 @@ router.delete('/patterns', auth, async (req, res) => { router.get('/stats', auth, async (req, res) => { try { const stats = await categorizationService.getUserStats(req.user._id); - + res.json({ success: true, data: stats @@ -232,4 +232,62 @@ router.get('/stats', auth, async (req, res) => { } }); +/** + * @route GET /api/categorization/analytics + * @desc Get user's categorization analytics + * @access Private + */ +router.get('/analytics', auth, async (req, res) => { + try { + const { days = 30 } = req.query; + const CategoryAnalytics = require('../models/CategoryAnalytics'); + + const analytics = await CategoryAnalytics.getUserAnalytics(req.user._id, parseInt(days)); + + res.json({ + success: true, + data: analytics + }); + } catch (error) { + console.error('Get analytics error:', error); + res.status(500).json({ + success: false, + message: 'Error fetching analytics', + error: error.message + }); + } +}); + +/** + * @route POST /api/categorization/record-prediction + * @desc Record a prediction result for analytics + * @access Private + */ +router.post('/record-prediction', auth, async (req, res) => { + try { + const { prediction, actualCategory, confidence } = req.body; + const CategoryAnalytics = require('../models/CategoryAnalytics'); + + const analytics = await CategoryAnalytics.recordPrediction( + req.user._id, + prediction, + actualCategory, + confidence + ); + + res.json({ + success: true, + message: 'Prediction recorded', + data: analytics + }); + } catch (error) { + console.error('Record prediction error:', error); + res.status(500).json({ + success: false, + message: 'Error recording prediction', + error: error.message + }); + } +}); + module.exports = router; diff --git a/routes/sharedBudgets.js b/routes/sharedBudgets.js new file mode 100644 index 00000000..56c0965a --- /dev/null +++ b/routes/sharedBudgets.js @@ -0,0 +1,142 @@ +const express = require('express'); +const Joi = require('joi'); +const auth = require('../middleware/auth'); +const SharedBudget = require('../models/SharedBudget'); +const Group = require('../models/Group'); + +const router = express.Router(); + +const sharedBudgetSchema = Joi.object({ + group: Joi.string().required(), + name: Joi.string().trim().max(100).required(), + totalAmount: Joi.number().min(0).required(), + categoryAllocations: Joi.array().items(Joi.object({ + category: Joi.string().valid('food', 'transport', 'entertainment', 'utilities', 'healthcare', 'shopping', 'other', 'all').required(), + amount: Joi.number().min(0).required() + })).required(), + memberContributions: Joi.array().items(Joi.object({ + user: Joi.string().required(), + amount: Joi.number().min(0).required() + })).required(), + period: Joi.string().valid('monthly', 'weekly', 'yearly').default('monthly'), + startDate: Joi.date().required(), + endDate: Joi.date().required(), + alertThreshold: Joi.number().min(0).max(100).default(80) +}); + +// Middleware to check group membership +const checkGroupMembership = async (req, res, next) => { + try { + const groupId = req.body.group || req.params.groupId; + const group = await Group.findById(groupId); + if (!group || !group.isMember(req.user._id)) { + return res.status(403).json({ error: 'Access denied. Not a member of this group.' }); + } + req.group = group; + next(); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; + +// Create shared budget +router.post('/', auth, checkGroupMembership, async (req, res) => { + try { + const { error, value } = sharedBudgetSchema.validate(req.body); + if (error) return res.status(400).json({ error: error.details[0].message }); + + const sharedBudget = new SharedBudget({ ...value, createdBy: req.user._id }); + await sharedBudget.save(); + + res.status(201).json(sharedBudget); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get shared budgets for user's groups +router.get('/', auth, async (req, res) => { + try { + const userGroups = await Group.find({ 'members.user': req.user._id, 'members.isActive': true, isActive: true }); + const groupIds = userGroups.map(g => g._id); + + const sharedBudgets = await SharedBudget.find({ group: { $in: groupIds }, isActive: true }) + .populate('group', 'name') + .populate('createdBy', 'name') + .sort({ createdAt: -1 }); + + // Calculate spent for each + for (const budget of sharedBudgets) { + await budget.calculateSpent(); + } + + res.json(sharedBudgets); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get specific shared budget +router.get('/:id', auth, async (req, res) => { + try { + const sharedBudget = await SharedBudget.findById(req.params.id) + .populate('group', 'name members') + .populate('createdBy', 'name') + .populate('memberContributions.user', 'name'); + + if (!sharedBudget) return res.status(404).json({ error: 'Shared budget not found' }); + + // Check if user is member of the group + if (!sharedBudget.group.members.some(m => m.user.toString() === req.user._id.toString() && m.isActive)) { + return res.status(403).json({ error: 'Access denied' }); + } + + await sharedBudget.calculateSpent(); + res.json(sharedBudget); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Update shared budget +router.put('/:id', auth, async (req, res) => { + try { + const sharedBudget = await SharedBudget.findById(req.params.id).populate('group'); + if (!sharedBudget) return res.status(404).json({ error: 'Shared budget not found' }); + + // Check membership + if (!sharedBudget.group.isMember(req.user._id)) { + return res.status(403).json({ error: 'Access denied' }); + } + + const { error, value } = sharedBudgetSchema.validate(req.body); + if (error) return res.status(400).json({ error: error.details[0].message }); + + Object.assign(sharedBudget, value); + await sharedBudget.save(); + + res.json(sharedBudget); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Delete shared budget +router.delete('/:id', auth, async (req, res) => { + try { + const sharedBudget = await SharedBudget.findById(req.params.id).populate('group'); + if (!sharedBudget) return res.status(404).json({ error: 'Shared budget not found' }); + + // Check membership + if (!sharedBudget.group.isMember(req.user._id)) { + return res.status(403).json({ error: 'Access denied' }); + } + + await SharedBudget.findByIdAndDelete(req.params.id); + res.json({ message: 'Shared budget deleted successfully' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/services/aiInsightsService.js b/services/aiInsightsService.js index 249e6e6c..88be46d6 100644 --- a/services/aiInsightsService.js +++ b/services/aiInsightsService.js @@ -6,6 +6,7 @@ const AnalyticsCache = require('../models/AnalyticsCache'); const notificationService = require('./notificationService'); const mongoose = require('mongoose'); const crypto = require('crypto'); +const tf = require('@tensorflow/tfjs-node'); /** * Smart Budget Forecasting & AI Financial Insights Service diff --git a/services/groupService.js b/services/groupService.js index f7f284ea..8694dbfe 100644 --- a/services/groupService.js +++ b/services/groupService.js @@ -1,6 +1,7 @@ const Group = require('../models/Group'); const User = require('../models/User'); const Expense = require('../models/Expense'); +const SharedBudget = require('../models/SharedBudget'); const notificationService = require('./notificationService'); class GroupService {