mcp-ynab
Version:
Model Context Protocol server for YNAB integration
347 lines (308 loc) • 15.1 kB
JavaScript
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 };