Skip to content
Open
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
7 changes: 2 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed stale docs

- **src/types/**: TypeScript type definitions

### Authentication Flow
Expand Down Expand Up @@ -90,17 +89,15 @@ 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

## 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.

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ ynab accounts transactions <id>
```bash
ynab categories list
ynab categories view <id>
ynab categories create --name <name> --category-group-id <id> [--note <note>] [--goal-target <amount>]
ynab categories create-group --name <name>
ynab categories update <id> [--name <name>] [--note <note>] [--category-group-id <id>] [--goal-target <amount>]
ynab categories budget <id> --month <YYYY-MM> --amount <amount>
ynab categories transactions <id>
Expand Down Expand Up @@ -92,6 +94,7 @@ ynab transactions split <id> --splits '[{"amount": -50.00, "category_id": "xxx"}
```bash
ynab payees list
ynab payees view <id>
ynab payees create --name <name>
ynab payees update <id> --name <name>
ynab payees locations <id>
ynab payees transactions <id>
Expand Down Expand Up @@ -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.

Expand Down
71 changes: 71 additions & 0 deletions src/commands/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,77 @@ export function createCategoriesCommand(): Command {
)
);

cmd
.command('create')
.description('Create a new category')
.requiredOption('--name <name>', 'Category name')
.requiredOption('--category-group-id <id>', 'Category group ID')
.option('--note <note>', 'Category note')
.option('--goal-target <amount>', 'Goal target amount in dollars', parseFloat)
.option('-b, --budget <id>', '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 <name>', 'Category group name (max 50 characters)')
.option('-b, --budget <id>', '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)')
Expand Down
21 changes: 21 additions & 0 deletions src/commands/payees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,27 @@ export function createPayeesCommand(): Command {
)
);

cmd
.command('create')
.description('Create a new payee')
.requiredOption('--name <name>', 'Payee name')
.option('-b, --budget <id>', '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')
Expand Down
87 changes: 87 additions & 0 deletions src/lib/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
21 changes: 21 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down