From 45de39a377c6b418b5ac05695fd573ad2fedcdd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 11:12:13 +0000 Subject: [PATCH 1/2] Add payees create and categories create commands, fix API limitations docs (#47) Add new methods to YnabClient for creating payees and categories, which are now supported by the YNAB SDK 4.4.0. Add corresponding CLI subcommands for creating payees and categories with proper validation. Update documentation to correct the API limitations section, removing incorrect claims about payee and category creation not being supported. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PEB6n8Noc57qoFsNt2dZLQ --- CLAUDE.md | 8 ++--- README.md | 4 ++- src/commands/categories.ts | 50 ++++++++++++++++++++++++++++++ src/commands/payees.ts | 21 +++++++++++++ src/lib/api-client.test.ts | 63 ++++++++++++++++++++++++++++++++++++++ src/lib/api-client.ts | 14 +++++++++ 6 files changed, 154 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e14f8ae..c4709a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,6 @@ The CLI follows a command-based architecture built on Commander.js: - **utils.ts**: Currency conversion, date formatting, filtering, field selection - **errors.ts**: Centralized error handling for YNAB API errors - **command-utils.ts**: Shared command helpers - - **prompts.ts**: Interactive prompts using inquirer - **src/types/**: TypeScript type definitions ### Authentication Flow @@ -90,7 +89,7 @@ All API calls go through `YnabClient.withErrorHandling()` which catches errors a 2. Export a `create*Command()` function that returns a Commander Command 3. Register in src/cli.ts 4. Use `client` from src/lib/api-client.ts for API calls -5. Use `outputJson()` or `outputSuccess()` for JSON output +5. Use `outputJson()` for JSON output 6. Handle milliunits conversion: - Input: Use `amountToMilliunits()` for user-provided amounts - Output: Automatic via `outputJson()` - don't manually convert @@ -98,9 +97,8 @@ All API calls go through `YnabClient.withErrorHandling()` which catches errors a ## API Limitations YNAB API does not support: -- Creating categories or category groups -- Creating payees -- Creating or updating accounts (beyond initial creation) +- Creating category groups +- Updating accounts These operations must be done through YNAB's web/mobile apps. diff --git a/README.md b/README.md index 9db1a1d..e2e9323 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ ynab accounts transactions ```bash ynab categories list ynab categories view +ynab categories create --name --category-group-id [--note ] [--goal-target ] ynab categories update [--name ] [--note ] [--category-group-id ] [--goal-target ] ynab categories budget --month --amount ynab categories transactions @@ -92,6 +93,7 @@ ynab transactions split --splits '[{"amount": -50.00, "category_id": "xxx"} ```bash ynab payees list ynab payees view +ynab payees create --name ynab payees update --name ynab payees locations ynab payees transactions @@ -135,7 +137,7 @@ All commands return JSON. Use `--compact` for minified output. ## API Limitations -The YNAB API does not support creating categories, category groups, or payees. Use the web or mobile app for these. +The YNAB API does not support creating category groups or updating accounts. Use the web or mobile app for these operations. Rate limit: 200 requests/hour per token. If exceeded, wait 5-10 minutes. diff --git a/src/commands/categories.ts b/src/commands/categories.ts index 2dd95ec..d6934e7 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -102,6 +102,56 @@ export function createCategoriesCommand(): Command { ) ); + cmd + .command('create') + .description('Create a new category') + .requiredOption('--name ', 'Category name') + .requiredOption('--category-group-id ', 'Category group ID') + .option('--note ', 'Category note') + .option('--goal-target ', 'Goal target amount in dollars', parseFloat) + .option('-b, --budget ', 'Budget ID') + .action( + withErrorHandling( + async ( + options: { + name: string; + categoryGroupId: string; + note?: string; + goalTarget?: number; + budget?: string; + } & CommandOptions + ) => { + if (!options.name?.trim()) { + throw new YnabCliError('Name cannot be empty', 400); + } + + const categoryData: { + name: string; + category_group_id: string; + note?: string; + goal_target?: number; + } = { + name: options.name.trim(), + category_group_id: options.categoryGroupId, + }; + + if (options.note !== undefined) { + categoryData.note = options.note.trim(); + } + + if (options.goalTarget !== undefined) { + categoryData.goal_target = amountToMilliunits(options.goalTarget); + } + + const category = await client.createCategory( + { category: categoryData }, + options.budget + ); + outputJson(category); + } + ) + ); + cmd .command('budget') .description('Set category budgeted amount for a month (overrides existing amount)') diff --git a/src/commands/payees.ts b/src/commands/payees.ts index de42c9c..2e8ce4b 100644 --- a/src/commands/payees.ts +++ b/src/commands/payees.ts @@ -59,6 +59,27 @@ export function createPayeesCommand(): Command { ) ); + cmd + .command('create') + .description('Create a new payee') + .requiredOption('--name ', 'Payee name') + .option('-b, --budget ', 'Budget ID') + .action( + withErrorHandling( + async (options: { name: string; budget?: string } & CommandOptions) => { + if (!options.name?.trim()) { + throw new YnabCliError('Name cannot be empty', 400); + } + + const payee = await client.createPayee( + { payee: { name: options.name } }, + options.budget + ); + outputJson(payee); + } + ) + ); + cmd .command('locations') .description('List locations for payee') diff --git a/src/lib/api-client.test.ts b/src/lib/api-client.test.ts index 9b42e38..cf37bed 100644 --- a/src/lib/api-client.test.ts +++ b/src/lib/api-client.test.ts @@ -114,3 +114,66 @@ describe('YnabClient authentication status', () => { expect(mockApiConstructor).toHaveBeenCalledTimes(2); }); }); + +describe('YnabClient payee methods', () => { + const validToken = 'valid-test-token'; + const budgetId = 'test-budget-id'; + + beforeEach(() => { + vi.clearAllMocks(); + mockResolveCredential.mockResolvedValue({ token: validToken, source: 'keychain' }); + }); + + it('creates a payee and returns the created payee', async () => { + const mockCreatePayee = vi.fn(); + mockApiConstructor.mockImplementation(function () { + return { payees: { createPayee: mockCreatePayee } }; + }); + + mockCreatePayee.mockResolvedValue({ + data: { + payee: { id: 'new-payee-id', name: 'Test Payee' }, + }, + }); + + const client = new YnabClient(); + const result = await client.createPayee({ payee: { name: 'Test Payee' } }, budgetId); + + expect(mockCreatePayee).toHaveBeenCalledWith(budgetId, { payee: { name: 'Test Payee' } }); + expect(result).toEqual({ id: 'new-payee-id', name: 'Test Payee' }); + }); +}); + +describe('YnabClient category methods', () => { + const validToken = 'valid-test-token'; + const budgetId = 'test-budget-id'; + + beforeEach(() => { + vi.clearAllMocks(); + mockResolveCredential.mockResolvedValue({ token: validToken, source: 'keychain' }); + }); + + it('creates a category and returns the created category', async () => { + const mockCreateCategory = vi.fn(); + mockApiConstructor.mockImplementation(function () { + return { categories: { createCategory: mockCreateCategory } }; + }); + + mockCreateCategory.mockResolvedValue({ + data: { + category: { id: 'new-category-id', name: 'Test Category' }, + }, + }); + + const client = new YnabClient(); + const result = await client.createCategory( + { category: { name: 'Test Category', category_group_id: 'group-id' } }, + budgetId + ); + + expect(mockCreateCategory).toHaveBeenCalledWith(budgetId, { + category: { name: 'Test Category', category_group_id: 'group-id' }, + }); + expect(result).toEqual({ id: 'new-category-id', name: 'Test Category' }); + }); +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 028aba0..50da57f 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -187,6 +187,13 @@ export class YnabClient { return response.data.category; } + async createCategory(data: ynab.PostCategoryWrapper, budgetId?: string) { + const api = await this.getApi(); + const id = await this.getBudgetId(budgetId); + const response = await api.categories.createCategory(id, data); + return response.data.category; + } + async getPayees(budgetId?: string, lastKnowledgeOfServer?: number) { const api = await this.getApi(); const id = await this.getBudgetId(budgetId); @@ -211,6 +218,13 @@ export class YnabClient { return response.data.payee; } + async createPayee(data: ynab.PostPayeeWrapper, budgetId?: string) { + const api = await this.getApi(); + const id = await this.getBudgetId(budgetId); + const response = await api.payees.createPayee(id, data); + return response.data.payee; + } + async getPayeeLocationsByPayee(payeeId: string, budgetId?: string) { const api = await this.getApi(); const id = await this.getBudgetId(budgetId); From a8a257656970b478950d6a86df01b99f0cbf8e54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 11:32:38 +0000 Subject: [PATCH 2/2] feat: add categories create-group command The YNAB API supports creating category groups, so the remaining docs claim about this limitation is removed as well. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PEB6n8Noc57qoFsNt2dZLQ --- CLAUDE.md | 1 - README.md | 3 ++- src/commands/categories.ts | 21 +++++++++++++++++++++ src/lib/api-client.test.ts | 24 ++++++++++++++++++++++++ src/lib/api-client.ts | 7 +++++++ 5 files changed, 54 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c4709a8..c434439 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,6 @@ All API calls go through `YnabClient.withErrorHandling()` which catches errors a ## API Limitations YNAB API does not support: -- Creating category groups - Updating accounts These operations must be done through YNAB's web/mobile apps. diff --git a/README.md b/README.md index e2e9323..2224c63 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ ynab accounts transactions ynab categories list ynab categories view ynab categories create --name --category-group-id [--note ] [--goal-target ] +ynab categories create-group --name ynab categories update [--name ] [--note ] [--category-group-id ] [--goal-target ] ynab categories budget --month --amount ynab categories transactions @@ -137,7 +138,7 @@ All commands return JSON. Use `--compact` for minified output. ## API Limitations -The YNAB API does not support creating category groups or updating accounts. Use the web or mobile app for these operations. +The YNAB API does not support updating accounts. Use the web or mobile app for this operation. Rate limit: 200 requests/hour per token. If exceeded, wait 5-10 minutes. diff --git a/src/commands/categories.ts b/src/commands/categories.ts index d6934e7..bcf37c0 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -152,6 +152,27 @@ export function createCategoriesCommand(): Command { ) ); + cmd + .command('create-group') + .description('Create a new category group') + .requiredOption('--name ', 'Category group name (max 50 characters)') + .option('-b, --budget ', 'Budget ID') + .action( + withErrorHandling( + async (options: { name: string; budget?: string } & CommandOptions) => { + if (!options.name?.trim()) { + throw new YnabCliError('Name cannot be empty', 400); + } + + const categoryGroup = await client.createCategoryGroup( + { category_group: { name: options.name.trim() } }, + options.budget + ); + outputJson(categoryGroup); + } + ) + ); + cmd .command('budget') .description('Set category budgeted amount for a month (overrides existing amount)') diff --git a/src/lib/api-client.test.ts b/src/lib/api-client.test.ts index cf37bed..5b87730 100644 --- a/src/lib/api-client.test.ts +++ b/src/lib/api-client.test.ts @@ -176,4 +176,28 @@ describe('YnabClient category methods', () => { }); expect(result).toEqual({ id: 'new-category-id', name: 'Test Category' }); }); + + it('creates a category group and returns the created category group', async () => { + const mockCreateCategoryGroup = vi.fn(); + mockApiConstructor.mockImplementation(function () { + return { categories: { createCategoryGroup: mockCreateCategoryGroup } }; + }); + + mockCreateCategoryGroup.mockResolvedValue({ + data: { + category_group: { id: 'new-group-id', name: 'Test Group' }, + }, + }); + + const client = new YnabClient(); + const result = await client.createCategoryGroup( + { category_group: { name: 'Test Group' } }, + budgetId + ); + + expect(mockCreateCategoryGroup).toHaveBeenCalledWith(budgetId, { + category_group: { name: 'Test Group' }, + }); + expect(result).toEqual({ id: 'new-group-id', name: 'Test Group' }); + }); }); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 50da57f..79d24ca 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -194,6 +194,13 @@ export class YnabClient { return response.data.category; } + async createCategoryGroup(data: ynab.PostCategoryGroupWrapper, budgetId?: string) { + const api = await this.getApi(); + const id = await this.getBudgetId(budgetId); + const response = await api.categories.createCategoryGroup(id, data); + return response.data.category_group; + } + async getPayees(budgetId?: string, lastKnowledgeOfServer?: number) { const api = await this.getApi(); const id = await this.getBudgetId(budgetId);