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