mcp-ynab
Version:
Model Context Protocol server for YNAB integration
426 lines (375 loc) • 15.7 kB
JavaScript
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 };