UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

347 lines (308 loc) 15.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_underspending_analysis', description: 'Identify categories with unused budget allocations and provide optimization recommendations for better budget efficiency', 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 underspending patterns (default: 6, max: 24)', minimum: 1, maximum: 24, default: 6 }, min_unused_amount: { type: 'number', description: 'Minimum unused amount to flag per month (default: 25.00)', minimum: 0, default: 25 }, min_underspend_frequency: { type: 'number', description: 'Minimum percentage of months underspent to flag (default: 60%, range: 0-100)', minimum: 0, maximum: 100, default: 60 }, include_current_month: { type: 'boolean', description: 'Include current month in analysis (default: false - current month may be incomplete)', default: false }, category_group_id: { type: 'string', description: 'Analyze only categories within this group (optional)' }, exclude_goal_categories: { type: 'boolean', description: 'Exclude categories with active savings goals (default: true)', default: true } }, additionalProperties: false } }; const handler = async (params) => { try { const ynab = new YnabClient(); const { budget_id, months_back = 6, min_unused_amount = 25, min_underspend_frequency = 60, include_current_month = false, category_group_id, exclude_goal_categories = true } = params; // Generate cache key const cacheKey = cache.generateKey('underspending_analysis', params); return await cache.getOrSet(cacheKey, async () => { const budgetId = budget_id || await ynab.getDefaultBudgetId(); // Get budget months to analyze const budgetMonths = await ynab.getBudgetMonths(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 { underspent_categories: [], analysis_summary: { months_analyzed: 0, categories_analyzed: 0, underspent_categories_found: 0, total_unused_budget: 0, potential_reallocation: 0 }, analysis_period: { start_month: null, end_month: null, months_count: 0 }, filters_applied: { budget_id: budgetId, months_back, min_unused_amount, min_underspend_frequency, include_current_month, category_group_id: category_group_id || null, exclude_goal_categories } }; } // 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; // Skip categories with goals if requested if (exclude_goal_categories && category.goal_type && category.goal_type !== 'none') 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 underspending (positive balance when budgeted indicates unused budget) // Only count as underspending if budget was allocated but not fully used const actualSpent = Math.abs(activity); const underSpent = budgeted > 0 && actualSpent < budgeted ? budgeted - actualSpent : 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_underspent: 0, total_budgeted: 0, total_activity: 0, total_underspent: 0, average_budgeted: 0, average_activity: 0, average_underspent: 0, underspend_frequency: 0, max_underspend: 0, goal_type: category.goal_type || null, goal_target: category.goal_target ? ynab.milliunitsToAmount(category.goal_target) : null, utilization_rate: 0 // Percentage of budget actually used }); } const categoryData = categoryAnalysis.get(categoryId); // Add monthly data categoryData.monthly_data.push({ month: month, budgeted: budgeted, activity: actualSpent, balance: balance, underspent: underSpent, utilization_rate: budgeted > 0 ? (actualSpent / budgeted) * 100 : 0 }); categoryData.months_analyzed++; categoryData.total_budgeted += budgeted; categoryData.total_activity += actualSpent; categoryData.total_underspent += underSpent; if (underSpent > 0) { categoryData.months_underspent++; categoryData.max_underspend = Math.max(categoryData.max_underspend, underSpent); } }); } }); }); // 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.average_underspent = categoryData.total_underspent / categoryData.months_analyzed; categoryData.underspend_frequency = (categoryData.months_underspent / categoryData.months_analyzed) * 100; categoryData.utilization_rate = categoryData.total_budgeted > 0 ? (categoryData.total_activity / categoryData.total_budgeted) * 100 : 0; } // 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 underspending criteria const underspentCategories = Array.from(categoryAnalysis.values()).filter(category => { return category.average_underspent >= min_unused_amount && category.underspend_frequency >= min_underspend_frequency && category.total_budgeted > 0; // Only include categories that had budget allocated }); // Sort by total underspent amount (highest first) underspentCategories.sort((a, b) => b.total_underspent - a.total_underspent); // Generate recommendations for each underspent category const categoriesWithRecommendations = underspentCategories.map(category => { const recommendations = []; const optimizedBudget = Math.ceil(category.average_activity * 1.1); // 110% of actual usage const potentialReallocation = category.average_budgeted - optimizedBudget; // Budget optimization recommendations if (category.utilization_rate < 50) { recommendations.push(`Low utilization (${category.utilization_rate.toFixed(1)}%) - consider reducing budget by $${potentialReallocation.toFixed(2)}`); } else if (category.utilization_rate < 75) { recommendations.push(`Moderate utilization (${category.utilization_rate.toFixed(1)}%) - could reduce budget by $${potentialReallocation.toFixed(2)}`); } // Frequency-based recommendations if (category.underspend_frequency > 80) { recommendations.push('Consistent underspending - budget appears too high for actual needs'); } else if (category.underspend_frequency > 60) { recommendations.push('Frequent underspending - review if budget reflects realistic spending'); } // Seasonal or pattern recommendations const recentUtilization = category.monthly_data.slice(0, 3) .reduce((sum, month) => sum + month.utilization_rate, 0) / 3; const olderUtilization = category.monthly_data.slice(3) .reduce((sum, month) => sum + month.utilization_rate, 0) / Math.max(category.monthly_data.slice(3).length, 1); if (recentUtilization < olderUtilization * 0.8) { recommendations.push('Utilization decreasing - spending patterns may have changed'); } // Zero spending recommendations const zeroSpendingMonths = category.monthly_data.filter(m => m.activity === 0).length; if (zeroSpendingMonths > category.months_analyzed * 0.5) { recommendations.push('Frequent zero spending - consider if this category is still needed'); } if (recommendations.length === 0) { recommendations.push('Monitor spending patterns and consider budget adjustment'); } return { ...category, recommendations, optimized_budget_suggestion: optimizedBudget, potential_reallocation: Math.max(0, potentialReallocation), efficiency_score: Math.min(100, category.utilization_rate), // Cap at 100% priority_level: category.utilization_rate < 50 ? 'high' : category.utilization_rate < 75 ? 'medium' : 'low' }; }); // Calculate analysis summary const totalCategoriesAnalyzed = categoryAnalysis.size; const totalUnusedBudget = underspentCategories.reduce((sum, cat) => sum + cat.total_underspent, 0); const totalPotentialReallocation = categoriesWithRecommendations.reduce((sum, cat) => sum + cat.potential_reallocation, 0); const highPriorityCount = categoriesWithRecommendations.filter(cat => cat.priority_level === 'high').length; const mediumPriorityCount = categoriesWithRecommendations.filter(cat => cat.priority_level === 'medium').length; const lowPriorityCount = categoriesWithRecommendations.filter(cat => cat.priority_level === 'low').length; // Identify reallocation opportunities const reallocationOpportunities = categoriesWithRecommendations .filter(cat => cat.potential_reallocation > 50) .sort((a, b) => b.potential_reallocation - a.potential_reallocation) .slice(0, 5) .map(cat => ({ category_name: cat.name, current_budget: cat.average_budgeted, suggested_budget: cat.optimized_budget_suggestion, potential_reallocation: cat.potential_reallocation, utilization_rate: cat.utilization_rate })); return { underspent_categories: categoriesWithRecommendations, analysis_summary: { months_analyzed: monthsToAnalyze.length, categories_analyzed: totalCategoriesAnalyzed, underspent_categories_found: underspentCategories.length, total_unused_budget: totalUnusedBudget, potential_reallocation: totalPotentialReallocation, average_unused_per_category: underspentCategories.length > 0 ? totalUnusedBudget / underspentCategories.length : 0, priority_breakdown: { high: highPriorityCount, medium: mediumPriorityCount, low: lowPriorityCount } }, reallocation_opportunities: reallocationOpportunities, analysis_period: { start_month: monthsToAnalyze[monthsToAnalyze.length - 1]?.month || null, end_month: monthsToAnalyze[0]?.month || null, months_count: monthsToAnalyze.length }, optimization_suggestions: [ 'Focus on high priority categories for maximum budget efficiency gain', 'Consider reallocating unused funds to savings goals or debt payments', 'Review categories with zero spending to eliminate unnecessary budget line items', 'Use historical utilization rates to set more realistic future budgets', `Potential monthly reallocation: $${(totalPotentialReallocation / monthsToAnalyze.length).toFixed(2)}` ], filters_applied: { budget_id: budgetId, months_back, min_unused_amount, min_underspend_frequency, include_current_month, category_group_id: category_group_id || null, exclude_goal_categories } }; }, 15); // Cache for 15 minutes (category data changes less frequently) } catch (error) { return errorHandler.formatForMCP(error); } }; export { toolDefinition, handler };