mcp-ynab
Version:
Model Context Protocol server for YNAB integration
299 lines (262 loc) • 12.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_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 };