mcp-ynab
Version:
Model Context Protocol server for YNAB integration
253 lines (217 loc) • 7.88 kB
JavaScript
import * as ynab from 'ynab';
import logger from './logger.js';
class YnabClient {
constructor() {
this.apiKey = process.env.YNAB_API_KEY;
this.budgetId = process.env.YNAB_BUDGET_ID;
if (!this.apiKey) {
throw new Error('YNAB_API_KEY environment variable is required');
}
this.ynabAPI = new ynab.API(this.apiKey);
this._defaultBudgetId = null;
}
async getDefaultBudgetId() {
if (this.budgetId) {
return this.budgetId;
}
if (this._defaultBudgetId) {
return this._defaultBudgetId;
}
try {
const budgetsResponse = await this.ynabAPI.budgets.getBudgets();
const budgets = budgetsResponse.data.budgets;
if (budgets.length === 0) {
throw new Error('No budgets found in YNAB account');
}
// Use the first non-demo budget or the first budget if all are demo
const nonDemoBudget = budgets.find(b => !b.name.toLowerCase().includes('demo'));
this._defaultBudgetId = nonDemoBudget ? nonDemoBudget.id : budgets[0].id;
return this._defaultBudgetId;
} catch (error) {
throw new Error(`Failed to get default budget: ${error.message}`);
}
}
async getBudgets() {
try {
await logger.debug('Making YNAB API call to get budgets...');
const response = await this.ynabAPI.budgets.getBudgets();
await logger.debug(`Successfully retrieved ${response.data.budgets.length} budgets`);
return response.data.budgets;
} catch (error) {
await logger.error('Failed to get budgets', { error: error.message });
throw new Error(`Failed to get budgets: ${error.message}`);
}
}
async getBudgetById(budgetId) {
try {
const response = await this.ynabAPI.budgets.getBudgetById(budgetId);
return response.data.budget;
} catch (error) {
throw new Error(`Failed to get budget: ${error.message}`);
}
}
async getCategories(budgetId, month = null) {
try {
budgetId = budgetId || await this.getDefaultBudgetId();
let response;
if (month) {
// When month is specified, get month data which includes categories with their month-specific balances
response = await this.ynabAPI.months.getBudgetMonth(budgetId, month);
if (response.data.month.categories) {
// Month API returns flat categories array, need to group them
// We'll get the category groups from the regular API and merge the month data
const categoriesResponse = await this.ynabAPI.categories.getCategories(budgetId);
const categoryGroups = categoriesResponse.data.category_groups;
const monthCategories = response.data.month.categories;
// Create a map of category ID to month data for quick lookup
const monthCategoryMap = {};
monthCategories.forEach(cat => {
monthCategoryMap[cat.id] = cat;
});
// Merge month-specific data into category groups structure
categoryGroups.forEach(group => {
if (group.categories) {
group.categories.forEach(category => {
const monthData = monthCategoryMap[category.id];
if (monthData) {
// Merge month-specific fields into category
Object.assign(category, monthData);
}
});
}
});
return categoryGroups;
} else {
// Fallback to regular categories call
await logger.debug('Categories not found in month response, falling back to categories API');
response = await this.ynabAPI.categories.getCategories(budgetId);
return response.data.category_groups;
}
} else {
// When no month specified, get current categories
response = await this.ynabAPI.categories.getCategories(budgetId);
return response.data.category_groups;
}
} catch (error) {
throw new Error(`Failed to get categories: ${error.message}`);
}
}
async getAccounts(budgetId) {
try {
budgetId = budgetId || await this.getDefaultBudgetId();
const response = await this.ynabAPI.accounts.getAccounts(budgetId);
return response.data.accounts;
} catch (error) {
throw new Error(`Failed to get accounts: ${error.message}`);
}
}
async getAccountById(budgetId, accountId) {
try {
budgetId = budgetId || await this.getDefaultBudgetId();
const response = await this.ynabAPI.accounts.getAccountById(budgetId, accountId);
return response.data.account;
} catch (error) {
throw new Error(`Failed to get account: ${error.message}`);
}
}
async getTransactions(budgetId, params = {}) {
try {
budgetId = budgetId || await this.getDefaultBudgetId();
const {
accountId,
categoryId,
sinceDate,
untilDate,
limit = 100
} = params;
let response;
if (accountId) {
response = await this.ynabAPI.transactions.getTransactionsByAccount(
budgetId,
accountId,
sinceDate
);
} else if (categoryId) {
response = await this.ynabAPI.transactions.getTransactionsByCategory(
budgetId,
categoryId,
sinceDate
);
} else {
response = await this.ynabAPI.transactions.getTransactions(
budgetId,
sinceDate
);
}
let transactions = response.data.transactions;
// Apply date filtering if needed
if (untilDate) {
transactions = transactions.filter(t => t.date <= untilDate);
}
// Apply limit
if (limit) {
transactions = transactions.slice(0, limit);
}
return transactions;
} catch (error) {
throw new Error(`Failed to get transactions: ${error.message}`);
}
}
async getPayees(budgetId) {
try {
budgetId = budgetId || await this.getDefaultBudgetId();
const response = await this.ynabAPI.payees.getPayees(budgetId);
return response.data.payees;
} catch (error) {
throw new Error(`Failed to get payees: ${error.message}`);
}
}
async getScheduledTransactions(budgetId) {
try {
budgetId = budgetId || await this.getDefaultBudgetId();
const response = await this.ynabAPI.scheduledTransactions.getScheduledTransactions(budgetId);
return response.data.scheduled_transactions;
} catch (error) {
throw new Error(`Failed to get scheduled transactions: ${error.message}`);
}
}
async getMonths(budgetId) {
try {
budgetId = budgetId || await this.getDefaultBudgetId();
const response = await this.ynabAPI.months.getBudgetMonths(budgetId);
return response.data.months;
} catch (error) {
throw new Error(`Failed to get months: ${error.message}`);
}
}
async getMonth(budgetId, month) {
try {
budgetId = budgetId || await this.getDefaultBudgetId();
const response = await this.ynabAPI.months.getBudgetMonth(budgetId, month);
return response.data.month;
} catch (error) {
throw new Error(`Failed to get month: ${error.message}`);
}
}
// Utility methods
getCurrentMonthInISOFormat() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return `${year}-${month}-01`;
}
milliunitsToAmount(milliunits) {
return milliunits / 1000;
}
amountToMilliunits(amount) {
return Math.round(amount * 1000);
}
formatCurrency(milliunits) {
const amount = this.milliunitsToAmount(milliunits);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
}
}
export { YnabClient };