UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

325 lines (289 loc) 11.6 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_payees', description: 'Get all payees with transaction history, spending totals, and activity analysis', inputSchema: { type: 'object', properties: { budget_id: { type: 'string', description: 'Specific budget ID (optional, defaults to default budget)' }, include_inactive: { type: 'boolean', description: 'Include payees with no recent transactions (default: true)', default: true }, since_date: { type: 'string', description: 'Include only payees with transactions since this date in YYYY-MM-DD format (optional)', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, min_transaction_count: { type: 'integer', description: 'Include only payees with at least this many transactions (optional)', minimum: 1 }, sort_by: { type: 'string', enum: ['name', 'total_spent', 'transaction_count', 'last_transaction_date'], description: 'Sort payees by field (default: name)', default: 'name' }, sort_order: { type: 'string', enum: ['asc', 'desc'], description: 'Sort order ascending or descending (default: desc for amounts/counts, asc for names/dates)', default: 'asc' } }, additionalProperties: false } }; const handler = async (params) => { try { const ynab = new YnabClient(); const { budget_id, include_inactive = true, since_date, min_transaction_count, sort_by = 'name', sort_order = 'asc' } = params; // Generate cache key const cacheKey = cache.generateKey('payees', params); return await cache.getOrSet(cacheKey, async () => { const budgetId = budget_id || await ynab.getDefaultBudgetId(); // Get payees and transactions const [payees, transactions] = await Promise.all([ ynab.getPayees(budgetId), ynab.getTransactions(budgetId, { sinceDate: since_date, limit: 1000 // Get more transactions for comprehensive analysis }) ]); // Create transaction analysis per payee const payeeStats = new Map(); // Initialize payee stats payees.forEach(payee => { payeeStats.set(payee.id, { id: payee.id, name: payee.name, transfer_account_id: payee.transfer_account_id || null, is_transfer_payee: !!payee.transfer_account_id, deleted: payee.deleted || false, transactions: [], transaction_count: 0, total_spent: 0, total_income: 0, net_amount: 0, average_transaction: 0, first_transaction_date: null, last_transaction_date: null, categories_used: new Set(), accounts_used: new Set(), cleared_transactions: 0, uncleared_transactions: 0, flagged_transactions: 0 }); }); // Add stats for transfer payees (they might not be in the payees list) const transferPayees = new Set(); transactions.forEach(transaction => { if (transaction.transfer_account_id && !payeeStats.has(transaction.payee_id)) { transferPayees.add(transaction.payee_id); payeeStats.set(transaction.payee_id, { id: transaction.payee_id, name: 'Transfer', transfer_account_id: transaction.transfer_account_id, is_transfer_payee: true, deleted: false, transactions: [], transaction_count: 0, total_spent: 0, total_income: 0, net_amount: 0, average_transaction: 0, first_transaction_date: null, last_transaction_date: null, categories_used: new Set(), accounts_used: new Set(), cleared_transactions: 0, uncleared_transactions: 0, flagged_transactions: 0 }); } }); // Process transactions and build payee statistics transactions.forEach(transaction => { const payeeId = transaction.payee_id; const payeeStat = payeeStats.get(payeeId); if (!payeeStat) return; // Skip if payee not found const amount = ynab.milliunitsToAmount(transaction.amount); const transactionDate = transaction.date; // Update transaction list payeeStat.transactions.push({ id: transaction.id, date: transactionDate, amount: amount, cleared: transaction.cleared, approved: transaction.approved, memo: transaction.memo || null, category_id: transaction.category_id, account_id: transaction.account_id, flag_color: transaction.flag_color || null }); // Update counts and totals payeeStat.transaction_count++; payeeStat.net_amount += amount; if (amount < 0) { payeeStat.total_spent += Math.abs(amount); } else { payeeStat.total_income += amount; } // Update dates if (!payeeStat.first_transaction_date || transactionDate < payeeStat.first_transaction_date) { payeeStat.first_transaction_date = transactionDate; } if (!payeeStat.last_transaction_date || transactionDate > payeeStat.last_transaction_date) { payeeStat.last_transaction_date = transactionDate; } // Track categories and accounts used if (transaction.category_id) { payeeStat.categories_used.add(transaction.category_id); } payeeStat.accounts_used.add(transaction.account_id); // Update status counts if (transaction.cleared === 'cleared') { payeeStat.cleared_transactions++; } else { payeeStat.uncleared_transactions++; } if (transaction.flag_color) { payeeStat.flagged_transactions++; } }); // Calculate averages and convert sets to counts payeeStats.forEach(payeeStat => { if (payeeStat.transaction_count > 0) { payeeStat.average_transaction = payeeStat.net_amount / payeeStat.transaction_count; } payeeStat.categories_count = payeeStat.categories_used.size; payeeStat.accounts_count = payeeStat.accounts_used.size; // Remove the sets from final output delete payeeStat.categories_used; delete payeeStat.accounts_used; // Sort transactions by date (most recent first) payeeStat.transactions.sort((a, b) => new Date(b.date) - new Date(a.date)); }); // Convert to array and apply filters let payeesList = Array.from(payeeStats.values()); // Filter by activity if requested if (!include_inactive) { payeesList = payeesList.filter(payee => payee.transaction_count > 0); } // Filter by minimum transaction count if (min_transaction_count) { payeesList = payeesList.filter(payee => payee.transaction_count >= min_transaction_count); } // Determine sort order let finalSortOrder = sort_order; if (sort_order === 'asc' && (sort_by === 'total_spent' || sort_by === 'transaction_count')) { finalSortOrder = 'desc'; // Default to desc for amounts and counts if not specified } // Sort payees payeesList.sort((a, b) => { let aValue, bValue; switch (sort_by) { case 'name': aValue = a.name.toLowerCase(); bValue = b.name.toLowerCase(); break; case 'total_spent': aValue = a.total_spent; bValue = b.total_spent; break; case 'transaction_count': aValue = a.transaction_count; bValue = b.transaction_count; break; case 'last_transaction_date': aValue = a.last_transaction_date ? new Date(a.last_transaction_date) : new Date(0); bValue = b.last_transaction_date ? new Date(b.last_transaction_date) : new Date(0); break; default: aValue = a.name.toLowerCase(); bValue = b.name.toLowerCase(); } if (finalSortOrder === 'desc') { return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; } else { return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; } }); // Calculate summary statistics const totalPayees = payeesList.length; const activePayees = payeesList.filter(p => p.transaction_count > 0).length; const transferPayeesCount = payeesList.filter(p => p.is_transfer_payee).length; const totalTransactions = payeesList.reduce((sum, p) => sum + p.transaction_count, 0); const totalSpent = payeesList.reduce((sum, p) => sum + p.total_spent, 0); const totalIncome = payeesList.reduce((sum, p) => sum + p.total_income, 0); // Top payees by spending const topBySpending = [...payeesList] .filter(p => p.total_spent > 0) .sort((a, b) => b.total_spent - a.total_spent) .slice(0, 10) .map(p => ({ name: p.name, total_spent: p.total_spent, transaction_count: p.transaction_count, average_transaction: p.average_transaction })); // Most frequent payees const topByFrequency = [...payeesList] .filter(p => p.transaction_count > 0) .sort((a, b) => b.transaction_count - a.transaction_count) .slice(0, 10) .map(p => ({ name: p.name, transaction_count: p.transaction_count, total_spent: p.total_spent, average_transaction: p.average_transaction })); return { payees: payeesList, total_count: totalPayees, summary: { total_payees: totalPayees, active_payees: activePayees, inactive_payees: totalPayees - activePayees, transfer_payees: transferPayeesCount, total_transactions: totalTransactions, total_spent: totalSpent, total_income: totalIncome, net_total: totalIncome - totalSpent }, top_payees: { by_spending: topBySpending, by_frequency: topByFrequency }, filters_applied: { budget_id: budgetId, include_inactive, since_date: since_date || null, min_transaction_count: min_transaction_count || null, sort_by, sort_order: finalSortOrder } }; }, 10); // Cache for 10 minutes (payees change less frequently) } catch (error) { return errorHandler.formatForMCP(error); } }; export { toolDefinition, handler };