From 3fe32a04a2e50320150b19df5f9737097e7cf770 Mon Sep 17 00:00:00 2001 From: theprogrammersingh Date: Thu, 3 Sep 2026 23:02:19 +0530 Subject: [PATCH] fix(expenses): add fetch_categories tool and category field to add-expense form - Add fetch_categories read-only WebMCP tool contract and implementation - Update submit_expense tool contract to accept categoryId UUID instead of category name - Add category dropdown selector to Add Expense form with declarative annotations - Update unit tests and tool registration assertions --- frontend/src/app/ai/gemini-schema.spec.ts | 5 +- .../app/pages/add-expense/add-expense.spec.ts | 42 +++++++++++++++-- .../src/app/pages/add-expense/add-expense.ts | 37 ++++++++++++++- frontend/src/app/tools/expense-tools.spec.ts | 46 +++++++++++++++++++ frontend/src/app/tools/expense-tools.ts | 24 +++++++++- frontend/src/app/webmcp/tool-session.spec.ts | 3 +- shared/src/tools.ts | 23 +++++++++- 7 files changed, 169 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/ai/gemini-schema.spec.ts b/frontend/src/app/ai/gemini-schema.spec.ts index 81300f5..f95c983 100644 --- a/frontend/src/app/ai/gemini-schema.spec.ts +++ b/frontend/src/app/ai/gemini-schema.spec.ts @@ -284,12 +284,13 @@ describe('toGeminiSchema', () => { 'get_budget_status', 'get_spend_summary', 'generate_report', + 'fetch_categories', 'approve_expense', ]); for (const declaration of declarations) { expect(declaration.description.length).toBeGreaterThan(0); - // Tools with inputs have parameters; `get_spend_summary` has none. - if (declaration.name === 'get_spend_summary') { + // Tools with inputs have parameters; `get_spend_summary` and `fetch_categories` have none. + if (declaration.name === 'get_spend_summary' || declaration.name === 'fetch_categories') { expect(declaration.parameters).toBeUndefined(); } else { expect(declaration.parameters?.type).toBe('OBJECT'); diff --git a/frontend/src/app/pages/add-expense/add-expense.spec.ts b/frontend/src/app/pages/add-expense/add-expense.spec.ts index 82bda6b..500228d 100644 --- a/frontend/src/app/pages/add-expense/add-expense.spec.ts +++ b/frontend/src/app/pages/add-expense/add-expense.spec.ts @@ -7,12 +7,18 @@ import { AddExpense } from './add-expense.js'; describe('AddExpense (declarative WebMCP surface)', () => { let fixture: ComponentFixture; - let api: { post: ReturnType }; + let api: { get: ReturnType; post: ReturnType }; let audit: { record: ReturnType }; let session: { refreshPendingApprovals: ReturnType }; beforeEach(() => { - api = { post: vi.fn().mockResolvedValue({ id: 'exp-1', amount: 450, currency: 'INR', merchant: 'Barista', status: 'draft' }) }; + api = { + get: vi.fn().mockResolvedValue([ + { id: 'cat-1', orgId: 'org-1', name: 'Travel', icon: 'plane', isDefault: true }, + { id: 'cat-2', orgId: 'org-1', name: 'Meals', icon: 'utensils', isDefault: true }, + ]), + post: vi.fn().mockResolvedValue({ id: 'exp-1', amount: 450, currency: 'INR', merchant: 'Barista', status: 'draft' }), + }; audit = { record: vi.fn() }; session = { refreshPendingApprovals: vi.fn().mockResolvedValue(0) }; TestBed.resetTestingModule(); @@ -56,7 +62,7 @@ describe('AddExpense (declarative WebMCP surface)', () => { }); it('describes every parameter an agent has to fill', () => { - for (const name of ['amount', 'currency', 'merchant', 'expenseDate', 'note']) { + for (const name of ['amount', 'currency', 'categoryId', 'merchant', 'expenseDate', 'note']) { const control = form().querySelector(`[name="${name}"]`); expect(control, `missing control: ${name}`).not.toBeNull(); expect( @@ -134,6 +140,36 @@ describe('AddExpense (declarative WebMCP surface)', () => { expect(fixture.nativeElement.textContent).toContain('Amount must be greater than 0'); }); + it('renders a category dropdown populated from the API', async () => { + await fixture.whenStable(); + fixture.detectChanges(); + + const select = form().querySelector('[name="categoryId"]') as HTMLSelectElement; + expect(select).not.toBeNull(); + // "None" + 2 categories from the mock + expect(select.options.length).toBe(3); + expect(select.options[1].textContent?.trim()).toBe('Travel'); + expect(select.options[1].value).toBe('cat-1'); + }); + + it('includes categoryId in the POST payload', async () => { + await fixture.whenStable(); + fixture.detectChanges(); + + const amount = form().querySelector('[name="amount"]') as HTMLInputElement; + amount.value = '450'; + const select = form().querySelector('[name="categoryId"]') as HTMLSelectElement; + select.value = 'cat-1'; + + form().dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); + await fixture.whenStable(); + + expect(api.post).toHaveBeenCalledWith( + '/expenses', + expect.objectContaining({ categoryId: 'cat-1' }), + ); + }); + /** * This form is the only tool call a person can make — every other tool goes * through `ToolRegistry`, which is always an agent. Without this row the diff --git a/frontend/src/app/pages/add-expense/add-expense.ts b/frontend/src/app/pages/add-expense/add-expense.ts index dd6dbfb..42bda12 100644 --- a/frontend/src/app/pages/add-expense/add-expense.ts +++ b/frontend/src/app/pages/add-expense/add-expense.ts @@ -1,5 +1,6 @@ -import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; -import { CURRENCIES, type Expense } from '@actuo/shared'; +import { ChangeDetectionStrategy, Component, PLATFORM_ID, inject, signal } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { CURRENCIES, type Category, type Expense } from '@actuo/shared'; import { ApiClient } from '../../core/api/api-client.js'; import { Session } from '../../core/session/session.js'; import { ToolCallAudit } from '../../webmcp/tool-call-audit.js'; @@ -86,6 +87,21 @@ import { ToolCallAudit } from '../../webmcp/tool-call-audit.js'; /> +
+ + +
+
(null); protected readonly failed = signal(false); + protected readonly categories = signal([]); + + constructor() { + if (this.isBrowser) void this.loadCategories(); + } + + private async loadCategories(): Promise { + try { + this.categories.set( + await this.api.get('/orgs/current/categories'), + ); + } catch { + // The form still works without categories — the field stays empty. + } + } protected onSubmit(event: Event): void { event.preventDefault(); @@ -151,6 +183,7 @@ export class AddExpense { const payload = { amount: Number(data.get('amount')), currency: String(data.get('currency') ?? 'INR'), + categoryId: (data.get('categoryId') as string) || null, merchant: (data.get('merchant') as string) || null, note: (data.get('note') as string) || null, expenseDate: String(data.get('expenseDate') ?? this.today), diff --git a/frontend/src/app/tools/expense-tools.spec.ts b/frontend/src/app/tools/expense-tools.spec.ts index 7f9d117..f9f265a 100644 --- a/frontend/src/app/tools/expense-tools.spec.ts +++ b/frontend/src/app/tools/expense-tools.spec.ts @@ -76,6 +76,21 @@ describe('ExpenseTools', () => { expect(api.post.mock.calls[1][0]).toBe('/expenses/exp-1/submit'); expect(result).toMatchObject({ status: 'submitted' }); }); + + it('passes categoryId to the create call', async () => { + api.post + .mockResolvedValueOnce(expense({ status: 'draft' })) + .mockResolvedValueOnce(expense({ status: 'submitted' })); + + await tools.submitExpense().execute( + { amount: 450, currency: 'INR', categoryId: 'cat-uuid-1' }, + { signal: new AbortController().signal }, + ); + + const [, createBody] = api.post.mock.calls[0]; + expect(createBody.categoryId).toBe('cat-uuid-1'); + expect(createBody).not.toHaveProperty('category'); + }); }); describe('get_budget_status', () => { @@ -225,6 +240,36 @@ describe('ExpenseTools', () => { }); }); + describe('fetch_categories', () => { + it('is annotated read-only and needs no confirmation', () => { + const tool = tools.fetchCategories(); + expect(tool.contract.annotations.readOnlyHint).toBe(true); + expect(tool.contract.requiresConfirmation).toBe(false); + }); + + it('returns categories with id, name, and icon', async () => { + api.get.mockResolvedValue([ + { id: 'cat-1', orgId: 'org-1', name: 'Travel', icon: 'plane', isDefault: true }, + { id: 'cat-2', orgId: 'org-1', name: 'Meals', icon: 'utensils', isDefault: true }, + ]); + + const result = await tools.fetchCategories().execute( + {}, + { signal: new AbortController().signal }, + ) as Array>; + + expect(api.get).toHaveBeenCalledWith( + '/orgs/current/categories', + undefined, + expect.anything(), + ); + expect(result).toEqual([ + { id: 'cat-1', name: 'Travel', icon: 'plane' }, + { id: 'cat-2', name: 'Meals', icon: 'utensils' }, + ]); + }); + }); + describe('approve_expense', () => { it('routes to approve or reject based on the decision', async () => { api.post.mockResolvedValue(expense({ status: 'approved' })); @@ -253,6 +298,7 @@ describe('ExpenseTools', () => { 'get_budget_status', 'get_spend_summary', 'generate_report', + 'fetch_categories', ]); }); }); diff --git a/frontend/src/app/tools/expense-tools.ts b/frontend/src/app/tools/expense-tools.ts index 1939962..ab581d4 100644 --- a/frontend/src/app/tools/expense-tools.ts +++ b/frontend/src/app/tools/expense-tools.ts @@ -1,5 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { + FETCH_CATEGORIES, GENERATE_REPORT, GET_BUDGET_STATUS, GET_SPEND_SUMMARY, @@ -8,6 +9,7 @@ import { APPROVE_EXPENSE, type AnalyticsSummary, type BudgetStatus, + type Category, type Expense, type ExpensePage, } from '@actuo/shared'; @@ -53,7 +55,7 @@ export class ExpenseTools { amount: number; currency: string; merchant?: string; - category?: string; + categoryId?: string; note?: string; expenseDate?: string; }> { @@ -176,6 +178,25 @@ export class ExpenseTools { }; } + /** Read-only lookup for category IDs (PRD §7). */ + fetchCategories(): ActuoTool> { + return { + contract: FETCH_CATEGORIES, + execute: async (_args, { signal }) => { + const categories = await this.api.get( + '/orgs/current/categories', + undefined, + signal, + ); + return categories.map((c) => ({ + id: c.id, + name: c.name, + icon: c.icon, + })); + }, + }; + } + all(): ActuoTool[] { return [ this.searchExpenses(), @@ -183,6 +204,7 @@ export class ExpenseTools { this.getBudgetStatus(), this.getSpendSummary(), this.generateReport(), + this.fetchCategories(), ] as unknown as ActuoTool[]; } diff --git a/frontend/src/app/webmcp/tool-session.spec.ts b/frontend/src/app/webmcp/tool-session.spec.ts index 1326bcf..fb78083 100644 --- a/frontend/src/app/webmcp/tool-session.spec.ts +++ b/frontend/src/app/webmcp/tool-session.spec.ts @@ -26,9 +26,10 @@ describe('ToolSession (state-gated approve_expense)', () => { registry = TestBed.inject(ToolRegistry); }); - it('publishes the five always-on tools on start', async () => { + it('publishes the always-on tools on start', async () => { await session.start(); expect([...registry.registeredNames()].sort()).toEqual([ + 'fetch_categories', 'generate_report', 'get_budget_status', 'get_spend_summary', diff --git a/shared/src/tools.ts b/shared/src/tools.ts index e9f2412..89fb021 100644 --- a/shared/src/tools.ts +++ b/shared/src/tools.ts @@ -84,14 +84,14 @@ export const SUBMIT_EXPENSE: ActuoToolContract = { name: 'submit_expense', title: 'Submit an expense', description: - 'Create an expense and submit it for approval. Use when the user describes a purchase they want recorded.', + 'Create an expense and submit it for approval. Use when the user describes a purchase they want recorded. If the user mentions a category, call fetch_categories first to get the categoryId.', inputSchema: { type: 'object', properties: { amount: { type: 'number', exclusiveMinimum: 0, description: 'Amount in the given currency.' }, currency: { type: 'string', enum: [...CURRENCIES], default: 'INR' }, merchant: { type: 'string', description: 'Where the money was spent.' }, - category: { type: 'string', description: 'Category name, e.g. "Travel" or "Dining".' }, + categoryId: { type: 'string', description: 'Category UUID from fetch_categories.' }, note: { type: 'string' }, expenseDate: { type: 'string', format: 'date', description: 'YYYY-MM-DD. Defaults to today.' }, }, @@ -163,6 +163,24 @@ export const GENERATE_REPORT: ActuoToolContract = { requiresConfirmation: false, }; +/** + * Read-only lookup so the LLM can resolve human-friendly category names to + * the UUIDs that `submit_expense` and `search_expenses` need. + */ +export const FETCH_CATEGORIES: ActuoToolContract = { + name: 'fetch_categories', + title: 'List expense categories', + description: + 'Returns the organization\'s expense categories with their IDs. Call this before submit_expense when the user mentions a category, so you can pass the correct categoryId.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + annotations: { readOnlyHint: true }, + requiresConfirmation: false, +}; + /** * State-gated (PRD §7): only registered when the signed-in user is an * admin/owner AND at least one expense is awaiting approval. Registration and @@ -203,6 +221,7 @@ export const ALWAYS_ON_TOOLS: readonly ActuoToolContract[] = [ GET_BUDGET_STATUS, GET_SPEND_SUMMARY, GENERATE_REPORT, + FETCH_CATEGORIES, ] as const; export const ALL_TOOL_CONTRACTS: readonly ActuoToolContract[] = [