UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

401 lines (354 loc) 15.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_scheduled_transactions', description: 'Get upcoming scheduled transactions with frequency analysis, next occurrence dates, and financial planning insights', inputSchema: { type: 'object', properties: { budget_id: { type: 'string', description: 'Specific budget ID (optional, defaults to default budget)' }, upcoming_days: { type: 'integer', description: 'Number of days ahead to look for upcoming scheduled transactions (default: 30, max: 365)', minimum: 1, maximum: 365, default: 30 }, account_id: { type: 'string', description: 'Filter by specific account ID (optional)' }, category_id: { type: 'string', description: 'Filter by specific category ID (optional)' }, frequency_filter: { type: 'string', enum: ['never', 'daily', 'weekly', 'everyOtherWeek', 'twiceAMonth', 'monthly', 'everyOtherMonth', 'everyThreeMonths', 'everyFourMonths', 'twiceAYear', 'yearly'], description: 'Filter by specific frequency (optional)' }, include_inactive: { type: 'boolean', description: 'Include inactive scheduled transactions (default: false)', default: false }, sort_by: { type: 'string', enum: ['next_date', 'amount', 'payee_name', 'frequency'], description: 'Sort scheduled transactions by field (default: next_date)', default: 'next_date' } }, additionalProperties: false } }; const handler = async (params) => { try { const ynab = new YnabClient(); const { budget_id, upcoming_days = 30, account_id, category_id, frequency_filter, include_inactive = false, sort_by = 'next_date' } = params; // Generate cache key const cacheKey = cache.generateKey('scheduled_transactions', params); return await cache.getOrSet(cacheKey, async () => { const budgetId = budget_id || await ynab.getDefaultBudgetId(); // Get scheduled transactions const scheduledTransactions = await ynab.getScheduledTransactions(budgetId); // Get additional data for enriching transaction details const [accounts, categories, payees] = await Promise.all([ ynab.getAccounts(budgetId), 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 }); }); } }); // Filter and enrich scheduled transactions let filteredTransactions = scheduledTransactions.filter(st => { // Filter by active status if (!include_inactive && st.deleted_at) return false; // Filter by account if (account_id && st.account_id !== account_id) return false; // Filter by category if (category_id && st.category_id !== category_id) return false; // Filter by frequency if (frequency_filter && st.frequency !== frequency_filter) return false; return true; }); // Calculate next occurrence dates and enrich data const today = new Date(); const upcomingDate = new Date(today.getTime() + upcoming_days * 24 * 60 * 60 * 1000); const enrichedTransactions = filteredTransactions.map(st => { const account = accountMap.get(st.account_id); const category = categoryMap.get(st.category_id); const payee = payeeMap.get(st.payee_id); // Calculate next occurrence dates const nextOccurrences = calculateNextOccurrences(st, today, upcoming_days); const upcomingOccurrences = nextOccurrences.filter(date => date <= upcomingDate); return { id: st.id, date_first: st.date_first, frequency: st.frequency, amount: ynab.milliunitsToAmount(st.amount), memo: st.memo || null, payee_id: st.payee_id, payee_name: payee ? payee.name : 'Transfer', category_id: st.category_id, category_name: category ? category.name : null, category_group_name: category ? category.group_name : null, account_id: st.account_id, account_name: account ? account.name : null, transfer_account_id: st.transfer_account_id || null, flag_color: st.flag_color || null, deleted_at: st.deleted_at || null, is_active: !st.deleted_at, next_occurrence_date: nextOccurrences.length > 0 ? nextOccurrences[0] : null, upcoming_occurrences: upcomingOccurrences, occurrences_in_period: upcomingOccurrences.length, estimated_total_amount: upcomingOccurrences.length * ynab.milliunitsToAmount(st.amount) }; }); // Filter out transactions with no upcoming occurrences if we're looking for upcoming only const upcomingTransactions = enrichedTransactions.filter(st => st.upcoming_occurrences.length > 0 || include_inactive ); // Sort transactions upcomingTransactions.sort((a, b) => { switch (sort_by) { case 'next_date': if (!a.next_occurrence_date && !b.next_occurrence_date) return 0; if (!a.next_occurrence_date) return 1; if (!b.next_occurrence_date) return -1; return new Date(a.next_occurrence_date) - new Date(b.next_occurrence_date); case 'amount': return Math.abs(b.amount) - Math.abs(a.amount); case 'payee_name': return a.payee_name.localeCompare(b.payee_name); case 'frequency': const frequencyOrder = ['daily', 'weekly', 'everyOtherWeek', 'twiceAMonth', 'monthly', 'everyOtherMonth', 'everyThreeMonths', 'everyFourMonths', 'twiceAYear', 'yearly']; return frequencyOrder.indexOf(a.frequency) - frequencyOrder.indexOf(b.frequency); default: return 0; } }); // Calculate summary statistics const totalUpcoming = upcomingTransactions.filter(st => st.upcoming_occurrences.length > 0).length; const totalIncome = upcomingTransactions .filter(st => st.amount > 0) .reduce((sum, st) => sum + st.estimated_total_amount, 0); const totalExpenses = upcomingTransactions .filter(st => st.amount < 0) .reduce((sum, st) => sum + Math.abs(st.estimated_total_amount), 0); const netCashFlow = totalIncome - totalExpenses; // Group by frequency const frequencyGroups = {}; upcomingTransactions.forEach(st => { if (!frequencyGroups[st.frequency]) { frequencyGroups[st.frequency] = { frequency: st.frequency, count: 0, total_amount: 0, transactions: [] }; } frequencyGroups[st.frequency].count++; frequencyGroups[st.frequency].total_amount += Math.abs(st.amount); frequencyGroups[st.frequency].transactions.push({ payee_name: st.payee_name, amount: st.amount, next_occurrence_date: st.next_occurrence_date }); }); // Top categories by scheduled spending const categorySpending = new Map(); upcomingTransactions.filter(st => st.amount < 0 && st.category_name).forEach(st => { const current = categorySpending.get(st.category_name) || { category_name: st.category_name, total_amount: 0, transaction_count: 0 }; current.total_amount += Math.abs(st.estimated_total_amount); current.transaction_count++; categorySpending.set(st.category_name, current); }); const topCategories = Array.from(categorySpending.values()) .sort((a, b) => b.total_amount - a.total_amount) .slice(0, 10); // Upcoming cash flow by week const weeklyForecasts = calculateWeeklyCashFlow(upcomingTransactions, upcoming_days); return { scheduled_transactions: upcomingTransactions, total_count: upcomingTransactions.length, upcoming_count: totalUpcoming, summary: { total_upcoming_income: totalIncome, total_upcoming_expenses: totalExpenses, net_cash_flow: netCashFlow, active_schedules: upcomingTransactions.filter(st => st.is_active).length, inactive_schedules: upcomingTransactions.filter(st => !st.is_active).length }, frequency_breakdown: Object.values(frequencyGroups).sort((a, b) => b.total_amount - a.total_amount), top_categories: topCategories, weekly_forecast: weeklyForecasts, analysis_period: { start_date: today.toISOString().split('T')[0], end_date: upcomingDate.toISOString().split('T')[0], days_ahead: upcoming_days }, recommendations: generateScheduledTransactionRecommendations(upcomingTransactions, netCashFlow), filters_applied: { budget_id: budgetId, upcoming_days, account_id: account_id || null, category_id: category_id || null, frequency_filter: frequency_filter || null, include_inactive, sort_by } }; }, 10); // Cache for 10 minutes (scheduled transactions change less frequently) } catch (error) { return errorHandler.formatForMCP(error); } }; // Helper function to calculate next occurrence dates function calculateNextOccurrences(scheduledTransaction, fromDate, daysAhead) { const startDate = new Date(Math.max(new Date(scheduledTransaction.date_first), fromDate)); const endDate = new Date(fromDate.getTime() + daysAhead * 24 * 60 * 60 * 1000); const occurrences = []; let currentDate = new Date(startDate); // Generate occurrences based on frequency while (currentDate <= endDate && occurrences.length < 100) { // Safety limit if (currentDate >= fromDate) { occurrences.push(currentDate.toISOString().split('T')[0]); } // Calculate next occurrence based on frequency switch (scheduledTransaction.frequency) { case 'daily': currentDate.setDate(currentDate.getDate() + 1); break; case 'weekly': currentDate.setDate(currentDate.getDate() + 7); break; case 'everyOtherWeek': currentDate.setDate(currentDate.getDate() + 14); break; case 'twiceAMonth': // Approximate: 15 days (twice a month) currentDate.setDate(currentDate.getDate() + 15); break; case 'monthly': currentDate.setMonth(currentDate.getMonth() + 1); break; case 'everyOtherMonth': currentDate.setMonth(currentDate.getMonth() + 2); break; case 'everyThreeMonths': currentDate.setMonth(currentDate.getMonth() + 3); break; case 'everyFourMonths': currentDate.setMonth(currentDate.getMonth() + 4); break; case 'twiceAYear': currentDate.setMonth(currentDate.getMonth() + 6); break; case 'yearly': currentDate.setFullYear(currentDate.getFullYear() + 1); break; default: // If frequency is 'never' or unknown, no future occurrences return occurrences; } } return occurrences; } // Helper function to calculate weekly cash flow forecast function calculateWeeklyCashFlow(transactions, daysAhead) { const weeks = Math.ceil(daysAhead / 7); const weeklyData = []; const today = new Date(); for (let week = 0; week < weeks; week++) { const weekStart = new Date(today.getTime() + week * 7 * 24 * 60 * 60 * 1000); const weekEnd = new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000); let weekIncome = 0; let weekExpenses = 0; let weekTransactionCount = 0; transactions.forEach(st => { st.upcoming_occurrences.forEach(occurrenceDate => { const occDate = new Date(occurrenceDate); if (occDate >= weekStart && occDate <= weekEnd) { weekTransactionCount++; if (st.amount > 0) { weekIncome += st.amount; } else { weekExpenses += Math.abs(st.amount); } } }); }); weeklyData.push({ week_number: week + 1, start_date: weekStart.toISOString().split('T')[0], end_date: weekEnd.toISOString().split('T')[0], income: weekIncome, expenses: weekExpenses, net_flow: weekIncome - weekExpenses, transaction_count: weekTransactionCount }); } return weeklyData; } // Helper function to generate recommendations function generateScheduledTransactionRecommendations(transactions, netCashFlow) { const recommendations = []; // Cash flow recommendations if (netCashFlow < -500) { recommendations.push('Negative cash flow detected - review upcoming expenses and ensure adequate funds'); } else if (netCashFlow < 0) { recommendations.push('Slight negative cash flow - monitor account balances closely'); } else if (netCashFlow > 1000) { recommendations.push('Positive cash flow - consider allocating excess to savings goals'); } // Frequency recommendations const highFrequencyCount = transactions.filter(st => ['daily', 'weekly'].includes(st.frequency) && st.amount < -50 ).length; if (highFrequencyCount > 5) { recommendations.push('Many high-frequency scheduled expenses - consider consolidating or reviewing necessity'); } // Inactive schedule recommendations const inactiveCount = transactions.filter(st => !st.is_active).length; if (inactiveCount > 0) { recommendations.push(`${inactiveCount} inactive scheduled transactions found - consider cleaning up old schedules`); } // Large expense recommendations const largeExpenses = transactions.filter(st => st.amount < -1000); if (largeExpenses.length > 0) { recommendations.push('Large scheduled expenses detected - ensure adequate budgeting and cash flow planning'); } if (recommendations.length === 0) { recommendations.push('Scheduled transactions appear well-managed - maintain regular review'); } return recommendations; } export { toolDefinition, handler };