UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

312 lines (272 loc) 10.8 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_spending_report', description: 'Generate comprehensive spending analysis across timeframes and categories', inputSchema: { type: 'object', properties: { budget_id: { type: 'string', description: 'Specific budget ID (optional, defaults to default budget)' }, period: { type: 'string', enum: ['current_month', 'last_month', 'last_3_months', 'last_6_months', 'year_to_date', 'last_year'], description: 'Time period for the report', default: 'current_month' }, category_groups: { type: 'array', items: { type: 'string' }, description: 'Array of category group IDs to include (optional, defaults to all)' }, include_transfers: { type: 'boolean', description: 'Include transfer transactions in analysis (optional, defaults to false)', default: false } }, additionalProperties: false } }; const handler = async (params) => { try { const ynab = new YnabClient(); const { budget_id, period = 'current_month', category_groups, include_transfers = false } = params; // Generate cache key const cacheKey = cache.generateKey('spending_report', { budget_id, period, category_groups, include_transfers }); return await cache.getOrSet(cacheKey, async () => { const budgetId = budget_id || await ynab.getDefaultBudgetId(); // Calculate date range based on period const dateRange = calculateDateRange(period); // Get transactions for the period const transactions = await ynab.getTransactions(budgetId, { sinceDate: dateRange.start, untilDate: dateRange.end, limit: 5000 // Get more transactions for comprehensive analysis }); // Get additional data for enrichment const [categories, payees] = await Promise.all([ ynab.getCategories(budgetId), ynab.getPayees(budgetId) ]); // Create lookup maps const categoryMap = new Map(); const categoryGroupMap = new Map(); categories.forEach(group => { categoryGroupMap.set(group.id, { id: group.id, name: group.name, categories: [] }); if (group.categories) { group.categories.forEach(cat => { categoryMap.set(cat.id, { ...cat, group_id: group.id, group_name: group.name }); categoryGroupMap.get(group.id).categories.push(cat.id); }); } }); const payeeMap = new Map(payees.map(p => [p.id, p])); // Filter transactions based on criteria let filteredTransactions = transactions.filter(t => { // Filter out transfers unless specifically requested if (!include_transfers && t.transfer_account_id) { return false; } // Filter by category groups if specified if (category_groups && category_groups.length > 0) { const category = categoryMap.get(t.category_id); return category && category_groups.includes(category.group_id); } return true; }); // Analyze spending by category const categorySpending = new Map(); const payeeSpending = new Map(); let totalSpending = 0; let totalIncome = 0; filteredTransactions.forEach(transaction => { const amount = ynab.milliunitsToAmount(transaction.amount); const category = categoryMap.get(transaction.category_id); const payee = payeeMap.get(transaction.payee_id); // Track by category if (category) { const categoryKey = `${category.group_name}:${category.name}`; if (!categorySpending.has(categoryKey)) { categorySpending.set(categoryKey, { category_id: category.id, category_name: category.name, group_id: category.group_id, group_name: category.group_name, amount: 0, transaction_count: 0, transactions: [] }); } const catData = categorySpending.get(categoryKey); catData.amount += Math.abs(amount); // Use absolute value for spending analysis catData.transaction_count++; catData.transactions.push({ date: transaction.date, amount: amount, payee_name: payee ? payee.name : 'Transfer', memo: transaction.memo }); } // Track by payee (only for expenses) if (amount < 0 && payee) { if (!payeeSpending.has(payee.id)) { payeeSpending.set(payee.id, { payee_id: payee.id, payee_name: payee.name, amount: 0, transaction_count: 0 }); } const payeeData = payeeSpending.get(payee.id); payeeData.amount += Math.abs(amount); payeeData.transaction_count++; } // Track totals if (amount < 0) { totalSpending += Math.abs(amount); } else { totalIncome += amount; } }); // Convert maps to sorted arrays const categoryBreakdown = Array.from(categorySpending.values()) .sort((a, b) => b.amount - a.amount) .map(cat => ({ ...cat, percentage_of_total: totalSpending > 0 ? Math.round((cat.amount / totalSpending) * 100 * 100) / 100 : 0, average_transaction: cat.transaction_count > 0 ? Math.round((cat.amount / cat.transaction_count) * 100) / 100 : 0, transactions: undefined // Remove detailed transactions from summary })); const topPayees = Array.from(payeeSpending.values()) .sort((a, b) => b.amount - a.amount) .slice(0, 10) .map(payee => ({ ...payee, percentage_of_total: totalSpending > 0 ? Math.round((payee.amount / totalSpending) * 100 * 100) / 100 : 0, average_transaction: payee.transaction_count > 0 ? Math.round((payee.amount / payee.transaction_count) * 100) / 100 : 0 })); // Group spending by category group const groupSpending = new Map(); categoryBreakdown.forEach(cat => { if (!groupSpending.has(cat.group_id)) { groupSpending.set(cat.group_id, { group_id: cat.group_id, group_name: cat.group_name, amount: 0, category_count: 0, categories: [] }); } const groupData = groupSpending.get(cat.group_id); groupData.amount += cat.amount; groupData.category_count++; groupData.categories.push({ category_name: cat.category_name, amount: cat.amount, percentage_of_group: 0 // Will calculate below }); }); // Calculate percentage within each group Array.from(groupSpending.values()).forEach(group => { group.categories.forEach(cat => { cat.percentage_of_group = group.amount > 0 ? Math.round((cat.amount / group.amount) * 100 * 100) / 100 : 0; }); }); const groupBreakdown = Array.from(groupSpending.values()) .sort((a, b) => b.amount - a.amount) .map(group => ({ ...group, percentage_of_total: totalSpending > 0 ? Math.round((group.amount / totalSpending) * 100 * 100) / 100 : 0 })); return { period: period, date_range: dateRange, total_transactions: filteredTransactions.length, total_spending: Math.round(totalSpending * 100) / 100, total_income: Math.round(totalIncome * 100) / 100, net_cash_flow: Math.round((totalIncome - totalSpending) * 100) / 100, category_breakdown: categoryBreakdown, group_breakdown: groupBreakdown, top_payees: topPayees, filters_applied: { budget_id: budgetId, period: period, category_groups: category_groups || null, include_transfers: include_transfers } }; }, 5); // Cache for 5 minutes } catch (error) { return errorHandler.formatForMCP(error); } }; function calculateDateRange(period) { const now = new Date(); const currentYear = now.getFullYear(); const currentMonth = now.getMonth(); switch (period) { case 'current_month': return { start: new Date(currentYear, currentMonth, 1).toISOString().substring(0, 10), end: new Date(currentYear, currentMonth + 1, 0).toISOString().substring(0, 10) }; case 'last_month': return { start: new Date(currentYear, currentMonth - 1, 1).toISOString().substring(0, 10), end: new Date(currentYear, currentMonth, 0).toISOString().substring(0, 10) }; case 'last_3_months': return { start: new Date(currentYear, currentMonth - 3, 1).toISOString().substring(0, 10), end: new Date(currentYear, currentMonth + 1, 0).toISOString().substring(0, 10) }; case 'last_6_months': return { start: new Date(currentYear, currentMonth - 6, 1).toISOString().substring(0, 10), end: new Date(currentYear, currentMonth + 1, 0).toISOString().substring(0, 10) }; case 'year_to_date': return { start: new Date(currentYear, 0, 1).toISOString().substring(0, 10), end: now.toISOString().substring(0, 10) }; case 'last_year': return { start: new Date(currentYear - 1, 0, 1).toISOString().substring(0, 10), end: new Date(currentYear - 1, 11, 31).toISOString().substring(0, 10) }; default: // Default to current month return { start: new Date(currentYear, currentMonth, 1).toISOString().substring(0, 10), end: new Date(currentYear, currentMonth + 1, 0).toISOString().substring(0, 10) }; } } export { toolDefinition, handler };