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