Skip to content

Commit 8ce29c1

Browse files
stephendolanclaude
andauthored
chore(release): v2.8.0 (#33)
Add transaction summary, pagination controls, and transfer matching. - Add `transactions summary` command and MCP tool for aggregate counts by payee, category, cleared/approval status with `--top N` truncation (#28) - Add `--last-knowledge` and `--limit` to `transactions list` for delta sync and result capping; MCP `list_transactions` gains fields/limit/lastKnowledge (#29) - Add `transactions find-transfers` command and MCP tool for finding candidate transfer matches by amount, date proximity, and account (#30) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e34c52f commit 8ce29c1

File tree

5 files changed

+482
-20
lines changed

5 files changed

+482
-20
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stephendolan/ynab-cli",
3-
"version": "2.7.0",
3+
"version": "2.8.0",
44
"description": "A command-line interface for You Need a Budget (YNAB)",
55
"type": "module",
66
"main": "./dist/cli.js",

src/commands/transactions.ts

Lines changed: 138 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,43 @@ import { Command } from 'commander';
22
import { client } from '../lib/api-client.js';
33
import { outputJson } from '../lib/output.js';
44
import { YnabCliError } from '../lib/errors.js';
5+
import dayjs from 'dayjs';
56
import {
67
amountToMilliunits,
78
applyTransactionFilters,
89
applyFieldSelection,
10+
summarizeTransactions,
11+
findTransferCandidates,
912
type TransactionLike,
13+
type SummaryTransaction,
1014
} from '../lib/utils.js';
1115
import { withErrorHandling, requireConfirmation, buildUpdateObject } from '../lib/command-utils.js';
1216
import { validateTransactionSplits, validateBatchUpdates } from '../lib/schemas.js';
1317
import { parseDate, todayDate } from '../lib/dates.js';
1418
import type { CommandOptions } from '../types/index.js';
1519

20+
async function fetchTransactions(options: {
21+
budget?: string;
22+
account?: string;
23+
category?: string;
24+
payee?: string;
25+
since?: string;
26+
type?: string;
27+
lastKnowledge?: number;
28+
}) {
29+
const params = {
30+
budgetId: options.budget,
31+
sinceDate: options.since ? parseDate(options.since) : undefined,
32+
type: options.type,
33+
lastKnowledgeOfServer: options.lastKnowledge,
34+
};
35+
36+
if (options.account) return client.getTransactionsByAccount(options.account, params);
37+
if (options.category) return client.getTransactionsByCategory(options.category, params);
38+
if (options.payee) return client.getTransactionsByPayee(options.payee, params);
39+
return client.getTransactions(params);
40+
}
41+
1642
interface TransactionOptions {
1743
account?: string;
1844
date?: string;
@@ -70,6 +96,8 @@ export function createTransactionsCommand(): Command {
7096
'--fields <fields>',
7197
'Comma-separated list of fields to include (e.g., id,date,amount,memo)'
7298
)
99+
.option('--last-knowledge <number>', 'Last server knowledge for delta requests. When used, output includes server_knowledge.', parseInt)
100+
.option('--limit <number>', 'Maximum number of transactions to return', parseInt)
73101
.action(
74102
withErrorHandling(
75103
async (
@@ -86,35 +114,32 @@ export function createTransactionsCommand(): Command {
86114
minAmount?: number;
87115
maxAmount?: number;
88116
fields?: string;
117+
lastKnowledge?: number;
118+
limit?: number;
89119
} & CommandOptions
90120
) => {
91-
const params = {
92-
budgetId: options.budget,
93-
sinceDate: options.since ? parseDate(options.since) : undefined,
94-
type: options.type,
95-
};
96-
97-
const result = options.account
98-
? await client.getTransactionsByAccount(options.account, params)
99-
: options.category
100-
? await client.getTransactionsByCategory(options.category, params)
101-
: options.payee
102-
? await client.getTransactionsByPayee(options.payee, params)
103-
: await client.getTransactions(params);
104-
121+
const result = await fetchTransactions(options);
105122
const transactions = result?.transactions || [];
106123

107-
const filtered = applyTransactionFilters(transactions as TransactionLike[], {
124+
let filtered = applyTransactionFilters(transactions as TransactionLike[], {
108125
until: options.until ? parseDate(options.until) : undefined,
109126
approved: options.approved,
110127
status: options.status,
111128
minAmount: options.minAmount,
112129
maxAmount: options.maxAmount,
113130
});
114131

132+
if (options.limit && options.limit > 0) {
133+
filtered = filtered.slice(0, options.limit);
134+
}
135+
115136
const selected = applyFieldSelection(filtered, options.fields);
116137

117-
outputJson(selected);
138+
if (options.lastKnowledge !== undefined) {
139+
outputJson({ transactions: selected, server_knowledge: result?.server_knowledge });
140+
} else {
141+
outputJson(selected);
142+
}
118143
}
119144
)
120145
);
@@ -435,5 +460,102 @@ export function createTransactionsCommand(): Command {
435460
)
436461
);
437462

463+
cmd
464+
.command('summary')
465+
.description('Summarize transactions with aggregate counts by payee, category, and status')
466+
.option('-b, --budget <id>', 'Budget ID')
467+
.option('--account <id>', 'Filter by account ID')
468+
.option('--category <id>', 'Filter by category ID')
469+
.option('--payee <id>', 'Filter by payee ID')
470+
.option('--since <date>', 'Filter transactions since date')
471+
.option('--until <date>', 'Filter transactions until date')
472+
.option('--type <type>', 'Filter by transaction type')
473+
.option('--approved <value>', 'Filter by approval status: true or false')
474+
.option(
475+
'--status <statuses>',
476+
'Filter by cleared status: cleared, uncleared, reconciled (comma-separated)'
477+
)
478+
.option('--min-amount <amount>', 'Minimum amount in currency units', parseFloat)
479+
.option('--max-amount <amount>', 'Maximum amount in currency units', parseFloat)
480+
.option('--top <number>', 'Limit payee/category breakdowns to top N entries', parseInt)
481+
.action(
482+
withErrorHandling(
483+
async (
484+
options: {
485+
budget?: string;
486+
account?: string;
487+
category?: string;
488+
payee?: string;
489+
since?: string;
490+
until?: string;
491+
type?: string;
492+
approved?: string;
493+
status?: string;
494+
minAmount?: number;
495+
maxAmount?: number;
496+
top?: number;
497+
} & CommandOptions
498+
) => {
499+
const result = await fetchTransactions(options);
500+
const transactions = result?.transactions || [];
501+
502+
const filtered = applyTransactionFilters(transactions as TransactionLike[], {
503+
until: options.until ? parseDate(options.until) : undefined,
504+
approved: options.approved,
505+
status: options.status,
506+
minAmount: options.minAmount,
507+
maxAmount: options.maxAmount,
508+
});
509+
510+
const summary = summarizeTransactions(
511+
filtered as SummaryTransaction[],
512+
options.top ? { top: options.top } : undefined
513+
);
514+
outputJson(summary);
515+
}
516+
)
517+
);
518+
519+
cmd
520+
.command('find-transfers')
521+
.description('Find candidate transfer matches for a transaction across accounts')
522+
.argument('<id>', 'Transaction ID')
523+
.option('-b, --budget <id>', 'Budget ID')
524+
.option('--days <number>', 'Maximum date difference in days (default: 3)', parseInt)
525+
.option('--since <date>', 'Search transactions since date (defaults to source date minus --days)')
526+
.action(
527+
withErrorHandling(
528+
async (
529+
id: string,
530+
options: {
531+
budget?: string;
532+
days?: number;
533+
since?: string;
534+
} & CommandOptions
535+
) => {
536+
const maxDays = options.days ?? 3;
537+
const source = await client.getTransaction(id, options.budget);
538+
539+
const sinceDate = options.since
540+
? parseDate(options.since)
541+
: dayjs(source.date).subtract(maxDays, 'day').format('YYYY-MM-DD');
542+
543+
const result = await client.getTransactions({
544+
budgetId: options.budget,
545+
sinceDate,
546+
});
547+
548+
const allTransactions = result?.transactions || [];
549+
const candidates = findTransferCandidates(
550+
source as SummaryTransaction,
551+
allTransactions as SummaryTransaction[],
552+
{ maxDays }
553+
);
554+
555+
outputJson({ source, candidates });
556+
}
557+
)
558+
);
559+
438560
return cmd;
439561
}

src/lib/utils.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { summarizeTransactions, findTransferCandidates, type SummaryTransaction } from './utils.js';
3+
4+
function makeTx(overrides: Partial<SummaryTransaction> = {}): SummaryTransaction {
5+
return {
6+
date: '2026-03-01',
7+
amount: -10000,
8+
approved: false,
9+
cleared: 'uncleared',
10+
account_id: 'acc-1',
11+
payee_id: 'payee-1',
12+
payee_name: 'Store A',
13+
category_id: 'cat-1',
14+
category_name: 'Groceries',
15+
...overrides,
16+
};
17+
}
18+
19+
describe('summarizeTransactions', () => {
20+
it('returns zeroed summary for empty array', () => {
21+
const summary = summarizeTransactions([]);
22+
expect(summary.total_count).toBe(0);
23+
expect(summary.total_amount).toBe(0);
24+
expect(summary.date_range).toBeNull();
25+
expect(summary.by_payee).toEqual([]);
26+
expect(summary.by_category).toEqual([]);
27+
});
28+
29+
it('aggregates counts and amounts', () => {
30+
const transactions = [
31+
makeTx({ amount: -10000, payee_name: 'Store A', category_name: 'Groceries' }),
32+
makeTx({ amount: -5000, payee_name: 'Store A', category_name: 'Groceries' }),
33+
makeTx({ amount: -20000, payee_name: 'Store B', payee_id: 'payee-2', category_name: 'Dining', category_id: 'cat-2' }),
34+
];
35+
const summary = summarizeTransactions(transactions);
36+
expect(summary.total_count).toBe(3);
37+
expect(summary.total_amount).toBe(-35000);
38+
expect(summary.by_payee).toHaveLength(2);
39+
expect(summary.by_payee[0].payee_name).toBe('Store B');
40+
expect(summary.by_payee[0].total_amount).toBe(-20000);
41+
expect(summary.by_category).toHaveLength(2);
42+
});
43+
44+
it('respects top N truncation', () => {
45+
const transactions = [
46+
makeTx({ amount: -10000, payee_name: 'A', payee_id: 'p1' }),
47+
makeTx({ amount: -20000, payee_name: 'B', payee_id: 'p2' }),
48+
makeTx({ amount: -30000, payee_name: 'C', payee_id: 'p3' }),
49+
];
50+
const summary = summarizeTransactions(transactions, { top: 2 });
51+
expect(summary.by_payee).toHaveLength(3);
52+
expect(summary.by_payee[2].payee_name).toBe('(other)');
53+
expect(summary.by_payee[2].total_amount).toBe(-10000);
54+
});
55+
56+
it('tracks date range', () => {
57+
const transactions = [
58+
makeTx({ date: '2026-03-01' }),
59+
makeTx({ date: '2026-03-15' }),
60+
makeTx({ date: '2026-03-10' }),
61+
];
62+
const summary = summarizeTransactions(transactions);
63+
expect(summary.date_range).toEqual({ from: '2026-03-01', to: '2026-03-15' });
64+
});
65+
66+
it('groups by cleared and approval status', () => {
67+
const transactions = [
68+
makeTx({ cleared: 'cleared', approved: true }),
69+
makeTx({ cleared: 'cleared', approved: false }),
70+
makeTx({ cleared: 'uncleared', approved: false }),
71+
];
72+
const summary = summarizeTransactions(transactions);
73+
expect(summary.by_cleared_status).toHaveLength(2);
74+
expect(summary.by_approval_status).toHaveLength(2);
75+
});
76+
});
77+
78+
describe('findTransferCandidates', () => {
79+
it('finds matching transfer by opposite amount and different account', () => {
80+
const source = makeTx({ amount: -50000, account_id: 'checking', date: '2026-03-05' });
81+
const all = [
82+
makeTx({ amount: 50000, account_id: 'savings', date: '2026-03-05' }),
83+
makeTx({ amount: -50000, account_id: 'savings', date: '2026-03-05' }),
84+
makeTx({ amount: 50000, account_id: 'checking', date: '2026-03-05' }),
85+
];
86+
const candidates = findTransferCandidates(source, all, { maxDays: 3 });
87+
expect(candidates).toHaveLength(1);
88+
expect(candidates[0].transaction.account_id).toBe('savings');
89+
expect(candidates[0].date_difference_days).toBe(0);
90+
});
91+
92+
it('excludes transactions outside date range', () => {
93+
const source = makeTx({ amount: -10000, account_id: 'acc-1', date: '2026-03-05' });
94+
const all = [
95+
makeTx({ amount: 10000, account_id: 'acc-2', date: '2026-03-20' }),
96+
];
97+
const candidates = findTransferCandidates(source, all, { maxDays: 3 });
98+
expect(candidates).toHaveLength(0);
99+
});
100+
101+
it('detects already-linked transfers', () => {
102+
const source = makeTx({ amount: -10000, account_id: 'acc-1', date: '2026-03-05' });
103+
const all = [
104+
makeTx({
105+
amount: 10000,
106+
account_id: 'acc-2',
107+
date: '2026-03-05',
108+
transfer_transaction_id: 'linked-tx',
109+
}),
110+
];
111+
const candidates = findTransferCandidates(source, all, { maxDays: 3 });
112+
expect(candidates).toHaveLength(1);
113+
expect(candidates[0].already_linked).toBe(true);
114+
});
115+
116+
it('detects transfer payee pattern', () => {
117+
const source = makeTx({ amount: -10000, account_id: 'acc-1', date: '2026-03-05' });
118+
const all = [
119+
makeTx({
120+
amount: 10000,
121+
account_id: 'acc-2',
122+
date: '2026-03-05',
123+
payee_name: 'Transfer : Checking',
124+
}),
125+
];
126+
const candidates = findTransferCandidates(source, all, { maxDays: 3 });
127+
expect(candidates[0].has_transfer_payee).toBe(true);
128+
});
129+
130+
it('sorts by date difference ascending', () => {
131+
const source = makeTx({ amount: -10000, account_id: 'acc-1', date: '2026-03-05' });
132+
const all = [
133+
makeTx({ amount: 10000, account_id: 'acc-2', date: '2026-03-07' }),
134+
makeTx({ amount: 10000, account_id: 'acc-3', date: '2026-03-05' }),
135+
makeTx({ amount: 10000, account_id: 'acc-4', date: '2026-03-06' }),
136+
];
137+
const candidates = findTransferCandidates(source, all, { maxDays: 3 });
138+
expect(candidates).toHaveLength(3);
139+
expect(candidates[0].date_difference_days).toBe(0);
140+
expect(candidates[1].date_difference_days).toBe(1);
141+
expect(candidates[2].date_difference_days).toBe(2);
142+
});
143+
});

0 commit comments

Comments
 (0)