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.6.0",
"version": "2.7.0",
"description": "A command-line interface for You Need a Budget (YNAB)",
"type": "module",
"main": "./dist/cli.js",
Expand Down
9 changes: 8 additions & 1 deletion src/commands/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from 'commander';
import { client } from '../lib/api-client.js';
import { outputJson } from '../lib/output.js';
import { withErrorHandling } from '../lib/command-utils.js';
import { applyFieldSelection } from '../lib/utils.js';
import { parseDate } from '../lib/dates.js';
import type { CommandOptions } from '../types/index.js';

Expand Down Expand Up @@ -38,6 +39,10 @@ export function createAccountsCommand(): Command {
.option('-b, --budget <id>', 'Budget ID')
.option('--since <date>', 'Filter transactions since date')
.option('--type <type>', 'Filter by transaction type')
.option(
'--fields <fields>',
'Comma-separated list of fields to include (e.g., id,date,amount,memo)'
)
.action(
withErrorHandling(
async (
Expand All @@ -46,14 +51,16 @@ export function createAccountsCommand(): Command {
budget?: string;
since?: string;
type?: string;
fields?: string;
} & CommandOptions
) => {
const result = await client.getTransactionsByAccount(id, {
budgetId: options.budget,
sinceDate: options.since ? parseDate(options.since) : undefined,
type: options.type,
});
outputJson(result?.transactions);
const transactions = result?.transactions || [];
outputJson(applyFieldSelection(transactions, options.fields));
}
)
);
Expand Down
40 changes: 37 additions & 3 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,54 @@
import { Command } from 'commander';
import { createInterface } from 'readline';
import { auth } from '../lib/auth.js';
import { outputJson } from '../lib/output.js';
import { client } from '../lib/api-client.js';
import { withErrorHandling } from '../lib/command-utils.js';
import { YnabCliError } from '../lib/errors.js';

function readTokenFromStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data.trim()));
process.stdin.on('error', reject);
});
}

function promptForToken(): Promise<string> {
return new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stderr,
});
process.stderr.write('Enter YNAB Personal Access Token: ');
rl.question('', (answer) => {
rl.close();
resolve(answer.trim());
});
});
}

