diff --git a/package.json b/package.json index 623df02..42ef017 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stephendolan/ynab-cli", - "version": "2.7.0", + "version": "2.8.0", "description": "A command-line interface for You Need a Budget (YNAB)", "type": "module", "main": "./dist/cli.js", diff --git a/src/commands/transactions.ts b/src/commands/transactions.ts index badacc9..7901b68 100644 --- a/src/commands/transactions.ts +++ b/src/commands/transactions.ts @@ -2,17 +2,43 @@ import { Command } from 'commander'; import { client } from '../lib/api-client.js'; import { outputJson } from '../lib/output.js'; import { YnabCliError } from '../lib/errors.js'; +import dayjs from 'dayjs'; import { amountToMilliunits, applyTransactionFilters, applyFieldSelection, + summarizeTransactions, + findTransferCandidates, type TransactionLike, + type SummaryTransaction, } from '../lib/utils.js'; import { withErrorHandling, requireConfirmation, buildUpdateObject } from '../lib/command-utils.js'; import { validateTransactionSplits, validateBatchUpdates } from '../lib/schemas.js'; import { parseDate, todayDate } from '../lib/dates.js'; import type { CommandOptions } from '../types/index.js'; +async function fetchTransactions(options: { + budget?: string; + account?: string; + category?: string; + payee?: string; + since?: string; + type?: string; + lastKnowledge?: number; +}) { + const params = { + budgetId: options.budget, + sinceDate: options.since ? parseDate(options.since) : undefined, + type: options.type, + lastKnowledgeOfServer: options.lastKnowledge, + }; + + if (options.account) return client.getTransactionsByAccount(options.account, params); + if (options.category) return client.getTransactionsByCategory(options.category, params); + if (options.payee) return client.getTransactionsByPayee(options.payee, params); + return client.getTransactions(params); +} + interface TransactionOptions { account?: string; date?: string; @@ -70,6 +96,8 @@ export function createTransactionsCommand(): Command { '--fields ', 'Comma-separated list of fields to include (e.g., id,date,amount,memo)' ) + .option('--last-knowledge ', 'Last server knowledge for delta requests. When used, output includes server_knowledge.', parseInt) + .option('--limit ', 'Maximum number of transactions to return', parseInt) .action( withErrorHandling( async ( @@ -86,25 +114,14 @@ export function createTransactionsCommand(): Command { minAmount?: number; maxAmount?: number; fields?: string; + lastKnowledge?: number; + limit?: number; } & CommandOptions ) => { - const params = { - budgetId: options.budget, - sinceDate: options.since ? parseDate(options.since) : undefined, - type: options.type, - }; - - const result = options.account - ? await client.getTransactionsByAccount(options.account, params) - : options.category - ? await client.getTransactionsByCategory(options.category, params) - : options.payee - ? await client.getTransactionsByPayee(options.payee, params) - : await client.getTransactions(params); - + const result = await fetchTransactions(options); const transactions = result?.transactions || []; - const filtered = applyTransactionFilters(transactions as TransactionLike[], { + let filtered = applyTransactionFilters(transactions as TransactionLike[], { until: options.until ? parseDate(options.until) : undefined, approved: options.approved, status: options.status, @@ -112,9 +129,17 @@ export function createTransactionsCommand(): Command { maxAmount: options.maxAmount, }); + if (options.limit && options.limit > 0) { + filtered = filtered.slice(0, options.limit); + } + const selected = applyFieldSelection(filtered, options.fields); - outputJson(selected); + if (options.lastKnowledge !== undefined) { + outputJson({ transactions: selected, server_knowledge: result?.server_knowledge }); + } else { + outputJson(selected); + } } ) ); @@ -435,5 +460,102 @@ export function createTransactionsCommand(): Command { ) ); + cmd + .command('summary') + .description('Summarize transactions with aggregate counts by payee, category, and status') + .option('-b, --budget ', 'Budget ID') + .option('--account ', 'Filter by account ID') + .option('--category ', 'Filter by category ID') + .option('--payee ', 'Filter by payee ID') + .option('--since ', 'Filter transactions since date') + .option('--until ', 'Filter transactions until date') + .option('--type ', 'Filter by transaction type') + .option('--approved ', 'Filter by approval status: true or false') + .option( + '--status ', + 'Filter by cleared status: cleared, uncleared, reconciled (comma-separated)' + ) + .option('--min-amount ', 'Minimum amount in currency units', parseFloat) + .option('--max-amount ', 'Maximum amount in currency units', parseFloat) + .option('--top ', 'Limit payee/category breakdowns to top N entries', parseInt) + .action( + withErrorHandling( + async ( + options: { + budget?: string; + account?: string; + category?: string; + payee?: string; + since?: string; + until?: string; + type?: string; + approved?: string; + status?: string; + minAmount?: number; + maxAmount?: number; + top?: number; + } & CommandOptions + ) => { + const result = await fetchTransactions(options); + const transactions = result?.transactions || []; + + const filtered = applyTransactionFilters(transactions as TransactionLike[], { + until: options.until ? parseDate(options.until) : undefined, + approved: options.approved, + status: options.status, + minAmount: options.minAmount, + maxAmount: options.maxAmount, + }); + + const summary = summarizeTransactions( + filtered as SummaryTransaction[], + options.top ? { top: options.top } : undefined + ); + outputJson(summary); + } + ) + ); + + cmd + .command('find-transfers') + .description('Find candidate transfer matches for a transaction across accounts') + .argument('', 'Transaction ID') + .option('-b, --budget ', 'Budget ID') + .option('--days ', 'Maximum date difference in days (default: 3)', parseInt) + .option('--since ', 'Search transactions since date (defaults to source date minus --days)') + .action( + withErrorHandling( + async ( + id: string, + options: { + budget?: string; + days?: number; + since?: string; + } & CommandOptions + ) => { + const maxDays = options.days ?? 3; + const source = await client.getTransaction(id, options.budget); + + const sinceDate = options.since + ? parseDate(options.since) + : dayjs(source.date).subtract(maxDays, 'day').format('YYYY-MM-DD'); + + const result = await client.getTransactions({ + budgetId: options.budget, + sinceDate, + }); + + const allTransactions = result?.transactions || []; + const candidates = findTransferCandidates( + source as SummaryTransaction, + allTransactions as SummaryTransaction[], + { maxDays } + ); + + outputJson({ source, candidates }); + } + ) + ); + return cmd; } diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..74a8e20 --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { summarizeTransactions, findTransferCandidates, type SummaryTransaction } from './utils.js'; + +function makeTx(overrides: Partial = {}): SummaryTransaction { + return { + date: '2026-03-01', + amount: -10000, + approved: false, + cleared: 'uncleared', + account_id: 'acc-1', + payee_id: 'payee-1', + payee_name: 'Store A', + category_id: 'cat-1', + category_name: 'Groceries', + ...overrides, + }; +} + +describe('summarizeTransactions', () => { + it('returns zeroed summary for empty array', () => { + const summary = summarizeTransactions([]); + expect(summary.total_count).toBe(0); + expect(summary.total_amount).toBe(0); + expect(summary.date_range).toBeNull(); + expect(summary.by_payee).toEqual([]); + expect(summary.by_category).toEqual([]); + }); + + it('aggregates counts and amounts', () => { + const transactions = [ + makeTx({ amount: -10000, payee_name: 'Store A', category_name: 'Groceries' }), + makeTx({ amount: -5000, payee_name: 'Store A', category_name: 'Groceries' }), + makeTx({ amount: -20000, payee_name: 'Store B', payee_id: 'payee-2', category_name: 'Dining', category_id: 'cat-2' }), + ]; + const summary = summarizeTransactions(transactions); + expect(summary.total_count).toBe(3); + expect(summary.total_amount).toBe(-35000); + expect(summary.by_payee).toHaveLength(2); + expect(summary.by_payee[0].payee_name).toBe('Store B'); + expect(summary.by_payee[0].total_amount).toBe(-20000); + expect(summary.by_category).toHaveLength(2); + }); + + it('respects top N truncation', () => { + const transactions = [ + makeTx({ amount: -10000, payee_name: 'A', payee_id: 'p1' }), + makeTx({ amount: -20000, payee_name: 'B', payee_id: 'p2' }), + makeTx({ amount: -30000, payee_name: 'C', payee_id: 'p3' }), + ]; + const summary = summarizeTransactions(transactions, { top: 2 }); + expect(summary.by_payee).toHaveLength(3); + expect(summary.by_payee[2].payee_name).toBe('(other)'); + expect(summary.by_payee[2].total_amount).toBe(-10000); + }); + + it('tracks date range', () => { + const transactions = [ + makeTx({ date: '2026-03-01' }), + makeTx({ date: '2026-03-15' }), + makeTx({ date: '2026-03-10' }), + ]; + const summary = summarizeTransactions(transactions); + expect(summary.date_range).toEqual({ from: '2026-03-01', to: '2026-03-15' }); + }); + + it('groups by cleared and approval status', () => { + const transactions = [ + makeTx({ cleared: 'cleared', approved: true }), + makeTx({ cleared: 'cleared', approved: false }), + makeTx({ cleared: 'uncleared', approved: false }), + ]; + const summary = summarizeTransactions(transactions); + expect(summary.by_cleared_status).toHaveLength(2); + expect(summary.by_approval_status).toHaveLength(2); + }); +}); + +describe('findTransferCandidates', () => { + it('finds matching transfer by opposite amount and different account', () => { + const source = makeTx({ amount: -50000, account_id: 'checking', date: '2026-03-05' }); + const all = [ + makeTx({ amount: 50000, account_id: 'savings', date: '2026-03-05' }), + makeTx({ amount: -50000, account_id: 'savings', date: '2026-03-05' }), + makeTx({ amount: 50000, account_id: 'checking', date: '2026-03-05' }), + ]; + const candidates = findTransferCandidates(source, all, { maxDays: 3 }); + expect(candidates).toHaveLength(1); + expect(candidates[0].transaction.account_id).toBe('savings'); + expect(candidates[0].date_difference_days).toBe(0); + }); + + it('excludes transactions outside date range', () => { + const source = makeTx({ amount: -10000, account_id: 'acc-1', date: '2026-03-05' }); + const all = [ + makeTx({ amount: 10000, account_id: 'acc-2', date: '2026-03-20' }), + ]; + const candidates = findTransferCandidates(source, all, { maxDays: 3 }); + expect(candidates).toHaveLength(0); + }); + + it('detects already-linked transfers', () => { + const source = makeTx({ amount: -10000, account_id: 'acc-1', date: '2026-03-05' }); + const all = [ + makeTx({ + amount: 10000, + account_id: 'acc-2', + date: '2026-03-05', + transfer_transaction_id: 'linked-tx', + }), + ]; + const candidates = findTransferCandidates(source, all, { maxDays: 3 }); + expect(candidates).toHaveLength(1); + expect(candidates[0].already_linked).toBe(true); + }); + + it('detects transfer payee pattern', () => { + const source = makeTx({ amount: -10000, account_id: 'acc-1', date: '2026-03-05' }); + const all = [ + makeTx({ + amount: 10000, + account_id: 'acc-2', + date: '2026-03-05', + payee_name: 'Transfer : Checking', + }), + ]; + const candidates = findTransferCandidates(source, all, { maxDays: 3 }); + expect(candidates[0].has_transfer_payee).toBe(true); + }); + + it('sorts by date difference ascending', () => { + const source = makeTx({ amount: -10000, account_id: 'acc-1', date: '2026-03-05' }); + const all = [ + makeTx({ amount: 10000, account_id: 'acc-2', date: '2026-03-07' }), + makeTx({ amount: 10000, account_id: 'acc-3', date: '2026-03-05' }), + makeTx({ amount: 10000, account_id: 'acc-4', date: '2026-03-06' }), + ]; + const candidates = findTransferCandidates(source, all, { maxDays: 3 }); + expect(candidates).toHaveLength(3); + expect(candidates[0].date_difference_days).toBe(0); + expect(candidates[1].date_difference_days).toBe(1); + expect(candidates[2].date_difference_days).toBe(2); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9cf1a0d..00ce18b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -137,6 +137,133 @@ export function applyTransactionFilters( return filtered; } +export type SummaryTransaction = TransactionLike & { + payee_id?: string | null; + payee_name?: string | null; + category_id?: string | null; + category_name?: string | null; + account_id: string; + transfer_account_id?: string | null; + transfer_transaction_id?: string | null; +}; + +interface BreakdownEntry { + count: number; + total_amount: number; +} + +export function summarizeTransactions( + transactions: SummaryTransaction[], + options?: { top?: number } +) { + let totalAmount = 0; + const dates: string[] = []; + const byPayee = new Map(); + const byCategory = new Map(); + const byCleared = new Map(); + const byApproval = new Map(); + + for (const t of transactions) { + totalAmount += t.amount; + dates.push(t.date); + + const payeeKey = t.payee_id || t.payee_name || '(none)'; + const payeeEntry = byPayee.get(payeeKey) || { + payee_id: t.payee_id || null, + payee_name: t.payee_name || null, + count: 0, + total_amount: 0, + }; + payeeEntry.count++; + payeeEntry.total_amount += t.amount; + byPayee.set(payeeKey, payeeEntry); + + const catKey = t.category_id || t.category_name || '(uncategorized)'; + const catEntry = byCategory.get(catKey) || { + category_id: t.category_id || null, + category_name: t.category_name || null, + count: 0, + total_amount: 0, + }; + catEntry.count++; + catEntry.total_amount += t.amount; + byCategory.set(catKey, catEntry); + + const clearedEntry = byCleared.get(t.cleared) || { count: 0, total_amount: 0 }; + clearedEntry.count++; + clearedEntry.total_amount += t.amount; + byCleared.set(t.cleared, clearedEntry); + + const approvalKey = String(t.approved); + const approvalEntry = byApproval.get(approvalKey) || { count: 0, total_amount: 0 }; + approvalEntry.count++; + approvalEntry.total_amount += t.amount; + byApproval.set(approvalKey, approvalEntry); + } + + const sortByAbsAmount = (entries: T[]): T[] => + entries.sort((a, b) => Math.abs(b.total_amount) - Math.abs(a.total_amount)); + + const truncate = (entries: T[], rollupFactory: (entry: BreakdownEntry) => T): T[] => { + const top = options?.top; + if (!top || top <= 0 || entries.length <= top) return entries; + const kept = entries.slice(0, top); + const rest = entries.slice(top); + const rollup = rollupFactory({ + count: rest.reduce((sum, e) => sum + e.count, 0), + total_amount: rest.reduce((sum, e) => sum + e.total_amount, 0), + }); + return [...kept, rollup]; + }; + + const payeeBreakdown = sortByAbsAmount([...byPayee.values()]); + const categoryBreakdown = sortByAbsAmount([...byCategory.values()]); + + return { + total_count: transactions.length, + total_amount: totalAmount, + date_range: dates.length > 0 + ? { + from: dates.reduce((a, b) => (a < b ? a : b)), + to: dates.reduce((a, b) => (a > b ? a : b)), + } + : null, + by_payee: truncate(payeeBreakdown, (e) => ({ payee_id: null, payee_name: '(other)', ...e })), + by_category: truncate(categoryBreakdown, (e) => ({ category_id: null, category_name: '(other)', ...e })), + by_cleared_status: [...byCleared.entries()].map(([status, entry]) => ({ status, ...entry })), + by_approval_status: [...byApproval.entries()].map(([approved, entry]) => ({ approved: approved === 'true', ...entry })), + }; +} + +export function findTransferCandidates( + source: SummaryTransaction, + allTransactions: SummaryTransaction[], + options: { maxDays: number } +) { + const sourceAmount = Math.abs(source.amount); + const sourceDateMs = new Date(source.date).getTime(); + const msPerDay = 86400000; + + function daysBetween(t: SummaryTransaction): number { + return Math.abs(new Date(t.date).getTime() - sourceDateMs) / msPerDay; + } + + return allTransactions + .filter((t) => { + if (t.account_id === source.account_id) return false; + if (Math.abs(t.amount) !== sourceAmount) return false; + if (Math.sign(t.amount) === Math.sign(source.amount)) return false; + return daysBetween(t) <= options.maxDays; + }) + .map((t) => ({ + transaction: t, + already_linked: !!t.transfer_transaction_id, + date_difference_days: Math.round(daysBetween(t)), + has_transfer_payee: !!t.payee_name?.startsWith('Transfer :'), + })) + .sort((a, b) => a.date_difference_days - b.date_difference_days); +} + export function applyFieldSelection(items: T[], fields?: string): Partial[] { if (!fields) return items; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 84dff43..749e3fb 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod'; import { client } from '../lib/api-client.js'; import { auth } from '../lib/auth.js'; -import { amountToMilliunits, applyFieldSelection, convertMilliunitsToAmounts } from '../lib/utils.js'; +import { amountToMilliunits, applyFieldSelection, applyTransactionFilters, convertMilliunitsToAmounts, summarizeTransactions, findTransferCandidates, type SummaryTransaction, type TransactionLike } from '../lib/utils.js'; const toolRegistry = [ { name: 'list_budgets', description: 'List all budgets in the YNAB account' }, @@ -22,6 +22,8 @@ const toolRegistry = [ { name: 'delete_transaction', description: 'Delete a transaction' }, { name: 'import_transactions', description: 'Trigger import of linked bank transactions' }, { name: 'batch_update_transactions', description: 'Update multiple transactions in a single API call' }, + { name: 'summarize_transactions', description: 'Get aggregate summary of transactions by payee, category, and status' }, + { name: 'find_transfer_candidates', description: 'Find candidate transfer matches for a transaction across accounts' }, { name: 'list_transactions_by_account', description: 'List transactions for a specific account' }, { name: 'list_transactions_by_category', description: 'List transactions for a specific category' }, { name: 'list_transactions_by_payee', description: 'List transactions for a specific payee' }, @@ -149,9 +151,17 @@ server.tool( budgetId: z.string().optional().describe('Budget ID (uses default if not specified)'), sinceDate: z.string().optional().describe('Only return transactions on or after this date (YYYY-MM-DD)'), type: z.enum(['uncategorized', 'unapproved']).optional().describe('Filter by transaction type'), + lastKnowledgeOfServer: z.number().optional().describe('Delta sync: only return changes since this server knowledge value. Response includes server_knowledge for next call.'), + limit: z.number().optional().describe('Maximum number of transactions to return'), + fields: z.string().optional().describe('Comma-separated list of fields to include (e.g., id,date,amount,memo)'), }, - async ({ budgetId, sinceDate, type }) => - currencyResponse(await client.getTransactions({ budgetId, sinceDate, type })) + async ({ budgetId, sinceDate, type, lastKnowledgeOfServer, limit, fields }) => { + const result = await client.getTransactions({ budgetId, sinceDate, type, lastKnowledgeOfServer }); + let transactions = result?.transactions || []; + if (limit && limit > 0) transactions = transactions.slice(0, limit); + const selected = fields ? applyFieldSelection(transactions, fields) : transactions; + return currencyResponse({ transactions: selected, server_knowledge: result?.server_knowledge }); + } ); server.tool( @@ -278,6 +288,66 @@ server.tool( } ); +server.tool( + 'summarize_transactions', + 'Get aggregate summary of transactions by payee, category, and status', + { + budgetId: z.string().optional().describe('Budget ID (uses default if not specified)'), + sinceDate: z.string().optional().describe('Only return transactions on or after this date (YYYY-MM-DD)'), + untilDate: z.string().optional().describe('Only return transactions on or before this date (YYYY-MM-DD)'), + type: z.enum(['uncategorized', 'unapproved']).optional().describe('Filter by transaction type'), + approved: z.enum(['true', 'false']).optional().describe('Filter by approval status'), + status: z.string().optional().describe('Filter by cleared status: cleared, uncleared, reconciled (comma-separated)'), + minAmount: z.number().optional().describe('Minimum amount in dollars'), + maxAmount: z.number().optional().describe('Maximum amount in dollars'), + top: z.number().optional().describe('Limit payee/category breakdowns to top N entries'), + }, + async ({ budgetId, sinceDate, untilDate, type, approved, status, minAmount, maxAmount, top }) => { + const result = await client.getTransactions({ budgetId, sinceDate, type }); + const transactions = result?.transactions || []; + const filtered = applyTransactionFilters(transactions as TransactionLike[], { + until: untilDate, + approved, + status, + minAmount, + maxAmount, + }); + const summary = summarizeTransactions( + filtered as SummaryTransaction[], + top ? { top } : undefined + ); + return currencyResponse(summary); + } +); + +server.tool( + 'find_transfer_candidates', + 'Find candidate transfer matches for a transaction across accounts', + { + transactionId: z.string().describe('Transaction ID to find transfers for'), + budgetId: z.string().optional().describe('Budget ID (uses default if not specified)'), + maxDays: z.number().optional().describe('Maximum date difference in days (default: 3)'), + sinceDate: z.string().optional().describe('Search transactions since date (defaults to source date minus maxDays)'), + }, + async ({ transactionId, budgetId, maxDays: maxDaysParam, sinceDate }) => { + const days = maxDaysParam ?? 3; + const source = await client.getTransaction(transactionId, budgetId); + if (!sinceDate) { + const d = new Date(source.date); + d.setDate(d.getDate() - days); + sinceDate = d.toISOString().split('T')[0]; + } + const result = await client.getTransactions({ budgetId, sinceDate }); + const allTransactions = result?.transactions || []; + const candidates = findTransferCandidates( + source as SummaryTransaction, + allTransactions as SummaryTransaction[], + { maxDays: days } + ); + return currencyResponse({ source, candidates }); + } +); + server.tool( 'list_transactions_by_account', 'List transactions for a specific account',