Files
accounted/extensions/general/ai-categorization/lib/__tests__/description-analyzer.test.ts
T
Jakob Wennberg 1a1c6ba40f feat: merge user-description-match into ai-categorization with AI-powered description analysis
Consolidate the standalone user-description-match extension into ai-categorization,
adding an AI description analyzer that provides account/VAT suggestions alongside
template matching. The describe transaction dialog now shows AI suggestions with
confidence scores and supports both template-based and AI-based booking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:22:31 +01:00

298 lines
8.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock server-only (no-op in tests)
vi.mock('server-only', () => ({}))
// Mock Anthropic SDK
const mockCreate = vi.fn()
vi.mock('@anthropic-ai/sdk', () => {
class MockAnthropic {
messages = { create: mockCreate }
}
return { default: MockAnthropic }
})
function makeToolResponse(input: Record<string, unknown>) {
return {
content: [
{
type: 'tool_use',
name: 'analyze_description',
input,
},
],
}
}
describe('description-analyzer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns correct result for an expense with standard VAT', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6071',
creditAccount: '1930',
vatTreatment: 'standard_25',
category: 'expense_representation',
confidence: 0.85,
reasoning: 'Lunch med kund klassificeras som representation.',
warnings: ['Max 300 kr/person for avdragsratt'],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Lunch med kund',
transactionAmount: -450,
transactionDate: '2026-01-15',
transactionDescription: 'RESTAURANT XYZ',
merchantName: 'Restaurant XYZ',
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.debitAccount).toBe('6071')
expect(result.creditAccount).toBe('1930')
expect(result.vatTreatment).toBe('standard_25')
expect(result.category).toBe('expense_representation')
expect(result.confidence).toBe(0.85)
expect(result.reasoning).toContain('representation')
expect(result.warnings).toHaveLength(1)
expect(result.templateId).toBeNull()
})
it('returns correct result for income', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '1930',
creditAccount: '3001',
vatTreatment: 'standard_25',
category: 'income_services',
confidence: 0.9,
reasoning: 'Konsultarvode bokfors som tjansteintakt.',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Konsultarvode',
transactionAmount: 25000,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT FROM CLIENT',
merchantName: null,
currency: 'SEK',
entityType: 'aktiebolag',
})
expect(result.debitAccount).toBe('1930')
expect(result.creditAccount).toBe('3001')
expect(result.category).toBe('income_services')
expect(result.confidence).toBe(0.9)
})
it('corrects category direction mismatch', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6071',
creditAccount: '1930',
vatTreatment: null,
category: 'income_services', // Wrong direction for expense
confidence: 0.7,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -500,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
// Income category should be corrected to expense for negative amount
expect(result.category).toBe('expense_other')
})
it('clamps confidence to [0, 1]', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6991',
creditAccount: '1930',
vatTreatment: null,
category: 'expense_other',
confidence: 1.5, // Over 1
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.confidence).toBe(1)
})
it('falls back to default accounts for invalid account numbers', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: 'INVALID',
creditAccount: 'bad',
vatTreatment: null,
category: 'expense_other',
confidence: 0.5,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
// Should fall back to safe defaults
expect(result.debitAccount).toBe('6991')
expect(result.creditAccount).toBe('1930')
})
it('enforces expense direction: credit account must be 1930', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '5420',
creditAccount: '2440', // Not 1930 for a bank transaction expense
vatTreatment: 'standard_25',
category: 'expense_software',
confidence: 0.8,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Software subscription',
transactionAmount: -500,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.creditAccount).toBe('1930')
})
it('rejects private category', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '2013',
creditAccount: '1930',
vatTreatment: null,
category: 'private',
confidence: 0.9,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.category).toBe('expense_other')
})
it('throws after retries exhausted', async () => {
mockCreate.mockRejectedValue(new Error('API error'))
const { analyzeDescription } = await import('../description-analyzer')
await expect(
analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
).rejects.toThrow('AI description analysis failed after 3 attempts')
// Should have retried 3 times (initial + 2 retries)
expect(mockCreate).toHaveBeenCalledTimes(3)
})
it('validates invalid VAT treatment to null', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6991',
creditAccount: '1930',
vatTreatment: 'invalid_vat',
category: 'expense_other',
confidence: 0.5,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.vatTreatment).toBeNull()
})
})