mcp-ynab
Version:
Model Context Protocol server for YNAB integration
536 lines (461 loc) • 21 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_cash_flow_analysis',
description: 'Analyze income vs expenses over time with trends, forecasting, and cash flow insights for financial planning',
inputSchema: {
type: 'object',
properties: {
budget_id: {
type: 'string',
description: 'Specific budget ID (optional, defaults to default budget)'
},
analysis_period: {
type: 'string',
enum: ['last_3_months', 'last_6_months', 'last_year', 'year_to_date', 'all_time'],
description: 'Time period for cash flow analysis (default: last_6_months)',
default: 'last_6_months'
},
include_transfers: {
type: 'boolean',
description: 'Include transfer transactions in cash flow analysis (default: false)',
default: false
},
group_by: {
type: 'string',
enum: ['month', 'quarter'],
description: 'Group cash flow data by time period (default: month)',
default: 'month'
},
on_budget_only: {
type: 'boolean',
description: 'Include only on-budget accounts (default: true)',
default: true
},
include_forecast: {
type: 'boolean',
description: 'Include cash flow forecast based on scheduled transactions (default: true)',
default: true
},
forecast_months: {
type: 'integer',
description: 'Number of months to forecast ahead (default: 3, max: 12)',
minimum: 1,
maximum: 12,
default: 3
}
},
additionalProperties: false
}
};
const handler = async (params) => {
try {
const ynab = new YnabClient();
const {
budget_id,
analysis_period = 'last_6_months',
include_transfers = false,
group_by = 'month',
on_budget_only = true,
include_forecast = true,
forecast_months = 3
} = params;
// Generate cache key
const cacheKey = cache.generateKey('cash_flow_analysis', params);
return await cache.getOrSet(cacheKey, async () => {
const budgetId = budget_id || await ynab.getDefaultBudgetId();
// Calculate date range based on analysis period
const endDate = new Date();
let startDate = new Date();
switch (analysis_period) {
case 'last_3_months':
startDate.setMonth(endDate.getMonth() - 3);
break;
case 'last_6_months':
startDate.setMonth(endDate.getMonth() - 6);
break;
case 'last_year':
startDate.setFullYear(endDate.getFullYear() - 1);
break;
case 'year_to_date':
startDate = new Date(endDate.getFullYear(), 0, 1);
break;
case 'all_time':
startDate.setFullYear(endDate.getFullYear() - 5); // Reasonable limit
break;
}
// Get accounts and filter if needed
const accounts = await ynab.getAccounts(budgetId);
const filteredAccounts = on_budget_only ?
accounts.filter(acc => acc.on_budget && !acc.closed) :
accounts.filter(acc => !acc.closed);
// Get transactions for analysis period
const transactions = await ynab.getTransactions(budgetId, {
sinceDate: startDate.toISOString().split('T')[0],
untilDate: endDate.toISOString().split('T')[0],
limit: 1000
});
// Filter transactions by account and transfer preference
let filteredTransactions = transactions.filter(transaction => {
// Filter by account
const account = filteredAccounts.find(acc => acc.id === transaction.account_id);
if (!account) return false;
// Filter transfers if not included
if (!include_transfers && transaction.transfer_account_id) return false;
return true;
});
// Get additional data for enrichment
const [categories, payees] = await Promise.all([
ynab.getCategories(budgetId),
ynab.getPayees(budgetId)
]);
// Create lookup maps
const accountMap = new Map(accounts.map(acc => [acc.id, acc]));
const payeeMap = new Map(payees.map(payee => [payee.id, payee]));
const categoryMap = new Map();
categories.forEach(group => {
if (group.categories) {
group.categories.forEach(cat => {
categoryMap.set(cat.id, { ...cat, group_name: group.name });
});
}
});
// Analyze cash flow by time period
const cashFlowData = analyzeCashFlowByPeriod(filteredTransactions, group_by, startDate, endDate);
// Calculate trends and statistics
const trendAnalysis = calculateCashFlowTrends(cashFlowData);
// Analyze by categories and payees
const categoryAnalysis = analyzeCashFlowByCategory(filteredTransactions, categoryMap);
const payeeAnalysis = analyzeCashFlowByPayee(filteredTransactions, payeeMap);
// Get forecast data if requested
let forecastData = null;
if (include_forecast) {
try {
const scheduledTransactions = await ynab.getScheduledTransactions(budgetId);
forecastData = generateCashFlowForecast(scheduledTransactions, accountMap, forecast_months);
} catch (error) {
// Forecast is optional, continue without it if it fails
console.warn('Failed to generate forecast:', error.message);
}
}
// Calculate summary statistics
const totalIncome = cashFlowData.reduce((sum, period) => sum + period.income, 0);
const totalExpenses = cashFlowData.reduce((sum, period) => sum + period.expenses, 0);
const netCashFlow = totalIncome - totalExpenses;
const averageMonthlyIncome = totalIncome / Math.max(cashFlowData.length, 1);
const averageMonthlyExpenses = totalExpenses / Math.max(cashFlowData.length, 1);
const averageMonthlyNet = netCashFlow / Math.max(cashFlowData.length, 1);
// Income vs expense ratio
const incomeExpenseRatio = totalExpenses > 0 ? totalIncome / totalExpenses : 0;
// Cash flow health assessment
let cashFlowHealth = 'unknown';
if (averageMonthlyNet > 500) {
cashFlowHealth = 'excellent';
} else if (averageMonthlyNet > 0) {
cashFlowHealth = 'positive';
} else if (averageMonthlyNet > -200) {
cashFlowHealth = 'tight';
} else {
cashFlowHealth = 'concerning';
}
return {
cash_flow_periods: cashFlowData,
summary: {
analysis_period: analysis_period,
periods_analyzed: cashFlowData.length,
total_income: totalIncome,
total_expenses: totalExpenses,
net_cash_flow: netCashFlow,
average_monthly_income: averageMonthlyIncome,
average_monthly_expenses: averageMonthlyExpenses,
average_monthly_net: averageMonthlyNet,
income_expense_ratio: incomeExpenseRatio,
cash_flow_health: cashFlowHealth
},
trends: trendAnalysis,
category_breakdown: {
top_income_categories: categoryAnalysis.income.slice(0, 10),
top_expense_categories: categoryAnalysis.expenses.slice(0, 10)
},
payee_breakdown: {
top_income_payees: payeeAnalysis.income.slice(0, 10),
top_expense_payees: payeeAnalysis.expenses.slice(0, 10)
},
forecast: forecastData,
insights: generateCashFlowInsights(cashFlowData, trendAnalysis, cashFlowHealth),
recommendations: generateCashFlowRecommendations(trendAnalysis, cashFlowHealth, incomeExpenseRatio),
analysis_period_details: {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
accounts_included: filteredAccounts.length,
transactions_analyzed: filteredTransactions.length
},
filters_applied: {
budget_id: budgetId,
analysis_period,
include_transfers,
group_by,
on_budget_only,
include_forecast,
forecast_months
}
};
}, 10); // Cache for 10 minutes
} catch (error) {
return errorHandler.formatForMCP(error);
}
};
// Helper function to analyze cash flow by time periods
function analyzeCashFlowByPeriod(transactions, groupBy, startDate, endDate) {
const periods = new Map();
transactions.forEach(transaction => {
const transactionDate = new Date(transaction.date);
let periodKey;
if (groupBy === 'quarter') {
const quarter = Math.floor(transactionDate.getMonth() / 3) + 1;
periodKey = `${transactionDate.getFullYear()}-Q${quarter}`;
} else {
periodKey = `${transactionDate.getFullYear()}-${String(transactionDate.getMonth() + 1).padStart(2, '0')}`;
}
if (!periods.has(periodKey)) {
periods.set(periodKey, {
period: periodKey,
income: 0,
expenses: 0,
net_flow: 0,
transaction_count: 0,
income_transactions: 0,
expense_transactions: 0
});
}
const period = periods.get(periodKey);
const amount = transaction.amount / 1000; // Convert from milliunits
period.transaction_count++;
if (amount > 0) {
period.income += amount;
period.income_transactions++;
} else {
period.expenses += Math.abs(amount);
period.expense_transactions++;
}
period.net_flow = period.income - period.expenses;
});
// Convert to array and sort by period
return Array.from(periods.values()).sort((a, b) => a.period.localeCompare(b.period));
}
// Helper function to calculate trends
function calculateCashFlowTrends(cashFlowData) {
if (cashFlowData.length < 2) {
return {
income_trend: 'insufficient_data',
expense_trend: 'insufficient_data',
net_flow_trend: 'insufficient_data',
volatility: 'unknown'
};
}
const midpoint = Math.floor(cashFlowData.length / 2);
const firstHalf = cashFlowData.slice(0, midpoint);
const secondHalf = cashFlowData.slice(midpoint);
const firstHalfAvgIncome = firstHalf.reduce((sum, p) => sum + p.income, 0) / firstHalf.length;
const secondHalfAvgIncome = secondHalf.reduce((sum, p) => sum + p.income, 0) / secondHalf.length;
const firstHalfAvgExpenses = firstHalf.reduce((sum, p) => sum + p.expenses, 0) / firstHalf.length;
const secondHalfAvgExpenses = secondHalf.reduce((sum, p) => sum + p.expenses, 0) / secondHalf.length;
const firstHalfAvgNet = firstHalf.reduce((sum, p) => sum + p.net_flow, 0) / firstHalf.length;
const secondHalfAvgNet = secondHalf.reduce((sum, p) => sum + p.net_flow, 0) / secondHalf.length;
const incomeTrend = secondHalfAvgIncome > firstHalfAvgIncome * 1.05 ? 'increasing' :
secondHalfAvgIncome < firstHalfAvgIncome * 0.95 ? 'decreasing' : 'stable';
const expenseTrend = secondHalfAvgExpenses > firstHalfAvgExpenses * 1.05 ? 'increasing' :
secondHalfAvgExpenses < firstHalfAvgExpenses * 0.95 ? 'decreasing' : 'stable';
const netFlowTrend = secondHalfAvgNet > firstHalfAvgNet * 1.05 ? 'improving' :
secondHalfAvgNet < firstHalfAvgNet * 0.95 ? 'declining' : 'stable';
// Calculate volatility
const netFlowValues = cashFlowData.map(p => p.net_flow);
const avgNetFlow = netFlowValues.reduce((sum, val) => sum + val, 0) / netFlowValues.length;
const variance = netFlowValues.reduce((sum, val) => sum + Math.pow(val - avgNetFlow, 2), 0) / netFlowValues.length;
const standardDeviation = Math.sqrt(variance);
const coefficientOfVariation = Math.abs(avgNetFlow) > 0 ? standardDeviation / Math.abs(avgNetFlow) : 0;
const volatility = coefficientOfVariation > 0.5 ? 'high' :
coefficientOfVariation > 0.2 ? 'moderate' : 'low';
return {
income_trend: incomeTrend,
expense_trend: expenseTrend,
net_flow_trend: netFlowTrend,
volatility: volatility,
income_change_percentage: firstHalfAvgIncome > 0 ?
((secondHalfAvgIncome - firstHalfAvgIncome) / firstHalfAvgIncome) * 100 : 0,
expense_change_percentage: firstHalfAvgExpenses > 0 ?
((secondHalfAvgExpenses - firstHalfAvgExpenses) / firstHalfAvgExpenses) * 100 : 0,
net_flow_change_percentage: Math.abs(firstHalfAvgNet) > 0 ?
((secondHalfAvgNet - firstHalfAvgNet) / Math.abs(firstHalfAvgNet)) * 100 : 0
};
}
// Helper function to analyze cash flow by category
function analyzeCashFlowByCategory(transactions, categoryMap) {
const incomeCategories = new Map();
const expenseCategories = new Map();
transactions.forEach(transaction => {
const category = categoryMap.get(transaction.category_id);
const categoryName = category ? category.name : 'Uncategorized';
const amount = transaction.amount / 1000;
if (amount > 0) {
const current = incomeCategories.get(categoryName) || { category: categoryName, total: 0, count: 0 };
current.total += amount;
current.count++;
incomeCategories.set(categoryName, current);
} else {
const current = expenseCategories.get(categoryName) || { category: categoryName, total: 0, count: 0 };
current.total += Math.abs(amount);
current.count++;
expenseCategories.set(categoryName, current);
}
});
return {
income: Array.from(incomeCategories.values()).sort((a, b) => b.total - a.total),
expenses: Array.from(expenseCategories.values()).sort((a, b) => b.total - a.total)
};
}
// Helper function to analyze cash flow by payee
function analyzeCashFlowByPayee(transactions, payeeMap) {
const incomePayees = new Map();
const expensePayees = new Map();
transactions.forEach(transaction => {
if (transaction.transfer_account_id) return; // Skip transfers for payee analysis
const payee = payeeMap.get(transaction.payee_id);
const payeeName = payee ? payee.name : 'Unknown';
const amount = transaction.amount / 1000;
if (amount > 0) {
const current = incomePayees.get(payeeName) || { payee: payeeName, total: 0, count: 0 };
current.total += amount;
current.count++;
incomePayees.set(payeeName, current);
} else {
const current = expensePayees.get(payeeName) || { payee: payeeName, total: 0, count: 0 };
current.total += Math.abs(amount);
current.count++;
expensePayees.set(payeeName, current);
}
});
return {
income: Array.from(incomePayees.values()).sort((a, b) => b.total - a.total),
expenses: Array.from(expensePayees.values()).sort((a, b) => b.total - a.total)
};
}
// Helper function to generate cash flow forecast
function generateCashFlowForecast(scheduledTransactions, accountMap, forecastMonths) {
const forecastPeriods = [];
const today = new Date();
for (let month = 0; month < forecastMonths; month++) {
const monthStart = new Date(today.getFullYear(), today.getMonth() + month + 1, 1);
const monthEnd = new Date(today.getFullYear(), today.getMonth() + month + 2, 0);
let monthlyIncome = 0;
let monthlyExpenses = 0;
scheduledTransactions.forEach(st => {
if (st.deleted_at) return; // Skip inactive schedules
// Simple approximation - would need more complex logic for accurate frequency calculation
const amount = st.amount / 1000;
let monthlyAmount = 0;
switch (st.frequency) {
case 'monthly':
monthlyAmount = amount;
break;
case 'weekly':
monthlyAmount = amount * 4.33; // Average weeks per month
break;
case 'everyOtherWeek':
monthlyAmount = amount * 2.17;
break;
case 'twiceAMonth':
monthlyAmount = amount * 2;
break;
case 'quarterly':
case 'everyThreeMonths':
monthlyAmount = amount / 3;
break;
case 'yearly':
monthlyAmount = amount / 12;
break;
default:
monthlyAmount = 0;
}
if (monthlyAmount > 0) {
monthlyIncome += monthlyAmount;
} else {
monthlyExpenses += Math.abs(monthlyAmount);
}
});
forecastPeriods.push({
period: `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`,
projected_income: monthlyIncome,
projected_expenses: monthlyExpenses,
projected_net_flow: monthlyIncome - monthlyExpenses,
confidence: 'medium' // Based on scheduled transactions only
});
}
return forecastPeriods;
}
// Helper function to generate insights
function generateCashFlowInsights(cashFlowData, trendAnalysis, cashFlowHealth) {
const insights = [];
// Health-based insights
if (cashFlowHealth === 'excellent') {
insights.push('Strong positive cash flow - excellent position for savings and investments');
} else if (cashFlowHealth === 'positive') {
insights.push('Positive cash flow maintained - good financial foundation');
} else if (cashFlowHealth === 'tight') {
insights.push('Tight cash flow - monitor expenses and look for optimization opportunities');
} else if (cashFlowHealth === 'concerning') {
insights.push('Negative cash flow detected - immediate attention needed to reduce expenses or increase income');
}
// Trend-based insights
if (trendAnalysis.income_trend === 'increasing' && trendAnalysis.expense_trend === 'stable') {
insights.push('Income growing while expenses remain controlled - strong financial trajectory');
} else if (trendAnalysis.expense_trend === 'increasing' && trendAnalysis.income_trend === 'stable') {
insights.push('Expenses increasing faster than income - review spending patterns');
} else if (trendAnalysis.net_flow_trend === 'declining') {
insights.push('Net cash flow declining - investigate underlying causes');
}
// Volatility insights
if (trendAnalysis.volatility === 'high') {
insights.push('High cash flow volatility - consider building emergency fund and smoothing irregular income/expenses');
} else if (trendAnalysis.volatility === 'low') {
insights.push('Stable cash flow patterns - good foundation for long-term financial planning');
}
return insights;
}
// Helper function to generate recommendations
function generateCashFlowRecommendations(trendAnalysis, cashFlowHealth, incomeExpenseRatio) {
const recommendations = [];
// Health-based recommendations
if (cashFlowHealth === 'concerning') {
recommendations.push('Priority: Create action plan to reduce expenses or increase income immediately');
recommendations.push('Consider temporary spending freeze on non-essential categories');
} else if (cashFlowHealth === 'tight') {
recommendations.push('Look for expense reduction opportunities in largest spending categories');
recommendations.push('Build small emergency buffer to handle unexpected expenses');
} else if (cashFlowHealth === 'excellent') {
recommendations.push('Consider increasing savings rate or investing excess cash flow');
recommendations.push('Review if budget allocations are optimized for your goals');
}
// Trend-based recommendations
if (trendAnalysis.expense_trend === 'increasing') {
recommendations.push('Investigate expense increases and ensure they align with your priorities');
}
if (trendAnalysis.volatility === 'high') {
recommendations.push('Build emergency fund to smooth cash flow volatility');
recommendations.push('Consider averaging irregular income/expenses over multiple months');
}
// Ratio-based recommendations
if (incomeExpenseRatio < 1.1) {
recommendations.push('Very tight income-to-expense ratio - focus on increasing the gap');
} else if (incomeExpenseRatio > 1.5) {
recommendations.push('Healthy income-to-expense ratio - good position for wealth building');
}
if (recommendations.length === 0) {
recommendations.push('Cash flow appears healthy - maintain current trajectory and regular monitoring');
}
return recommendations;
}
export { toolDefinition, handler };