mcp-ynab
Version:
Model Context Protocol server for YNAB integration
325 lines (289 loc) • 11.6 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_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 };