UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

299 lines (262 loc) 12.1 kB
import { YnabClient } from '../../shared/ynab-client.js'; import { CacheManager } from '../../shared/cache-manager.js'; import { ErrorHandler } from '../../shared/error-handler.js'; const cache = new CacheManager(); const errorHandler = new ErrorHandler(); const toolDefinition = { name: 'get_overspending_analysis', description: 'Identify categories with consistent overspending patterns and provide actionable recommendations', inputSchema: { type: 'object', properties: { budget_id: { type: 'string', description: 'Specific budget ID (optional, defaults to default budget)' }, months_back: { type: 'integer', description: 'Number of months to analyze for overspending patterns (default: 6, max: 24)', minimum: 1, maximum: 24, default: 6 }, min_overspend_amount: { type: 'number', description: 'Minimum overspend amount to flag (default: 10.00)', minimum: 0, default: 10 }, min_overspend_frequency: { type: 'number', description: 'Minimum percentage of months overspent to flag (default: 50%, range: 0-100)', minimum: 0, maximum: 100, default: 50 }, include_current_month: { type: 'boolean', description: 'Include current month in analysis (default: true)', default: true }, category_group_id: { type: 'string', description: 'Analyze only categories within this group (optional)' } }, additionalProperties: false } }; const handler = async (params) => { try { const ynab = new YnabClient(); const { budget_id, months_back = 6, min_overspend_amount = 10, min_overspend_frequency = 50, include_current_month = true, category_group_id } = params; // Generate cache key const cacheKey = cache.generateKey('overspending_analysis', params); return await cache.getOrSet(cacheKey, async () => { const budgetId = budget_id || await ynab.getDefaultBudgetId(); // Get budget months to analyze const budgetMonths = await ynab.getMonths(budgetId); // Sort months by date and get the range we need const sortedMonths = budgetMonths.sort((a, b) => new Date(b.month) - new Date(a.month)); // Determine which months to analyze let monthsToAnalyze = sortedMonths.slice(0, months_back); if (!include_current_month && monthsToAnalyze.length > 0) { monthsToAnalyze = monthsToAnalyze.slice(1); // Remove current month } if (monthsToAnalyze.length === 0) { return { overspent_categories: [], analysis_summary: { months_analyzed: 0, categories_analyzed: 0, overspent_categories_found: 0, total_overspending: 0 }, analysis_period: { start_month: null, end_month: null, months_count: 0 }, filters_applied: { budget_id: budgetId, months_back, min_overspend_amount, min_overspend_frequency, include_current_month, category_group_id: category_group_id || null } }; } // Get categories for each month const monthlyData = await Promise.all( monthsToAnalyze.map(async (month) => { const categories = await ynab.getCategories(budgetId, month.month); return { month: month.month, categories: categories }; }) ); // Build category analysis const categoryAnalysis = new Map(); // Process each month's category data monthlyData.forEach(({ month, categories }) => { categories.forEach(group => { // Skip if we're filtering by group and this isn't the right group if (category_group_id && group.id !== category_group_id) return; if (group.categories) { group.categories.forEach(category => { // Skip system categories if (category.hidden || category.deleted) return; const categoryId = category.id; const budgeted = ynab.milliunitsToAmount(category.budgeted || 0); const activity = ynab.milliunitsToAmount(category.activity || 0); const balance = ynab.milliunitsToAmount(category.balance || 0); // Calculate overspending (negative balance indicates overspending) const overspent = balance < 0 ? Math.abs(balance) : 0; if (!categoryAnalysis.has(categoryId)) { categoryAnalysis.set(categoryId, { id: categoryId, name: category.name, group_id: group.id, group_name: group.name, monthly_data: [], months_analyzed: 0, months_overspent: 0, total_budgeted: 0, total_activity: 0, total_overspent: 0, average_budgeted: 0, average_activity: 0, overspend_frequency: 0, max_overspend: 0, goal_type: category.goal_type || null, goal_target: category.goal_target ? ynab.milliunitsToAmount(category.goal_target) : null }); } const categoryData = categoryAnalysis.get(categoryId); // Add monthly data categoryData.monthly_data.push({ month: month, budgeted: budgeted, activity: Math.abs(activity), // Make activity positive for spending balance: balance, overspent: overspent }); categoryData.months_analyzed++; categoryData.total_budgeted += budgeted; categoryData.total_activity += Math.abs(activity); categoryData.total_overspent += overspent; if (overspent > 0) { categoryData.months_overspent++; categoryData.max_overspend = Math.max(categoryData.max_overspend, overspent); } }); } }); }); // Calculate averages and frequencies categoryAnalysis.forEach(categoryData => { if (categoryData.months_analyzed > 0) { categoryData.average_budgeted = categoryData.total_budgeted / categoryData.months_analyzed; categoryData.average_activity = categoryData.total_activity / categoryData.months_analyzed; categoryData.overspend_frequency = (categoryData.months_overspent / categoryData.months_analyzed) * 100; } // Sort monthly data by date (most recent first) categoryData.monthly_data.sort((a, b) => new Date(b.month) - new Date(a.month)); }); // Filter categories that meet overspending criteria const overspentCategories = Array.from(categoryAnalysis.values()).filter(category => { return category.total_overspent >= min_overspend_amount && category.overspend_frequency >= min_overspend_frequency; }); // Sort by total overspent amount (highest first) overspentCategories.sort((a, b) => b.total_overspent - a.total_overspent); // Generate recommendations for each overspent category const categoriesWithRecommendations = overspentCategories.map(category => { const recommendations = []; // Budget adjustment recommendation if (category.average_activity > category.average_budgeted * 1.1) { const suggestedBudget = Math.ceil(category.average_activity * 1.1); recommendations.push(`Consider increasing monthly budget to $${suggestedBudget.toFixed(2)} (110% of average spending)`); } // Frequency-based recommendations if (category.overspend_frequency > 75) { recommendations.push('Chronic overspending - review if this category is realistically budgeted'); } else if (category.overspend_frequency > 50) { recommendations.push('Frequent overspending - consider splitting into more specific subcategories'); } // Goal-related recommendations if (category.goal_type) { recommendations.push('Has active goal - overspending may impact goal progress'); } // Activity pattern recommendations const recentActivity = category.monthly_data.slice(0, 3).reduce((sum, month) => sum + month.activity, 0) / 3; const olderActivity = category.monthly_data.slice(3).reduce((sum, month) => sum + month.activity, 0) / Math.max(category.monthly_data.slice(3).length, 1); if (recentActivity > olderActivity * 1.2) { recommendations.push('Spending trend increasing - review recent transactions for new patterns'); } if (recommendations.length === 0) { recommendations.push('Monitor spending patterns and consider budget adjustment'); } return { ...category, recommendations, severity: category.overspend_frequency > 75 ? 'high' : category.overspend_frequency > 60 ? 'medium' : 'low' }; }); // Calculate analysis summary const totalCategoriesAnalyzed = categoryAnalysis.size; const totalOverspending = overspentCategories.reduce((sum, cat) => sum + cat.total_overspent, 0); const highSeverityCount = categoriesWithRecommendations.filter(cat => cat.severity === 'high').length; const mediumSeverityCount = categoriesWithRecommendations.filter(cat => cat.severity === 'medium').length; const lowSeverityCount = categoriesWithRecommendations.filter(cat => cat.severity === 'low').length; return { overspent_categories: categoriesWithRecommendations, analysis_summary: { months_analyzed: monthsToAnalyze.length, categories_analyzed: totalCategoriesAnalyzed, overspent_categories_found: overspentCategories.length, total_overspending: totalOverspending, severity_breakdown: { high: highSeverityCount, medium: mediumSeverityCount, low: lowSeverityCount }, average_overspend_per_category: overspentCategories.length > 0 ? totalOverspending / overspentCategories.length : 0 }, analysis_period: { start_month: monthsToAnalyze[monthsToAnalyze.length - 1]?.month || null, end_month: monthsToAnalyze[0]?.month || null, months_count: monthsToAnalyze.length }, recommendations_summary: [ 'Focus on high severity categories first for maximum impact', 'Consider increasing budgets for categories with consistent overspending', 'Review transaction patterns for categories with increasing spending trends', 'Split broad categories that frequently overspend into more specific subcategories' ], filters_applied: { budget_id: budgetId, months_back, min_overspend_amount, min_overspend_frequency, include_current_month, category_group_id: category_group_id || null } }; }, 15); // Cache for 15 minutes (category data changes less frequently) } catch (error) { return errorHandler.formatForMCP(error); } }; export { toolDefinition, handler };