UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

426 lines (375 loc) 15.7 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_account_details', description: 'Get detailed information about a specific account including transaction history, balance trends, and performance metrics', inputSchema: { type: 'object', properties: { budget_id: { type: 'string', description: 'Specific budget ID (optional, defaults to default budget)' }, account_id: { type: 'string', description: 'Account ID to get details for (required)' }, include_transactions: { type: 'boolean', description: 'Include recent transaction history (default: true)', default: true }, transaction_limit: { type: 'integer', description: 'Number of recent transactions to include (default: 50, max: 200)', minimum: 1, maximum: 200, default: 50 }, include_reconciliation_history: { type: 'boolean', description: 'Include reconciliation history and cleared balance tracking (default: true)', default: true }, analysis_months: { type: 'integer', description: 'Number of months of historical data to analyze for trends (default: 6, max: 12)', minimum: 1, maximum: 12, default: 6 } }, required: ['account_id'], additionalProperties: false } }; const handler = async (params) => { try { const ynab = new YnabClient(); const { budget_id, account_id, include_transactions = true, transaction_limit = 50, include_reconciliation_history = true, analysis_months = 6 } = params; // Generate cache key const cacheKey = cache.generateKey('account_details', params); return await cache.getOrSet(cacheKey, async () => { const budgetId = budget_id || await ynab.getDefaultBudgetId(); // Get basic account information const accounts = await ynab.getAccounts(budgetId); const account = accounts.find(acc => acc.id === account_id); if (!account) { throw new Error(`Account with ID ${account_id} not found`); } // Get transactions for this account let accountTransactions = []; let transactionAnalysis = null; if (include_transactions) { // Calculate date range for analysis const endDate = new Date(); const startDate = new Date(); startDate.setMonth(endDate.getMonth() - analysis_months); accountTransactions = await ynab.getTransactions(budgetId, { accountId: account_id, sinceDate: startDate.toISOString().split('T')[0], limit: Math.max(transaction_limit, 200) // Get more for analysis }); // Get additional data for transaction enrichment const [categories, payees] = await Promise.all([ ynab.getCategories(budgetId), ynab.getPayees(budgetId) ]); // Create lookup maps 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 }); }); } }); // Enrich and analyze transactions const enrichedTransactions = accountTransactions.map(transaction => { const category = categoryMap.get(transaction.category_id); const payee = payeeMap.get(transaction.payee_id); return { id: transaction.id, date: transaction.date, amount: ynab.milliunitsToAmount(transaction.amount), cleared: transaction.cleared, approved: transaction.approved, payee_id: transaction.payee_id, payee_name: payee ? payee.name : 'Transfer', category_id: transaction.category_id, category_name: category ? category.name : null, category_group_name: category ? category.group_name : null, memo: transaction.memo || null, flag_color: transaction.flag_color || null, transfer_account_id: transaction.transfer_account_id || null, import_id: transaction.import_id || null }; }); // Sort by date (most recent first) enrichedTransactions.sort((a, b) => new Date(b.date) - new Date(a.date)); // Limit to requested number of transactions for display accountTransactions = enrichedTransactions.slice(0, transaction_limit); // Analyze all transactions for insights transactionAnalysis = analyzeTransactions(enrichedTransactions, analysis_months); } // Get reconciliation history if requested let reconciliationAnalysis = null; if (include_reconciliation_history) { reconciliationAnalysis = analyzeReconciliation(accountTransactions || []); } // Calculate account performance metrics const accountMetrics = calculateAccountMetrics(account, accountTransactions || []); return { account: { id: account.id, name: account.name, type: account.type, balance: ynab.milliunitsToAmount(account.balance), cleared_balance: ynab.milliunitsToAmount(account.cleared_balance), uncleared_balance: ynab.milliunitsToAmount(account.uncleared_balance), on_budget: account.on_budget, closed: account.closed, note: account.note || null, transfer_payee_id: account.transfer_payee_id || null, direct_import_linked: account.direct_import_linked || false, direct_import_in_error: account.direct_import_in_error || false, last_reconciled_at: account.last_reconciled_at || null, debt_original_balance: account.debt_original_balance ? ynab.milliunitsToAmount(account.debt_original_balance) : null, debt_interest_rates: account.debt_interest_rates || null, debt_minimum_payments: account.debt_minimum_payments || null, debt_escrow_amounts: account.debt_escrow_amounts || null }, transactions: accountTransactions, transaction_count: accountTransactions.length, analysis: { metrics: accountMetrics, transaction_analysis: transactionAnalysis, reconciliation_analysis: reconciliationAnalysis }, analysis_period: { months_analyzed: analysis_months, start_date: analysis_months > 0 ? new Date(Date.now() - analysis_months * 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] : null, end_date: new Date().toISOString().split('T')[0] }, filters_applied: { budget_id: budgetId, account_id, include_transactions, transaction_limit, include_reconciliation_history, analysis_months } }; }, 5); // Cache for 5 minutes (account data changes moderately) } catch (error) { return errorHandler.formatForMCP(error); } }; // Helper function to analyze transactions function analyzeTransactions(transactions, analysisMonths) { if (transactions.length === 0) { return { total_transactions: 0, income_transactions: 0, expense_transactions: 0, transfer_transactions: 0, total_income: 0, total_expenses: 0, net_change: 0, average_transaction: 0, largest_expense: null, largest_income: null, monthly_activity: [], top_categories: [], top_payees: [], cleared_percentage: 0 }; } // Basic transaction counts and totals const incomeTransactions = transactions.filter(t => t.amount > 0); const expenseTransactions = transactions.filter(t => t.amount < 0); const transferTransactions = transactions.filter(t => t.transfer_account_id); const totalIncome = incomeTransactions.reduce((sum, t) => sum + t.amount, 0); const totalExpenses = expenseTransactions.reduce((sum, t) => sum + Math.abs(t.amount), 0); const netChange = totalIncome - totalExpenses; // Find largest transactions const largestExpense = expenseTransactions.length > 0 ? expenseTransactions.reduce((max, t) => Math.abs(t.amount) > Math.abs(max.amount) ? t : max) : null; const largestIncome = incomeTransactions.length > 0 ? incomeTransactions.reduce((max, t) => t.amount > max.amount ? t : max) : null; // Monthly activity analysis const monthlyActivity = calculateMonthlyActivity(transactions, analysisMonths); // Top categories by spending const categoryTotals = new Map(); transactions.filter(t => t.amount < 0 && t.category_name).forEach(t => { const current = categoryTotals.get(t.category_name) || { name: t.category_name, total: 0, count: 0 }; current.total += Math.abs(t.amount); current.count++; categoryTotals.set(t.category_name, current); }); const topCategories = Array.from(categoryTotals.values()) .sort((a, b) => b.total - a.total) .slice(0, 10); // Top payees by spending const payeeTotals = new Map(); transactions.filter(t => t.amount < 0 && t.payee_name !== 'Transfer').forEach(t => { const current = payeeTotals.get(t.payee_name) || { name: t.payee_name, total: 0, count: 0 }; current.total += Math.abs(t.amount); current.count++; payeeTotals.set(t.payee_name, current); }); const topPayees = Array.from(payeeTotals.values()) .sort((a, b) => b.total - a.total) .slice(0, 10); // Cleared transaction percentage const clearedCount = transactions.filter(t => t.cleared === 'cleared').length; const clearedPercentage = (clearedCount / transactions.length) * 100; return { total_transactions: transactions.length, income_transactions: incomeTransactions.length, expense_transactions: expenseTransactions.length, transfer_transactions: transferTransactions.length, total_income: totalIncome, total_expenses: totalExpenses, net_change: netChange, average_transaction: transactions.length > 0 ? netChange / transactions.length : 0, largest_expense: largestExpense, largest_income: largestIncome, monthly_activity: monthlyActivity, top_categories: topCategories, top_payees: topPayees, cleared_percentage: clearedPercentage }; } // Helper function to calculate monthly activity function calculateMonthlyActivity(transactions, months) { const monthlyData = new Map(); transactions.forEach(transaction => { const monthKey = transaction.date.substring(0, 7); // YYYY-MM const current = monthlyData.get(monthKey) || { month: monthKey, transaction_count: 0, income: 0, expenses: 0, net: 0 }; current.transaction_count++; if (transaction.amount > 0) { current.income += transaction.amount; } else { current.expenses += Math.abs(transaction.amount); } current.net = current.income - current.expenses; monthlyData.set(monthKey, current); }); return Array.from(monthlyData.values()) .sort((a, b) => b.month.localeCompare(a.month)) .slice(0, months); } // Helper function to analyze reconciliation patterns function analyzeReconciliation(transactions) { if (transactions.length === 0) { return { cleared_transactions: 0, uncleared_transactions: 0, cleared_balance_total: 0, uncleared_balance_total: 0, oldest_uncleared_date: null, reconciliation_suggestions: [] }; } const clearedTransactions = transactions.filter(t => t.cleared === 'cleared'); const unclearedTransactions = transactions.filter(t => t.cleared === 'uncleared'); const clearedBalance = clearedTransactions.reduce((sum, t) => sum + t.amount, 0); const unclearedBalance = unclearedTransactions.reduce((sum, t) => sum + t.amount, 0); // Find oldest uncleared transaction const oldestUncleared = unclearedTransactions.length > 0 ? unclearedTransactions.reduce((oldest, t) => new Date(t.date) < new Date(oldest.date) ? t : oldest ) : null; // Generate reconciliation suggestions const suggestions = []; if (unclearedTransactions.length > 10) { suggestions.push('Consider reconciling - you have many uncleared transactions'); } if (oldestUncleared && new Date() - new Date(oldestUncleared.date) > 30 * 24 * 60 * 60 * 1000) { suggestions.push('You have uncleared transactions older than 30 days'); } if (Math.abs(unclearedBalance) > 100) { suggestions.push('Significant uncleared balance - reconciliation recommended'); } return { cleared_transactions: clearedTransactions.length, uncleared_transactions: unclearedTransactions.length, cleared_balance_total: clearedBalance, uncleared_balance_total: unclearedBalance, oldest_uncleared_date: oldestUncleared ? oldestUncleared.date : null, reconciliation_suggestions: suggestions }; } // Helper function to calculate account metrics function calculateAccountMetrics(account, transactions) { const metrics = { balance_health: 'unknown', activity_level: 'unknown', reconciliation_status: 'unknown' }; // Balance health assessment if (account.type === 'checking' || account.type === 'savings') { const balance = account.balance / 1000; // Convert from milliunits if (balance > 1000) { metrics.balance_health = 'healthy'; } else if (balance > 0) { metrics.balance_health = 'low'; } else { metrics.balance_health = 'overdrawn'; } } else if (account.type === 'creditCard' || account.type === 'lineOfCredit') { const balance = account.balance / 1000; // Convert from milliunits if (balance >= 0) { metrics.balance_health = 'paid_off'; } else if (Math.abs(balance) < 1000) { metrics.balance_health = 'manageable'; } else { metrics.balance_health = 'high_balance'; } } // Activity level based on recent transactions if (transactions.length > 0) { const recentTransactions = transactions.filter(t => { const daysSince = (new Date() - new Date(t.date)) / (1000 * 60 * 60 * 24); return daysSince <= 30; }); if (recentTransactions.length > 20) { metrics.activity_level = 'high'; } else if (recentTransactions.length > 5) { metrics.activity_level = 'moderate'; } else { metrics.activity_level = 'low'; } } // Reconciliation status const unclearedBalance = Math.abs((account.balance - account.cleared_balance) / 1000); if (unclearedBalance < 0.01) { metrics.reconciliation_status = 'fully_reconciled'; } else if (unclearedBalance < 100) { metrics.reconciliation_status = 'minor_differences'; } else { metrics.reconciliation_status = 'needs_reconciliation'; } return metrics; } export { toolDefinition, handler };