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>
This commit is contained in:
Jakob Wennberg
2026-03-01 19:22:31 +01:00
co-authored by Claude Opus 4.6
parent aabe527f68
commit 1a1c6ba40f
18 changed files with 856 additions and 280 deletions
@@ -387,6 +387,12 @@ export const aiCategorizationExtension: Extension = {
categorizeTransactions: async (...args: unknown[]) => {
return categorizeTransactions(args[0] as string, args[1] as string[])
},
analyzeDescription: async (...args: unknown[]) => {
const { analyzeDescription } = await import('./lib/description-analyzer')
return analyzeDescription(
args[0] as import('./lib/description-analyzer').DescriptionAnalysisInput
)
},
},
async onInstall(ctx) {
await ctx.settings.set('settings', DEFAULT_SETTINGS)
@@ -0,0 +1,297 @@
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()
})
})
@@ -0,0 +1,276 @@
/**
* AI Description Analyzer
*
* SERVER-ONLY: Uses the Anthropic SDK and must only be imported
* in server components or API routes.
*
* Interprets a user's plain-language description of a bank transaction
* and returns a structured booking suggestion with Swedish accounting reasoning.
* Uses Claude Haiku with structured tool outputs for reliable JSON.
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import type { TransactionCategory, VatTreatment, EntityType } from '@/types'
// ============================================================
// Types
// ============================================================
export interface DescriptionAnalysisInput {
description: string
transactionAmount: number
transactionDate: string
transactionDescription: string
merchantName: string | null
currency: string
entityType: EntityType
}
export interface DescriptionAnalysisResult {
debitAccount: string
creditAccount: string
vatTreatment: VatTreatment | null
category: TransactionCategory
confidence: number
reasoning: string
warnings: string[]
templateId: string | null
}
// ============================================================
// Constants
// ============================================================
const MAX_RETRIES = 2
const RETRY_DELAY_MS = 500
const MODEL = 'claude-haiku-4-5-20251001'
const VALID_CATEGORIES = new Set<string>([
'income_services', 'income_products', 'income_other',
'expense_equipment', 'expense_software', 'expense_travel',
'expense_office', 'expense_marketing', 'expense_professional_services',
'expense_education', 'expense_representation', 'expense_consumables',
'expense_vehicle', 'expense_telecom', 'expense_bank_fees',
'expense_card_fees', 'expense_currency_exchange', 'expense_other',
])
const VALID_VAT_TREATMENTS = new Set<string>([
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
])
// ============================================================
// Tool Schema
// ============================================================
const ANALYZE_TOOL: Anthropic.Tool = {
name: 'analyze_description',
description: 'Analyze a user description and return a structured booking suggestion for the transaction.',
input_schema: {
type: 'object' as const,
properties: {
debitAccount: { type: 'string', description: 'BAS debit account number (4 digits)' },
creditAccount: { type: 'string', description: 'BAS credit account number (4 digits)' },
vatTreatment: {
type: ['string', 'null'],
description: 'VAT treatment: standard_25, reduced_12, reduced_6, reverse_charge, export, exempt, or null if exempt/no VAT',
},
category: { type: 'string', description: 'Transaction category (e.g. expense_representation, income_services)' },
confidence: { type: 'number', description: 'Confidence score 0.0-1.0' },
reasoning: { type: 'string', description: 'Explanation in Swedish of why this booking is correct' },
warnings: {
type: 'array',
items: { type: 'string' },
description: 'Warnings about deductibility limits, special rules, etc. (in Swedish)',
},
templateId: {
type: ['string', 'null'],
description: 'Matching booking template ID if applicable, or null',
},
},
required: ['debitAccount', 'creditAccount', 'vatTreatment', 'category', 'confidence', 'reasoning', 'warnings', 'templateId'],
},
}
// ============================================================
// System Prompt
// ============================================================
function buildSystemPrompt(entityType: EntityType): string {
const privateAccount = entityType === 'aktiebolag' ? '2893' : '2013'
const entityLabel = entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'
return `Du ar expert pa svensk bokforing enligt BAS-kontoplanen. Analysera anvandarens beskrivning av en banktransaktion och returnera ett bokforingsforslag.
VANLIGA BAS-KONTON:
Utgifter: 5010 Lokalhyra | 5410 Forbrukningsinventarier | 5420 Programvara | 5460 Forbrukningsvaror | 5611 Bil/drivmedel | 5800 Resekostnader | 5910 Annonsering | 6071 Representation mat | 6200 Telefon/internet | 6530 Redovisning/konsult | 6570 Bankavgifter | 6991 Ovriga kostnader | ${entityType === 'aktiebolag' ? '7610' : '6991'} Utbildning
Intakter: 3001 Forsaljning 25% | 3002 Forsaljning 12% | 3003 Forsaljning 6% | 3305 Export | 3308 EU-tjanster | 3900 Ovriga intakter
Moms: 2611 Utg moms 25% | 2621 Utg moms 12% | 2631 Utg moms 6% | 2641 Ing moms | 2645 Beraknad ing moms
Ovrigt: 1510 Kundfordringar | 1930 Foretagskonto | 2440 Leverantorsskulder | ${privateAccount} Privat
MOMSREGLER:
- standard_25: Normala varor/tjanster (25%)
- reduced_12: Livsmedel, hotell, konstverk (12%)
- reduced_6: Bocker, tidningar, kollektivtrafik, kultur (6%)
- reverse_charge: Tjanstekop fran utlandet/EU
- export: Forsaljning utanfor Sverige
- exempt: Momsfritt (bank, forsakring, sjukvard, utbildning)
VARNINGSREGLER:
- Representation/maltider: Max 300 kr/person exkl moms for avdragsratt (IL 16 kap 2§)
- Gavor: Reklamgavor max 300 kr, representationsgavor max 180 kr
- Blandad anvandning (telefon/dator): Bara yrkesmassig del avdragsgill
- Bankavgifter, kortavgifter, valutavaxling: MOMSFRIA (exempt)
Foretagsform: ${entityLabel}
Privatkonto: ${privateAccount}
REGLER:
1. Negativt belopp = utgift: debitera kostnadskonto, kreditera 1930
2. Positivt belopp = intakt: debitera 1930, kreditera intaktskonto
3. Ge ett klart reasoning pa svenska som forklarar valet
4. Lagg till warnings for avdragsbegransningar eller speciella regler
5. templateId: null (vi matchar mallar separat)`
}
// ============================================================
// Analyzer
// ============================================================
export async function analyzeDescription(
input: DescriptionAnalysisInput
): Promise<DescriptionAnalysisResult> {
const client = new Anthropic()
const isExpense = input.transactionAmount < 0
const userPrompt = `Transaktion:
- Anvandarens beskrivning: "${input.description}"
- Banktext: "${input.transactionDescription}"
- Belopp: ${input.transactionAmount} ${input.currency}
- Datum: ${input.transactionDate}${input.merchantName ? `\n- Handlare: ${input.merchantName}` : ''}
Analysera och returnera bokforingsforslag med analyze_description-verktyget.`
let lastError: Error | null = null
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const message = await client.messages.create({
model: MODEL,
max_tokens: 1024,
system: [
{
type: 'text',
text: buildSystemPrompt(input.entityType),
cache_control: { type: 'ephemeral' },
},
],
tools: [ANALYZE_TOOL],
tool_choice: { type: 'tool', name: 'analyze_description' },
messages: [{ role: 'user', content: userPrompt }],
})
const toolUseBlock = message.content.find(
(block) => block.type === 'tool_use' && block.name === 'analyze_description'
)
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
throw new Error('No tool_use block in AI response')
}
return validateResult(toolUseBlock.input as Record<string, unknown>, isExpense, input.entityType)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (attempt < MAX_RETRIES) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(
`AI description analysis failed after ${MAX_RETRIES + 1} attempts: ${lastError?.message}`
)
}
// ============================================================
// Validation
// ============================================================
function validateResult(
raw: Record<string, unknown>,
isExpense: boolean,
entityType: EntityType
): DescriptionAnalysisResult {
const ACCOUNT_REGEX = /^\d{4}$/
// Validate accounts — default to safe fallbacks
let debitAccount = typeof raw.debitAccount === 'string' && ACCOUNT_REGEX.test(raw.debitAccount)
? raw.debitAccount
: (isExpense ? '6991' : '1930')
let creditAccount = typeof raw.creditAccount === 'string' && ACCOUNT_REGEX.test(raw.creditAccount)
? raw.creditAccount
: (isExpense ? '1930' : '3001')
// Enforce direction: expenses debit expense account + credit 1930, income debit 1930 + credit revenue
if (isExpense && creditAccount !== '1930') {
creditAccount = '1930'
}
if (!isExpense && debitAccount !== '1930') {
debitAccount = '1930'
}
// Validate VAT treatment
const rawVat = raw.vatTreatment as string | null
const vatTreatment = rawVat && VALID_VAT_TREATMENTS.has(rawVat)
? rawVat as VatTreatment
: null
// Validate category with direction correction
let category = VALID_CATEGORIES.has(raw.category as string)
? (raw.category as TransactionCategory)
: (isExpense ? 'expense_other' : 'income_other')
if (category === 'private') {
category = isExpense ? 'expense_other' : 'income_other'
}
if (isExpense && category.startsWith('income_')) {
category = 'expense_other'
}
if (!isExpense && category.startsWith('expense_')) {
category = 'income_other'
}
// Clamp confidence
const confidence = Math.max(0, Math.min(1, Number(raw.confidence) || 0.5))
// Reasoning — must be a non-empty string
const reasoning = typeof raw.reasoning === 'string' && raw.reasoning.length > 0
? raw.reasoning
: (isExpense ? 'Utgift bokford pa standardkonto' : 'Intakt bokford pa standardkonto')
// Warnings
const warnings = Array.isArray(raw.warnings)
? (raw.warnings as unknown[]).filter((w): w is string => typeof w === 'string')
: []
// Template ID
const templateId = typeof raw.templateId === 'string' && raw.templateId.length > 0
? raw.templateId
: null
return {
debitAccount,
creditAccount,
vatTreatment,
category,
confidence,
reasoning,
warnings,
templateId,
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -1,142 +0,0 @@
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
// ============================================================
// Settings
// ============================================================
export interface UserDescriptionMatchSettings {
batchApplyEnabled: boolean
minConfidenceThreshold: number
}
const DEFAULT_SETTINGS: UserDescriptionMatchSettings = {
batchApplyEnabled: true,
minConfidenceThreshold: 0.55,
}
export async function getSettings(userId: string): Promise<UserDescriptionMatchSettings> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', userId)
.eq('extension_id', 'user-description-match')
.eq('key', 'settings')
.single()
if (!data?.value) return { ...DEFAULT_SETTINGS }
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<UserDescriptionMatchSettings>) }
}
export async function saveSettings(
userId: string,
partial: Partial<UserDescriptionMatchSettings>
): Promise<UserDescriptionMatchSettings> {
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: 'user-description-match',
key: 'settings',
value: merged,
},
{ onConflict: 'user_id,extension_id,key' }
)
return merged
}
// ============================================================
// Event Handler
// ============================================================
async function handleTransactionCategorized(
payload: EventPayload<'transaction.categorized'>,
ctx?: ExtensionContext
): Promise<void> {
const { transaction, userId } = payload
const log = ctx?.log ?? console
if (!transaction.merchant_name) return
const settings = ctx
? { ...(DEFAULT_SETTINGS), ...(await ctx.settings.get<Partial<UserDescriptionMatchSettings>>() || {}) }
: await getSettings(userId)
if (!settings.batchApplyEnabled) return
try {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
// Check if the rule that fired was a user-described rule
const { data: rule } = await supabase
.from('mapping_rules')
.select('id, user_description, merchant_pattern')
.eq('user_id', userId)
.eq('source', 'user_description')
.ilike('merchant_pattern', transaction.merchant_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.limit(1)
.single()
if (!rule) return
// Count uncategorized siblings
const { count } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('merchant_name', transaction.merchant_name)
.is('journal_entry_id', null)
if (!count || count === 0) return
// Store batch hint for the UI
await supabase.from('extension_data').upsert(
{
user_id: userId,
extension_id: 'user-description-match',
key: `batch_hint:${transaction.merchant_name}`,
value: {
merchant_name: transaction.merchant_name,
uncategorized_count: count,
user_description: rule.user_description,
},
},
{ onConflict: 'user_id,extension_id,key' }
)
log.info(`[user-description-match] Batch hint stored: ${count} uncategorized for ${transaction.merchant_name}`)
} catch (error) {
log.error('[user-description-match] handleTransactionCategorized failed:', error)
}
}
// ============================================================
// Extension Object
// ============================================================
export const userDescriptionMatchExtension: Extension = {
id: 'user-description-match',
name: 'Beskrivningsmatchning',
version: '1.0.0',
eventHandlers: [
{ eventType: 'transaction.categorized', handler: handleTransactionCategorized },
],
settingsPanel: {
label: 'Beskrivningsmatchning',
path: '/settings/extensions/user-description-match',
},
async onInstall(ctx) {
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}
@@ -1,19 +0,0 @@
{
"id": "user-description-match",
"sector": "general",
"exportName": "userDescriptionMatchExtension",
"entryPoint": "@/extensions/general/user-description-match",
"workspace": "@/components/extensions/general/UserDescriptionMatchWorkspace",
"requiredEnvVars": [],
"optionalEnvVars": [],
"npmDependencies": [],
"definition": {
"name": "Beskrivningsmatchning",
"category": "operations",
"icon": "TextSearch",
"dataPattern": "core",
"readsCoreTables": ["transactions", "mapping_rules"],
"description": "Matcha transaktioner med egna beskrivningar",
"longDescription": "Beskriv vad en transaktion gäller med egna ord och få smarta bokföringsförslag. Systemet lär sig av dina beskrivningar och applicerar automatiskt på framtida transaktioner från samma leverantör."
}
}