UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

514 lines (437 loc) 19.3 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_net_worth_trend', description: 'Analyze net worth changes over time with asset/debt breakdown, growth trends, and wealth building insights', inputSchema: { type: 'object', properties: { budget_id: { type: 'string', description: 'Specific budget ID (optional, defaults to default budget)' }, time_period: { type: 'string', enum: ['last_6_months', 'last_year', 'last_2_years', 'all_time'], description: 'Time period for net worth trend analysis (default: last_year)', default: 'last_year' }, include_off_budget: { type: 'boolean', description: 'Include off-budget accounts in net worth calculation (default: true)', default: true }, group_by: { type: 'string', enum: ['month', 'quarter'], description: 'Group net worth data by time period (default: month)', default: 'month' }, account_type_breakdown: { type: 'boolean', description: 'Include breakdown by account types (assets vs debts) (default: true)', default: true }, growth_analysis: { type: 'boolean', description: 'Include growth rate analysis and projections (default: true)', default: true } }, additionalProperties: false } }; const handler = async (params) => { try { const ynab = new YnabClient(); const { budget_id, time_period = 'last_year', include_off_budget = true, group_by = 'month', account_type_breakdown = true, growth_analysis = true } = params; // Generate cache key const cacheKey = cache.generateKey('net_worth_trend', params); return await cache.getOrSet(cacheKey, async () => { const budgetId = budget_id || await ynab.getDefaultBudgetId(); // Calculate date range based on time period const endDate = new Date(); let startDate = new Date(); switch (time_period) { case 'last_6_months': startDate.setMonth(endDate.getMonth() - 6); break; case 'last_year': startDate.setFullYear(endDate.getFullYear() - 1); break; case 'last_2_years': startDate.setFullYear(endDate.getFullYear() - 2); break; case 'all_time': startDate.setFullYear(endDate.getFullYear() - 5); // Reasonable limit break; } // Get accounts const accounts = await ynab.getAccounts(budgetId); // Filter accounts based on preferences const filteredAccounts = include_off_budget ? accounts.filter(acc => !acc.closed) : accounts.filter(acc => acc.on_budget && !acc.closed); // Since YNAB doesn't provide historical account balances directly, // we'll calculate net worth trends based on transaction history // This is an approximation but provides valuable insights // Get transactions for the analysis period const transactions = await ynab.getTransactions(budgetId, { sinceDate: startDate.toISOString().split('T')[0], untilDate: endDate.toISOString().split('T')[0], limit: 1000 }); // Calculate net worth trend points const netWorthData = calculateNetWorthTrend( transactions, filteredAccounts, startDate, endDate, group_by ); // Calculate account type breakdown if requested let accountTypeBreakdown = null; if (account_type_breakdown) { accountTypeBreakdown = calculateAccountTypeBreakdown(filteredAccounts, netWorthData); } // Calculate growth analysis if requested let growthAnalysisData = null; if (growth_analysis && netWorthData.length >= 2) { growthAnalysisData = calculateGrowthAnalysis(netWorthData); } // Current net worth calculation const currentNetWorth = filteredAccounts.reduce((total, account) => { return total + (account.balance / 1000); // Convert from milliunits }, 0); // Asset and debt totals const assetAccounts = filteredAccounts.filter(acc => ['checking', 'savings', 'cash', 'investmentAccount', 'mortgage'].includes(acc.type) || (acc.type === 'creditCard' && acc.balance > 0) // Credit cards with positive balance are assets ); const debtAccounts = filteredAccounts.filter(acc => ['creditCard', 'lineOfCredit'].includes(acc.type) && acc.balance < 0 || (acc.type === 'mortgage' && acc.balance < 0) // Negative mortgage balance is debt ); const totalAssets = assetAccounts.reduce((total, acc) => total + Math.max(acc.balance / 1000, 0), 0); const totalDebts = Math.abs(debtAccounts.reduce((total, acc) => total + Math.min(acc.balance / 1000, 0), 0)); // Generate insights and recommendations const insights = generateNetWorthInsights(netWorthData, growthAnalysisData, currentNetWorth); const recommendations = generateNetWorthRecommendations( growthAnalysisData, totalAssets, totalDebts, currentNetWorth ); return { net_worth_trend: netWorthData, current_snapshot: { net_worth: currentNetWorth, total_assets: totalAssets, total_debts: totalDebts, asset_to_debt_ratio: totalDebts > 0 ? totalAssets / totalDebts : null, accounts_included: filteredAccounts.length }, account_type_breakdown: accountTypeBreakdown, growth_analysis: growthAnalysisData, insights: insights, recommendations: recommendations, analysis_period: { start_date: startDate.toISOString().split('T')[0], end_date: endDate.toISOString().split('T')[0], periods_analyzed: netWorthData.length, time_span_months: Math.round((endDate - startDate) / (1000 * 60 * 60 * 24 * 30)) }, filters_applied: { budget_id: budgetId, time_period, include_off_budget, group_by, account_type_breakdown, growth_analysis } }; }, 15); // Cache for 15 minutes (net worth changes less frequently) } catch (error) { return errorHandler.formatForMCP(error); } }; // Helper function to calculate net worth trend function calculateNetWorthTrend(transactions, accounts, startDate, endDate, groupBy) { // Create account balance tracking const accountBalances = new Map(); accounts.forEach(account => { accountBalances.set(account.id, { starting_balance: account.balance / 1000, // Current balance as starting point account_type: account.type, on_budget: account.on_budget }); }); // Generate time periods const periods = []; const currentDate = new Date(startDate); while (currentDate <= endDate) { let periodKey; let nextPeriod = new Date(currentDate); if (groupBy === 'quarter') { const quarter = Math.floor(currentDate.getMonth() / 3) + 1; periodKey = `${currentDate.getFullYear()}-Q${quarter}`; nextPeriod.setMonth(currentDate.getMonth() + 3); } else { periodKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`; nextPeriod.setMonth(currentDate.getMonth() + 1); } periods.push({ period: periodKey, date: currentDate.toISOString().split('T')[0], net_worth: 0, total_assets: 0, total_debts: 0, period_change: 0, transaction_impact: 0 }); currentDate.setTime(nextPeriod.getTime()); } // Since we don't have historical balance data, we'll work backwards from current balances // This is an approximation but provides meaningful trend insights // Group transactions by period and account const transactionsByPeriod = new Map(); transactions.forEach(transaction => { const transDate = new Date(transaction.date); let periodKey; if (groupBy === 'quarter') { const quarter = Math.floor(transDate.getMonth() / 3) + 1; periodKey = `${transDate.getFullYear()}-Q${quarter}`; } else { periodKey = `${transDate.getFullYear()}-${String(transDate.getMonth() + 1).padStart(2, '0')}`; } if (!transactionsByPeriod.has(periodKey)) { transactionsByPeriod.set(periodKey, new Map()); } const periodTransactions = transactionsByPeriod.get(periodKey); if (!periodTransactions.has(transaction.account_id)) { periodTransactions.set(transaction.account_id, 0); } periodTransactions.set( transaction.account_id, periodTransactions.get(transaction.account_id) + (transaction.amount / 1000) ); }); // Calculate net worth for each period (working backwards from current) let runningBalances = new Map(); accounts.forEach(account => { runningBalances.set(account.id, account.balance / 1000); }); // Calculate for each period (reverse order to work backwards) for (let i = periods.length - 1; i >= 0; i--) { const period = periods[i]; let periodAssets = 0; let periodDebts = 0; let transactionImpact = 0; runningBalances.forEach((balance, accountId) => { const account = accounts.find(acc => acc.id === accountId); if (!account) return; // Categorize as asset or debt if (['checking', 'savings', 'cash', 'investmentAccount'].includes(account.type) || (account.type === 'creditCard' && balance > 0)) { periodAssets += Math.max(balance, 0); } else if (['creditCard', 'lineOfCredit', 'mortgage'].includes(account.type) && balance < 0) { periodDebts += Math.abs(Math.min(balance, 0)); } }); period.total_assets = periodAssets; period.total_debts = periodDebts; period.net_worth = periodAssets - periodDebts; // Calculate transaction impact for this period const periodTransactions = transactionsByPeriod.get(period.period); if (periodTransactions) { periodTransactions.forEach((amount, accountId) => { transactionImpact += amount; // Update running balance for next iteration (going backwards) if (runningBalances.has(accountId)) { runningBalances.set(accountId, runningBalances.get(accountId) - amount); } }); } period.transaction_impact = transactionImpact; // Calculate period change if (i < periods.length - 1) { period.period_change = period.net_worth - periods[i + 1].net_worth; } } return periods; } // Helper function to calculate account type breakdown function calculateAccountTypeBreakdown(accounts, netWorthData) { const accountTypes = { 'Cash Accounts': { current: 0, types: ['checking', 'savings', 'cash'] }, 'Investment Accounts': { current: 0, types: ['investmentAccount'] }, 'Credit Cards': { current: 0, types: ['creditCard'] }, 'Loans': { current: 0, types: ['lineOfCredit', 'mortgage'] }, 'Other': { current: 0, types: [] } }; accounts.forEach(account => { const balance = account.balance / 1000; let categorized = false; for (const [category, info] of Object.entries(accountTypes)) { if (info.types.includes(account.type)) { accountTypes[category].current += balance; categorized = true; break; } } if (!categorized) { accountTypes['Other'].current += balance; } }); // Convert to array format return Object.entries(accountTypes).map(([name, data]) => ({ category: name, current_balance: data.current, percentage_of_net_worth: netWorthData.length > 0 ? (data.current / Math.max(netWorthData[netWorthData.length - 1].net_worth, 1)) * 100 : 0 })).filter(category => Math.abs(category.current_balance) > 0.01); // Filter out near-zero categories } // Helper function to calculate growth analysis function calculateGrowthAnalysis(netWorthData) { if (netWorthData.length < 2) return null; const firstValue = netWorthData[0].net_worth; const lastValue = netWorthData[netWorthData.length - 1].net_worth; const totalChange = lastValue - firstValue; const timeSpanMonths = netWorthData.length; // Calculate monthly growth rate const monthlyGrowthRate = timeSpanMonths > 1 ? (Math.pow(lastValue / Math.max(firstValue, 1), 1 / timeSpanMonths) - 1) * 100 : 0; // Calculate annual growth rate const annualGrowthRate = monthlyGrowthRate * 12; // Find peak and trough let peakValue = Math.max(...netWorthData.map(d => d.net_worth)); let troughValue = Math.min(...netWorthData.map(d => d.net_worth)); let peakPeriod = netWorthData.find(d => d.net_worth === peakValue)?.period; let troughPeriod = netWorthData.find(d => d.net_worth === troughValue)?.period; // Calculate volatility const avgNetWorth = netWorthData.reduce((sum, d) => sum + d.net_worth, 0) / netWorthData.length; const variance = netWorthData.reduce((sum, d) => sum + Math.pow(d.net_worth - avgNetWorth, 2), 0) / netWorthData.length; const standardDeviation = Math.sqrt(variance); const volatility = Math.abs(avgNetWorth) > 0 ? (standardDeviation / Math.abs(avgNetWorth)) * 100 : 0; // Calculate projected net worth (simple linear projection) const recentTrend = netWorthData.length >= 3 ? netWorthData.slice(-3).reduce((sum, d, i, arr) => { if (i === 0) return 0; return sum + (d.net_worth - arr[i-1].net_worth); }, 0) / 2 : 0; const projectedOneYearOut = lastValue + (recentTrend * 12); return { total_change: totalChange, total_change_percentage: firstValue !== 0 ? (totalChange / Math.abs(firstValue)) * 100 : 0, monthly_growth_rate: monthlyGrowthRate, annual_growth_rate: annualGrowthRate, peak_net_worth: peakValue, peak_period: peakPeriod, trough_net_worth: troughValue, trough_period: troughPeriod, volatility_percentage: volatility, recent_trend_monthly: recentTrend, projected_one_year: projectedOneYearOut, growth_consistency: calculateGrowthConsistency(netWorthData) }; } // Helper function to calculate growth consistency function calculateGrowthConsistency(netWorthData) { if (netWorthData.length < 3) return 0; let positiveChanges = 0; let negativeChanges = 0; for (let i = 1; i < netWorthData.length; i++) { const change = netWorthData[i].net_worth - netWorthData[i-1].net_worth; if (change > 0) { positiveChanges++; } else if (change < 0) { negativeChanges++; } } const totalChanges = positiveChanges + negativeChanges; return totalChanges > 0 ? (positiveChanges / totalChanges) * 100 : 0; } // Helper function to generate insights function generateNetWorthInsights(netWorthData, growthAnalysis, currentNetWorth) { const insights = []; // Current position insights if (currentNetWorth > 100000) { insights.push('Strong net worth position - well positioned for wealth building'); } else if (currentNetWorth > 0) { insights.push('Positive net worth - good foundation for financial growth'); } else { insights.push('Negative net worth indicates debt exceeds assets - focus on debt reduction'); } // Growth insights if (growthAnalysis) { if (growthAnalysis.annual_growth_rate > 10) { insights.push(`Excellent growth rate of ${growthAnalysis.annual_growth_rate.toFixed(1)}% annually`); } else if (growthAnalysis.annual_growth_rate > 5) { insights.push(`Solid growth rate of ${growthAnalysis.annual_growth_rate.toFixed(1)}% annually`); } else if (growthAnalysis.annual_growth_rate > 0) { insights.push(`Modest growth rate of ${growthAnalysis.annual_growth_rate.toFixed(1)}% annually`); } else { insights.push('Net worth declining - review spending and debt management strategies'); } if (growthAnalysis.growth_consistency > 75) { insights.push('Consistent growth pattern indicates stable financial habits'); } else if (growthAnalysis.growth_consistency < 50) { insights.push('Volatile growth pattern - consider more consistent savings and spending habits'); } if (growthAnalysis.volatility_percentage > 20) { insights.push('High net worth volatility - may indicate irregular income or large expenses'); } } return insights; } // Helper function to generate recommendations function generateNetWorthRecommendations(growthAnalysis, totalAssets, totalDebts, currentNetWorth) { const recommendations = []; // Position-based recommendations if (currentNetWorth < 0) { recommendations.push('Priority: Create debt elimination plan to achieve positive net worth'); recommendations.push('Focus on highest interest rate debts first'); } else if (currentNetWorth < 10000) { recommendations.push('Build emergency fund to protect net worth from unexpected expenses'); recommendations.push('Continue debt reduction while building assets'); } else if (currentNetWorth > 100000) { recommendations.push('Consider diversifying assets and optimizing investment allocation'); recommendations.push('Review tax optimization strategies for wealth preservation'); } // Asset-to-debt ratio recommendations if (totalDebts > 0) { const assetToDebtRatio = totalAssets / totalDebts; if (assetToDebtRatio < 1.5) { recommendations.push('Low asset-to-debt ratio - prioritize debt reduction'); } else if (assetToDebtRatio > 5) { recommendations.push('Strong asset-to-debt ratio - consider strategic use of leverage for investments'); } } // Growth-based recommendations if (growthAnalysis) { if (growthAnalysis.annual_growth_rate < 3) { recommendations.push('Below-average growth rate - review savings and investment strategy'); } if (growthAnalysis.growth_consistency < 60) { recommendations.push('Improve growth consistency with regular automated savings and investments'); } if (growthAnalysis.recent_trend_monthly < 0) { recommendations.push('Recent downward trend - investigate causes and adjust financial strategy'); } } if (recommendations.length === 0) { recommendations.push('Net worth trend appears healthy - maintain current financial habits and monitor regularly'); } return recommendations; } export { toolDefinition, handler };