diff --git a/CLAUDE.md b/CLAUDE.md index e14f8ae..c434439 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,7 @@ 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) +- Updating accounts These operations must be done through YNAB's web/mobile apps. diff --git a/README.md b/README.md index 9db1a1d..2224c63 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ ynab accounts transactions ```bash 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 @@ -92,6 +94,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 +138,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 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 2dd95ec..bcf37c0 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -102,6 +102,77 @@ 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('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/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..5b87730 100644 --- a/src/lib/api-client.test.ts +++ b/src/lib/api-client.test.ts @@ -114,3 +114,90 @@ 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' }); + }); + + 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 028aba0..79d24ca 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -187,6 +187,20 @@ 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 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); @@ -211,6 +225,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);