mcp-ynab
Version:
Model Context Protocol server for YNAB integration
514 lines (437 loc) • 19.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_net_worth_trend',
description: 'Analyze net worth changes over time with asset/debt breakdown, growth trends, and wealth building insights',
inputSchema: {
type: 'object',
properties: {
budget_id: {
type: 'string',
description: 'Specific budget ID (optional, defaults to default budget)'
},
time_period: {
type: 'string',
enum: ['last_6_months', 'last_year', 'last_2_years', 'all_time'],
description: 'Time period for net worth trend analysis (default: last_year)',
default: 'last_year'
},
include_off_budget: {
type: 'boolean',
description: 'Include off-budget accounts in net worth calculation (default: true)',
default: true
},
group_by: {
type: 'string',
enum: ['month', 'quarter'],
description: 'Group net worth data by time period (default: month)',
default: 'month'
},
account_type_breakdown: {
type: 'boolean',
description: 'Include breakdown by account types (assets vs debts) (default: true)',
default: true
},
growth_analysis: {
type: 'boolean',
description: 'Include growth rate analysis and projections (default: true)',
default: true
}
},
additionalProperties: false
}
};
const handler = async (params) => {
try {
const ynab = new YnabClient();
const {
budget_id,
time_period = 'last_year',
include_off_budget = true,
group_by = 'month',
account_type_breakdown = true,
growth_analysis = true
} = params;
// Generate cache key
const cacheKey = cache.generateKey('net_worth_trend', params);
return await cache.getOrSet(cacheKey, async () => {
const budgetId = budget_id || await ynab.getDefaultBudgetId();
// Calculate date range based on time period
const endDate = new Date();
let startDate = new Date();
switch (time_period) {
case 'last_6_months':
startDate.setMonth(endDate.getMonth() - 6);
break;
case 'last_year':
startDate.setFullYear(endDate.getFullYear() - 1);
break;
case 'last_2_years':
startDate.setFullYear(endDate.getFullYear() - 2);
break;
case 'all_time':
startDate.setFullYear(endDate.getFullYear() - 5); // Reasonable limit
break;
}
// Get accounts
const accounts = await ynab.getAccounts(budgetId);
// Filter accounts based on preferences
const filteredAccounts = include_off_budget ?
accounts.filter(acc => !acc.closed) :
accounts.filter(acc => acc.on_budget && !acc.closed);
// Since YNAB doesn't provide historical account balances directly,
// we'll calculate net worth trends based on transaction history
// This is an approximation but provides valuable insights
// Get transactions for the analysis period
const transactions = await ynab.getTransactions(budgetId, {
sinceDate: startDate.toISOString().split('T')[0],
untilDate: endDate.toISOString().split('T')[0],
limit: 1000
});
// Calculate net worth trend points
const netWorthData = calculateNetWorthTrend(
transactions,
filteredAccounts,
startDate,
endDate,
group_by
);
// Calculate account type breakdown if requested
let accountTypeBreakdown = null;
if (account_type_breakdown) {
accountTypeBreakdown = calculateAccountTypeBreakdown(filteredAccounts, netWorthData);
}
// Calculate growth analysis if requested
let growthAnalysisData = null;
if (growth_analysis && netWorthData.length >= 2) {
growthAnalysisData = calculateGrowthAnalysis(netWorthData);
}
// Current net worth calculation
const currentNetWorth = filteredAccounts.reduce((total, account) => {
return total + (account.balance / 1000); // Convert from milliunits
}, 0);
// Asset and debt totals
const assetAccounts = filteredAccounts.filter(acc =>
['checking', 'savings', 'cash', 'investmentAccount', 'mortgage'].includes(acc.type) ||
(acc.type === 'creditCard' && acc.balance > 0) // Credit cards with positive balance are assets
);
const debtAccounts = filteredAccounts.filter(acc =>
['creditCard', 'lineOfCredit'].includes(acc.type) && acc.balance < 0 ||
(acc.type === 'mortgage' && acc.balance < 0) // Negative mortgage balance is debt
);
const totalAssets = assetAccounts.reduce((total, acc) => total + Math.max(acc.balance / 1000, 0), 0);
const totalDebts = Math.abs(debtAccounts.reduce((total, acc) => total + Math.min(acc.balance / 1000, 0), 0));
// Generate insights and recommendations
const insights = generateNetWorthInsights(netWorthData, growthAnalysisData, currentNetWorth);
const recommendations = generateNetWorthRecommendations(
growthAnalysisData,
totalAssets,
totalDebts,
currentNetWorth
);
return {
net_worth_trend: netWorthData,
current_snapshot: {
net_worth: currentNetWorth,
total_assets: totalAssets,
total_debts: totalDebts,
asset_to_debt_ratio: totalDebts > 0 ? totalAssets / totalDebts : null,
accounts_included: filteredAccounts.length
},
account_type_breakdown: accountTypeBreakdown,
growth_analysis: growthAnalysisData,
insights: insights,
recommendations: recommendations,
analysis_period: {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
periods_analyzed: netWorthData.length,
time_span_months: Math.round((endDate - startDate) / (1000 * 60 * 60 * 24 * 30))
},
filters_applied: {
budget_id: budgetId,
time_period,
include_off_budget,
group_by,
account_type_breakdown,
growth_analysis
}
};
}, 15); // Cache for 15 minutes (net worth changes less frequently)
} catch (error) {
return errorHandler.formatForMCP(error);
}
};
// Helper function to calculate net worth trend
function calculateNetWorthTrend(transactions, accounts, startDate, endDate, groupBy) {
// Create account balance tracking
const accountBalances = new Map();
accounts.forEach(account => {
accountBalances.set(account.id, {
starting_balance: account.balance / 1000, // Current balance as starting point
account_type: account.type,
on_budget: account.on_budget
});
});
// Generate time periods
const periods = [];
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
let periodKey;
let nextPeriod = new Date(currentDate);
if (groupBy === 'quarter') {
const quarter = Math.floor(currentDate.getMonth() / 3) + 1;
periodKey = `${currentDate.getFullYear()}-Q${quarter}`;
nextPeriod.setMonth(currentDate.getMonth() + 3);
} else {
periodKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
nextPeriod.setMonth(currentDate.getMonth() + 1);
}
periods.push({
period: periodKey,
date: currentDate.toISOString().split('T')[0],
net_worth: 0,
total_assets: 0,
total_debts: 0,
period_change: 0,
transaction_impact: 0
});
currentDate.setTime(nextPeriod.getTime());
}
// Since we don't have historical balance data, we'll work backwards from current balances
// This is an approximation but provides meaningful trend insights
// Group transactions by period and account
const transactionsByPeriod = new Map();
transactions.forEach(transaction => {
const transDate = new Date(transaction.date);
let periodKey;
if (groupBy === 'quarter') {
const quarter = Math.floor(transDate.getMonth() / 3) + 1;
periodKey = `${transDate.getFullYear()}-Q${quarter}`;
} else {
periodKey = `${transDate.getFullYear()}-${String(transDate.getMonth() + 1).padStart(2, '0')}`;
}
if (!transactionsByPeriod.has(periodKey)) {
transactionsByPeriod.set(periodKey, new Map());
}
const periodTransactions = transactionsByPeriod.get(periodKey);
if (!periodTransactions.has(transaction.account_id)) {
periodTransactions.set(transaction.account_id, 0);
}
periodTransactions.set(
transaction.account_id,
periodTransactions.get(transaction.account_id) + (transaction.amount / 1000)
);
});
// Calculate net worth for each period (working backwards from current)
let runningBalances = new Map();
accounts.forEach(account => {
runningBalances.set(account.id, account.balance / 1000);
});
// Calculate for each period (reverse order to work backwards)
for (let i = periods.length - 1; i >= 0; i--) {
const period = periods[i];
let periodAssets = 0;
let periodDebts = 0;
let transactionImpact = 0;
runningBalances.forEach((balance, accountId) => {
const account = accounts.find(acc => acc.id === accountId);
if (!account) return;
// Categorize as asset or debt
if (['checking', 'savings', 'cash', 'investmentAccount'].includes(account.type) ||
(account.type === 'creditCard' && balance > 0)) {
periodAssets += Math.max(balance, 0);
} else if (['creditCard', 'lineOfCredit', 'mortgage'].includes(account.type) && balance < 0) {
periodDebts += Math.abs(Math.min(balance, 0));
}
});
period.total_assets = periodAssets;
period.total_debts = periodDebts;
period.net_worth = periodAssets - periodDebts;
// Calculate transaction impact for this period
const periodTransactions = transactionsByPeriod.get(period.period);
if (periodTransactions) {
periodTransactions.forEach((amount, accountId) => {
transactionImpact += amount;
// Update running balance for next iteration (going backwards)
if (runningBalances.has(accountId)) {
runningBalances.set(accountId, runningBalances.get(accountId) - amount);
}
});
}
period.transaction_impact = transactionImpact;
// Calculate period change
if (i < periods.length - 1) {
period.period_change = period.net_worth - periods[i + 1].net_worth;
}
}
return periods;
}
// Helper function to calculate account type breakdown
function calculateAccountTypeBreakdown(accounts, netWorthData) {
const accountTypes = {
'Cash Accounts': { current: 0, types: ['checking', 'savings', 'cash'] },
'Investment Accounts': { current: 0, types: ['investmentAccount'] },
'Credit Cards': { current: 0, types: ['creditCard'] },
'Loans': { current: 0, types: ['lineOfCredit', 'mortgage'] },
'Other': { current: 0, types: [] }
};
accounts.forEach(account => {
const balance = account.balance / 1000;
let categorized = false;
for (const [category, info] of Object.entries(accountTypes)) {
if (info.types.includes(account.type)) {
accountTypes[category].current += balance;
categorized = true;
break;
}
}
if (!categorized) {
accountTypes['Other'].current += balance;
}
});
// Convert to array format
return Object.entries(accountTypes).map(([name, data]) => ({
category: name,
current_balance: data.current,
percentage_of_net_worth: netWorthData.length > 0 ?
(data.current / Math.max(netWorthData[netWorthData.length - 1].net_worth, 1)) * 100 : 0
})).filter(category => Math.abs(category.current_balance) > 0.01); // Filter out near-zero categories
}
// Helper function to calculate growth analysis
function calculateGrowthAnalysis(netWorthData) {
if (netWorthData.length < 2) return null;
const firstValue = netWorthData[0].net_worth;
const lastValue = netWorthData[netWorthData.length - 1].net_worth;
const totalChange = lastValue - firstValue;
const timeSpanMonths = netWorthData.length;
// Calculate monthly growth rate
const monthlyGrowthRate = timeSpanMonths > 1 ?
(Math.pow(lastValue / Math.max(firstValue, 1), 1 / timeSpanMonths) - 1) * 100 : 0;
// Calculate annual growth rate
const annualGrowthRate = monthlyGrowthRate * 12;
// Find peak and trough
let peakValue = Math.max(...netWorthData.map(d => d.net_worth));
let troughValue = Math.min(...netWorthData.map(d => d.net_worth));
let peakPeriod = netWorthData.find(d => d.net_worth === peakValue)?.period;
let troughPeriod = netWorthData.find(d => d.net_worth === troughValue)?.period;
// Calculate volatility
const avgNetWorth = netWorthData.reduce((sum, d) => sum + d.net_worth, 0) / netWorthData.length;
const variance = netWorthData.reduce((sum, d) => sum + Math.pow(d.net_worth - avgNetWorth, 2), 0) / netWorthData.length;
const standardDeviation = Math.sqrt(variance);
const volatility = Math.abs(avgNetWorth) > 0 ? (standardDeviation / Math.abs(avgNetWorth)) * 100 : 0;
// Calculate projected net worth (simple linear projection)
const recentTrend = netWorthData.length >= 3 ?
netWorthData.slice(-3).reduce((sum, d, i, arr) => {
if (i === 0) return 0;
return sum + (d.net_worth - arr[i-1].net_worth);
}, 0) / 2 : 0;
const projectedOneYearOut = lastValue + (recentTrend * 12);
return {
total_change: totalChange,
total_change_percentage: firstValue !== 0 ? (totalChange / Math.abs(firstValue)) * 100 : 0,
monthly_growth_rate: monthlyGrowthRate,
annual_growth_rate: annualGrowthRate,
peak_net_worth: peakValue,
peak_period: peakPeriod,
trough_net_worth: troughValue,
trough_period: troughPeriod,
volatility_percentage: volatility,
recent_trend_monthly: recentTrend,
projected_one_year: projectedOneYearOut,
growth_consistency: calculateGrowthConsistency(netWorthData)
};
}
// Helper function to calculate growth consistency
function calculateGrowthConsistency(netWorthData) {
if (netWorthData.length < 3) return 0;
let positiveChanges = 0;
let negativeChanges = 0;
for (let i = 1; i < netWorthData.length; i++) {
const change = netWorthData[i].net_worth - netWorthData[i-1].net_worth;
if (change > 0) {
positiveChanges++;
} else if (change < 0) {
negativeChanges++;
}
}
const totalChanges = positiveChanges + negativeChanges;
return totalChanges > 0 ? (positiveChanges / totalChanges) * 100 : 0;
}
// Helper function to generate insights
function generateNetWorthInsights(netWorthData, growthAnalysis, currentNetWorth) {
const insights = [];
// Current position insights
if (currentNetWorth > 100000) {
insights.push('Strong net worth position - well positioned for wealth building');
} else if (currentNetWorth > 0) {
insights.push('Positive net worth - good foundation for financial growth');
} else {
insights.push('Negative net worth indicates debt exceeds assets - focus on debt reduction');
}
// Growth insights
if (growthAnalysis) {
if (growthAnalysis.annual_growth_rate > 10) {
insights.push(`Excellent growth rate of ${growthAnalysis.annual_growth_rate.toFixed(1)}% annually`);
} else if (growthAnalysis.annual_growth_rate > 5) {
insights.push(`Solid growth rate of ${growthAnalysis.annual_growth_rate.toFixed(1)}% annually`);
} else if (growthAnalysis.annual_growth_rate > 0) {
insights.push(`Modest growth rate of ${growthAnalysis.annual_growth_rate.toFixed(1)}% annually`);
} else {
insights.push('Net worth declining - review spending and debt management strategies');
}
if (growthAnalysis.growth_consistency > 75) {
insights.push('Consistent growth pattern indicates stable financial habits');
} else if (growthAnalysis.growth_consistency < 50) {
insights.push('Volatile growth pattern - consider more consistent savings and spending habits');
}
if (growthAnalysis.volatility_percentage > 20) {
insights.push('High net worth volatility - may indicate irregular income or large expenses');
}
}
return insights;
}
// Helper function to generate recommendations
function generateNetWorthRecommendations(growthAnalysis, totalAssets, totalDebts, currentNetWorth) {
const recommendations = [];
// Position-based recommendations
if (currentNetWorth < 0) {
recommendations.push('Priority: Create debt elimination plan to achieve positive net worth');
recommendations.push('Focus on highest interest rate debts first');
} else if (currentNetWorth < 10000) {
recommendations.push('Build emergency fund to protect net worth from unexpected expenses');
recommendations.push('Continue debt reduction while building assets');
} else if (currentNetWorth > 100000) {
recommendations.push('Consider diversifying assets and optimizing investment allocation');
recommendations.push('Review tax optimization strategies for wealth preservation');
}
// Asset-to-debt ratio recommendations
if (totalDebts > 0) {
const assetToDebtRatio = totalAssets / totalDebts;
if (assetToDebtRatio < 1.5) {
recommendations.push('Low asset-to-debt ratio - prioritize debt reduction');
} else if (assetToDebtRatio > 5) {
recommendations.push('Strong asset-to-debt ratio - consider strategic use of leverage for investments');
}
}
// Growth-based recommendations
if (growthAnalysis) {
if (growthAnalysis.annual_growth_rate < 3) {
recommendations.push('Below-average growth rate - review savings and investment strategy');
}
if (growthAnalysis.growth_consistency < 60) {
recommendations.push('Improve growth consistency with regular automated savings and investments');
}
if (growthAnalysis.recent_trend_monthly < 0) {
recommendations.push('Recent downward trend - investigate causes and adjust financial strategy');
}
}
if (recommendations.length === 0) {
recommendations.push('Net worth trend appears healthy - maintain current financial habits and monitor regularly');
}
return recommendations;
}
export { toolDefinition, handler };