Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
154 changes: 138 additions & 16 deletions src/commands/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,6 +96,8 @@ export function createTransactionsCommand(): Command {
'--fields <fields>',
'Comma-separated list of fields to include (e.g., id,date,amount,memo)'
)
.option('--last-knowledge <number>', 'Last server knowledge for delta requests. When used, output includes server_knowledge.', parseInt)
.option('--limit <number>', 'Maximum number of transactions to return', parseInt)
.action(
withErrorHandling(
async (
Expand All @@ -86,35 +114,32 @@ 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,
minAmount: options.minAmount,
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);
}
}
)
);
Expand Down Expand Up @@ -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 <id>', 'Budget ID')
.option('--account <id>', 'Filter by account ID')
.option('--category <id>', 'Filter by category ID')
.option('--payee <id>', 'Filter by payee ID')
.option('--since <date>', 'Filter transactions since date')
.option('--until <date>', 'Filter transactions until date')
.option('--type <type>', 'Filter by transaction type')
.option('--approved <value>', 'Filter by approval status: true or false')
.option(
'--status <statuses>',
'Filter by cleared status: cleared, uncleared, reconciled (comma-separated)'
)
.option('--min-amount <amount>', 'Minimum amount in currency units', parseFloat)
.option('--max-amount <amount>', 'Maximum amount in currency units', parseFloat)
.option('--top <number>', '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('<id>', 'Transaction ID')
.option('-b, --budget <id>', 'Budget ID')
.option('--days <number>', 'Maximum date difference in days (default: 3)', parseInt)
.option('--since <date>', '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;
}
143 changes: 143 additions & 0 deletions src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { summarizeTransactions, findTransferCandidates, type SummaryTransaction } from './utils.js';

function makeTx(overrides: Partial<SummaryTransaction> = {}): 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);
});
});
Loading