export function createAuthCommand(): Command {
const cmd = new Command('auth').description('Authentication management');

cmd
.command('login')
.description('Configure access token')
.requiredOption('-t, --token <token>', 'YNAB Personal Access Token')
.option('-t, --token <token>', 'YNAB Personal Access Token')
.action(
withErrorHandling(async (options: { token: string }) => {
const token = options.token.trim();
withErrorHandling(async (options: { token?: string }) => {
let token: string;

if (options.token) {
token = options.token.trim();
} else if (!process.stdin.isTTY) {
token = await readTokenFromStdin();
} else {
token = await promptForToken();
}

if (!token) {
throw new YnabCliError('Access token cannot be empty', 400);
}
Expand Down
10 changes: 8 additions & 2 deletions src/commands/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Command } from 'commander';
import { client } from '../lib/api-client.js';
import { outputJson } from '../lib/output.js';
import { YnabCliError } from '../lib/errors.js';
import { amountToMilliunits } from '../lib/utils.js';
import { amountToMilliunits, applyFieldSelection } from '../lib/utils.js';
import { withErrorHandling } from '../lib/command-utils.js';
import { parseDate } from '../lib/dates.js';
import type { CommandOptions } from '../types/index.js';
Expand Down Expand Up @@ -135,6 +135,10 @@ export function createCategoriesCommand(): Command {
.option('--since <date>', 'Filter transactions since date')
.option('--type <type>', 'Filter by transaction type')
.option('--last-knowledge <number>', 'Last knowledge of server', parseInt)
.option(
'--fields <fields>',
'Comma-separated list of fields to include (e.g., id,date,amount,memo)'
)
.action(
withErrorHandling(
async (
Expand All @@ -144,6 +148,7 @@ export function createCategoriesCommand(): Command {
since?: string;
type?: string;
lastKnowledge?: number;
fields?: string;
} & CommandOptions
) => {
const result = await client.getTransactionsByCategory(id, {
Expand All @@ -152,7 +157,8 @@ export function createCategoriesCommand(): Command {
type: options.type,
lastKnowledgeOfServer: options.lastKnowledge,
});
outputJson(result?.transactions);
const transactions = result?.transactions || [];
outputJson(applyFieldSelection(transactions, options.fields));
}
)
);
Expand Down
9 changes: 8 additions & 1 deletion src/commands/payees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { client } from '../lib/api-client.js';
import { outputJson } from '../lib/output.js';
import { YnabCliError } from '../lib/errors.js';
import { withErrorHandling } from '../lib/command-utils.js';
import { applyFieldSelection } from '../lib/utils.js';
import { parseDate } from '../lib/dates.js';
import type { CommandOptions } from '../types/index.js';

Expand Down Expand Up @@ -78,6 +79,10 @@ export function createPayeesCommand(): Command {
.option('--since <date>', 'Filter transactions since date')
.option('--type <type>', 'Filter by transaction type')
.option('--last-knowledge <number>', 'Last knowledge of server', parseInt)
.option(
'--fields <fields>',
'Comma-separated list of fields to include (e.g., id,date,amount,memo)'
)
.action(
withErrorHandling(
async (
Expand All @@ -87,6 +92,7 @@ export function createPayeesCommand(): Command {
since?: string;
type?: string;
lastKnowledge?: number;
fields?: string;
} & CommandOptions
) => {
const result = await client.getTransactionsByPayee(id, {
Expand All @@ -95,7 +101,8 @@ export function createPayeesCommand(): Command {
type: options.type,
lastKnowledgeOfServer: options.lastKnowledge,
});
outputJson(result?.transactions);
const transactions = result?.transactions || [];
outputJson(applyFieldSelection(transactions, options.fields));
}
)
);
Expand Down
40 changes: 39 additions & 1 deletion src/commands/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type TransactionLike,
} from '../lib/utils.js';
import { withErrorHandling, requireConfirmation, buildUpdateObject } from '../lib/command-utils.js';
import { validateTransactionSplits } from '../lib/schemas.js';
import { validateTransactionSplits, validateBatchUpdates } from '../lib/schemas.js';
import { parseDate, todayDate } from '../lib/dates.js';
import type { CommandOptions } from '../types/index.js';

Expand Down Expand Up @@ -322,6 +322,44 @@ export function createTransactionsCommand(): Command {
)
);

cmd
.command('batch-update')
.description(
'Update multiple transactions in a single API call. Amounts should be in dollars (e.g., -21.40).'
)
.requiredOption(
'--transactions <json>',
'JSON array of transaction updates. Each must have "id" or "import_id". Example: [{"id": "tx1", "approved": true, "category_id": "cat1"}]'
)
.option('-b, --budget <id>', 'Budget ID')
.action(
withErrorHandling(
async (options: { transactions: string; budget?: string } & CommandOptions) => {
let parsed;
try {
parsed = JSON.parse(options.transactions);
} catch {
throw new YnabCliError('Invalid JSON in --transactions parameter', 400);
}

const updates = validateBatchUpdates(parsed);

const transactionsInMilliunits = updates.map((update) => ({
...update,
...(update.amount !== undefined
? { amount: amountToMilliunits(update.amount) }
: {}),
}));

const result = await client.updateTransactions(
{ transactions: transactionsInMilliunits as Parameters<typeof client.updateTransactions>[0]['transactions'] },
options.budget
);
outputJson(result);
}
)
);

cmd
.command('search')
.description('Search transactions')
Expand Down
16 changes: 16 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,22 @@ export class YnabClient {
});
}

async updateTransactions(
transactions: ynab.PatchTransactionsWrapper,
budgetId?: string
) {
return this.withErrorHandling(async () => {
const api = await this.getApi();
const id = await this.getBudgetId(budgetId);
const response = await api.transactions.updateTransactions(id, transactions);
return {
transactions: response.data.transactions,
transaction_ids: response.data.transaction_ids,
server_knowledge: response.data.server_knowledge,
};
});
}

async deleteTransaction(transactionId: string, budgetId?: string) {
return this.withErrorHandling(async () => {
const api = await this.getApi();
Expand Down
50 changes: 50 additions & 0 deletions src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,56 @@ export function validateTransactionSplits(data: unknown): TransactionSplit[] {
});
}

export interface BatchTransactionUpdate {
id?: string | null;
import_id?: string | null;
account_id?: string;
date?: string;
amount?: number;
payee_id?: string | null;
payee_name?: string | null;
category_id?: string | null;
memo?: string | null;
cleared?: string;
approved?: boolean;
flag_color?: string | null;
}

const BATCH_UPDATE_FIELDS: (keyof BatchTransactionUpdate)[] = [
'id', 'import_id', 'account_id', 'date', 'amount',
'payee_id', 'payee_name', 'category_id', 'memo',
'cleared', 'approved', 'flag_color',
];

export function validateBatchUpdates(data: unknown): BatchTransactionUpdate[] {
if (!Array.isArray(data)) {
throw new YnabCliError('Batch updates must be an array', 400);
}

return data.map((item, index) => {
if (typeof item !== 'object' || item === null) {
throw new YnabCliError(`Update at index ${index} must be an object`, 400);
}

const update = item as Record<string, unknown>;

if (!update.id && !update.import_id) {
throw new YnabCliError(
`Update at index ${index} must have either "id" or "import_id"`,
400
);
}

const result: Record<string, unknown> = {};
for (const field of BATCH_UPDATE_FIELDS) {
if (update[field] !== undefined) {
result[field] = update[field];
}
}
return result as BatchTransactionUpdate;
});
}

export function validateApiData(data: unknown): Record<string, unknown> {
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
throw new YnabCliError('API data must be an object', 400);
Expand Down
Loading