UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

536 lines (461 loc) 21 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_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 };