diff --git a/CLAUDE.md b/CLAUDE.md index f838f91f..473b48c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,14 +172,13 @@ Extensions are opt-in plugins controlled by `extensions.config.json`. Core build | Extension | Category | Env Vars Required | |-----------|----------|-------------------| | `receipt-ocr` | import | `ANTHROPIC_API_KEY` | -| `ai-categorization` | operations | `OPENAI_API_KEY` | +| `ai-categorization` | operations | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` | | `ai-chat` | operations | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` | | `push-notifications` | operations | VAPID keys | | `invoice-inbox` | import | `ANTHROPIC_API_KEY` | | `calendar` | operations | — | | `enable-banking` | import | Enable Banking keys | | `email` | operations | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | -| `user-description-match` | operations | — | ### Creating Extensions diff --git a/app/api/transactions/[id]/describe/route.ts b/app/api/transactions/[id]/describe/route.ts index 41e08a75..8a67cd01 100644 --- a/app/api/transactions/[id]/describe/route.ts +++ b/app/api/transactions/[id]/describe/route.ts @@ -6,9 +6,48 @@ import { DescribeTransactionSchema } from '@/lib/api/schemas' import { extensionRegistry } from '@/lib/extensions/registry' import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates' import type { Transaction, EntityType } from '@/types' +import type { Extension } from '@/lib/extensions/types' +import type { DescriptionAnalysisInput, DescriptionAnalysisResult } from '@/extensions/general/ai-categorization/lib/description-analyzer' ensureInitialized() +async function getTemplateMatches( + aiExt: Extension | undefined, + transaction: Transaction, + entityType: EntityType, + description: string +): Promise { + if (aiExt?.services?.findSimilarTemplates) { + return aiExt.services.findSimilarTemplates(transaction, entityType, 10, description) + } + return findMatchingTemplates(transaction, entityType) +} + +async function getAiAnalysis( + aiExt: Extension | undefined, + transaction: Transaction, + entityType: EntityType, + description: string +): Promise { + if (!aiExt?.services?.analyzeDescription) return null + + try { + const input: DescriptionAnalysisInput = { + description, + transactionAmount: transaction.amount, + transactionDate: transaction.date, + transactionDescription: transaction.description, + merchantName: transaction.merchant_name, + currency: transaction.currency, + entityType, + } + return await aiExt.services.analyzeDescription(input) + } catch (error) { + console.error('[describe] AI analysis failed, continuing with templates only:', error) + return null + } +} + export async function POST( request: Request, { params }: { params: Promise<{ id: string }> } @@ -47,23 +86,18 @@ export async function POST( const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' - // Run embedding search with user description dominating the query - let templates: TemplateMatch[] const aiExt = extensionRegistry.get('ai-categorization') - if (aiExt?.services?.findSimilarTemplates) { - templates = await aiExt.services.findSimilarTemplates( - transaction as Transaction, - entityType, - 10, - description - ) - } else { - // Fallback to keyword matching when AI extension not loaded - templates = findMatchingTemplates(transaction as Transaction, entityType) - } - // Flag if top confidence is too low - const needsMoreDetail = templates.length === 0 || templates[0].confidence < 0.55 + // Run template matching and AI analysis in parallel + const [templates, aiSuggestion] = await Promise.all([ + getTemplateMatches(aiExt, transaction as Transaction, entityType, description), + getAiAnalysis(aiExt, transaction as Transaction, entityType, description), + ]) + + // AI rescues weak templates: needs_more_detail is false when AI provided a suggestion + const needsMoreDetail = aiSuggestion + ? false + : templates.length === 0 || templates[0].confidence < 0.55 // Count uncategorized sibling transactions from same merchant let batchCandidateCount = 0 @@ -97,6 +131,16 @@ export async function POST( special_rules_sv: m.template.special_rules_sv || null, risk_level: m.template.risk_level, })), + ai_suggestion: aiSuggestion ? { + debit_account: aiSuggestion.debitAccount, + credit_account: aiSuggestion.creditAccount, + vat_treatment: aiSuggestion.vatTreatment, + category: aiSuggestion.category, + confidence: aiSuggestion.confidence, + reasoning: aiSuggestion.reasoning, + warnings: aiSuggestion.warnings, + template_id: aiSuggestion.templateId, + } : null, needs_more_detail: needsMoreDetail, user_description: description, batch_candidate_count: batchCandidateCount, diff --git a/components/extensions/general/UserDescriptionMatchWorkspace.tsx b/components/extensions/general/UserDescriptionMatchWorkspace.tsx deleted file mode 100644 index 5d49b6d1..00000000 --- a/components/extensions/general/UserDescriptionMatchWorkspace.tsx +++ /dev/null @@ -1,15 +0,0 @@ -'use client' - -import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState' -import { TextSearch } from 'lucide-react' - -export default function UserDescriptionMatchWorkspace({ userId }: WorkspaceComponentProps) { - return ( - } - /> - ) -} diff --git a/components/transactions/DescribeTransactionDialog.tsx b/components/transactions/DescribeTransactionDialog.tsx index 45b40d84..2678e0ee 100644 --- a/components/transactions/DescribeTransactionDialog.tsx +++ b/components/transactions/DescribeTransactionDialog.tsx @@ -23,6 +23,7 @@ import { Check, CheckCircle2, AlertTriangle, + Sparkles, } from 'lucide-react' import JournalEntryPreview from './JournalEntryPreview' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' @@ -45,8 +46,20 @@ interface TemplateMatch { risk_level: string } +interface AiSuggestion { + debit_account: string + credit_account: string + vat_treatment: string | null + category: string + confidence: number + reasoning: string + warnings: string[] + template_id: string | null +} + interface DescribeResult { templates: TemplateMatch[] + ai_suggestion: AiSuggestion | null needs_more_detail: boolean user_description: string batch_candidate_count: number @@ -62,6 +75,7 @@ interface DescribeTransactionDialogProps { } type Step = 'describe' | 'pick' | 'batch' +type Selection = { type: 'template'; templateId: string } | { type: 'ai' } function getExamplePrompts(transaction: TransactionWithInvoice): string[] { const desc = (transaction.description || '').toLowerCase() @@ -71,7 +85,6 @@ function getExamplePrompts(transaction: TransactionWithInvoice): string[] { return ['Konsultarvode', 'Forsaljning av varor', 'Aterbetalning'] } - // Contextual suggestions based on description keywords if (desc.includes('restaurang') || desc.includes('lunch') || desc.includes('middag') || desc.includes('mat')) { return ['Lunch med kund', 'Personalmiddag', 'Fika till kontoret'] } @@ -88,10 +101,18 @@ function getExamplePrompts(transaction: TransactionWithInvoice): string[] { return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial'] } - // Generic expense suggestions return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjanst', 'Reklam'] } +function getVatRateFromTreatment(treatment: string | null): number { + switch (treatment) { + case 'standard_25': return 0.25 + case 'reduced_12': return 0.12 + case 'reduced_6': return 0.06 + default: return 0 + } +} + export default function DescribeTransactionDialog({ open, onOpenChange, @@ -106,7 +127,10 @@ export default function DescribeTransactionDialog({ const [isBooking, setIsBooking] = useState(false) const [isBatchApplying, setIsBatchApplying] = useState(false) const [describeResult, setDescribeResult] = useState(null) - const [selectedTemplateId, setSelectedTemplateId] = useState(null) + const [selection, setSelection] = useState(null) + + const selectedTemplateId = selection?.type === 'template' ? selection.templateId : null + const isAiSelected = selection?.type === 'ai' function resetState() { setStep('describe') @@ -115,7 +139,7 @@ export default function DescribeTransactionDialog({ setIsBooking(false) setIsBatchApplying(false) setDescribeResult(null) - setSelectedTemplateId(null) + setSelection(null) } function handleOpenChange(isOpen: boolean) { @@ -147,7 +171,7 @@ export default function DescribeTransactionDialog({ } setDescribeResult(result.data) - setSelectedTemplateId(null) + setSelection(null) setStep('pick') } catch { toast({ @@ -160,18 +184,44 @@ export default function DescribeTransactionDialog({ } async function handleBook() { - if (!transaction || !selectedTemplateId || !describeResult) return + if (!transaction || !describeResult || !selection) return setIsBooking(true) try { + // Build categorize request based on selection type + let body: Record + + if (selection.type === 'template') { + body = { + is_business: true, + template_id: selection.templateId, + user_description: describeResult.user_description, + } + } else { + // AI suggestion selected + const ai = describeResult.ai_suggestion! + if (ai.template_id) { + // AI matched a template — use template-based booking + body = { + is_business: true, + template_id: ai.template_id, + user_description: describeResult.user_description, + } + } else { + // AI category-based booking — category maps to the correct account + body = { + is_business: true, + category: ai.category, + vat_treatment: ai.vat_treatment || undefined, + user_description: describeResult.user_description, + } + } + } + const response = await fetch(`/api/transactions/${transaction.id}/categorize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - is_business: true, - template_id: selectedTemplateId, - user_description: describeResult.user_description, - }), + body: JSON.stringify(body), }) const result = await response.json() if (!response.ok) { @@ -204,7 +254,17 @@ export default function DescribeTransactionDialog({ } async function handleBatchApply() { - if (!describeResult || !selectedTemplateId) return + if (!describeResult) return + + // For batch apply, we need a template_id + let templateId: string | null = null + if (selection?.type === 'template') { + templateId = selection.templateId + } else if (selection?.type === 'ai' && describeResult.ai_suggestion?.template_id) { + templateId = describeResult.ai_suggestion.template_id + } + + if (!templateId) return setIsBatchApplying(true) try { @@ -213,7 +273,7 @@ export default function DescribeTransactionDialog({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ merchant_name: describeResult.merchant_name, - template_id: selectedTemplateId, + template_id: templateId, is_business: true, user_description: describeResult.user_description, }), @@ -263,6 +323,13 @@ export default function DescribeTransactionDialog({ if (!transaction) return null const isIncome = transaction.amount > 0 + const aiSuggestion = describeResult?.ai_suggestion + // Check if AI agrees with top template + const topTemplate = describeResult?.templates[0] + const aiAgreesWithTop = aiSuggestion && topTemplate && aiSuggestion.debit_account === topTemplate.debit_account + + // Determine if batch apply is available (requires a template_id) + const canBatchApply = selection?.type === 'template' || (selection?.type === 'ai' && !!describeResult?.ai_suggestion?.template_id) return ( @@ -361,7 +428,72 @@ export default function DescribeTransactionDialog({ )}
- {describeResult.templates.length === 0 ? ( + {/* AI Suggestion Card */} + {aiSuggestion && ( + setSelection({ type: 'ai' })} + > + +
+
+
+ + + AI-forslag + +
+

+ {aiSuggestion.reasoning} +

+
+ + D: {formatAccountWithName(aiSuggestion.debit_account)} + + + K: {formatAccountWithName(aiSuggestion.credit_account)} + + {aiSuggestion.vat_treatment && aiSuggestion.vat_treatment !== 'exempt' && ( + + Moms {Math.round(getVatRateFromTreatment(aiSuggestion.vat_treatment) * 100)}% + + )} + {aiSuggestion.vat_treatment === 'exempt' && ( + + Momsfritt + + )} +
+ {aiSuggestion.warnings.length > 0 && ( +
+ {aiSuggestion.warnings.map((warning, i) => ( + + {warning} + + ))} +
+ )} +
+
+ = 0.7 ? 'default' : 'outline'} + className="text-[10px] px-1.5 py-0" + > + {Math.round(aiSuggestion.confidence * 100)}% + + {isAiSelected && ( + + )} +
+
+
+
+ )} + + {/* Template cards */} + {describeResult.templates.length === 0 && !aiSuggestion ? (

Inga matchande mallar hittades. Forsok med en annan beskrivning.

@@ -374,12 +506,19 @@ export default function DescribeTransactionDialog({ ? 'border-primary bg-primary/5' : '' }`} - onClick={() => setSelectedTemplateId(template.template_id)} + onClick={() => setSelection({ type: 'template', templateId: template.template_id })} >
-

{template.name_sv}

+
+

{template.name_sv}

+ {aiAgreesWithTop && template.template_id === topTemplate.template_id && ( + + AI bekraftar + + )} +
{template.description_sv && (

{template.description_sv} @@ -446,19 +585,33 @@ export default function DescribeTransactionDialog({ )}

- {/* Journal entry preview for selected template */} - {selectedTemplateId && (() => { - const tmpl = describeResult.templates.find(t => t.template_id === selectedTemplateId) - if (!tmpl) return null - return ( - - ) + {/* Journal entry preview for selected template or AI suggestion */} + {selection && (() => { + if (selection.type === 'ai' && aiSuggestion) { + return ( + + ) + } + if (selection.type === 'template') { + const tmpl = describeResult.templates.find(t => t.template_id === selection.templateId) + if (!tmpl) return null + return ( + + ) + } + return null })()}
@@ -467,7 +620,7 @@ export default function DescribeTransactionDialog({ className="flex-shrink-0" onClick={() => { setStep('describe') - setSelectedTemplateId(null) + setSelection(null) }} > @@ -475,7 +628,7 @@ export default function DescribeTransactionDialog({
-

- Det finns ytterligare{' '} - - {describeResult.batch_candidate_count} - {' '} - obokforda transaktioner fran{' '} - - {describeResult.merchant_name} - - . Anvand samma mall? -

+ {canBatchApply && ( +

+ Det finns ytterligare{' '} + + {describeResult.batch_candidate_count} + {' '} + obokforda transaktioner fran{' '} + + {describeResult.merchant_name} + + . Anvand samma mall? +

+ )}
- + {canBatchApply && ( + + )}
)} diff --git a/dev_docs/TWO_PHASES.md b/dev_docs/TWO_PHASES.md index 58afa086..b84826f5 100644 --- a/dev_docs/TWO_PHASES.md +++ b/dev_docs/TWO_PHASES.md @@ -91,8 +91,6 @@ ├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ calendar │ extensions/general/calendar/ │ (none) │ ├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ user-description-match │ extensions/general/user-description-match/ │ (none) │ - ├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ eu-sales-list │ extensions/export/eu-sales-list/ │ (none) │ ├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ vat-monitor │ extensions/export/vat-monitor/ │ (none) │ @@ -457,8 +455,6 @@ ├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤ │ calendar │ (none) │ │ ├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤ - │ user-description-match │ (none) │ │ - ├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤ │ All export extensions │ (none) │ │ ├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤ │ All sector extensions │ (none) │ │ diff --git a/extensions.config.json b/extensions.config.json index def30112..e67339cf 100644 --- a/extensions.config.json +++ b/extensions.config.json @@ -1 +1 @@ -{"$schema":"./extensions.schema.json","extensions":["receipt-ocr","ai-categorization","ai-chat","push-notifications","invoice-inbox","calendar","enable-banking","email","user-description-match"]} +{"$schema":"./extensions.schema.json","extensions":["receipt-ocr","ai-categorization","ai-chat","push-notifications","invoice-inbox","calendar","enable-banking","email"]} diff --git a/extensions.md b/extensions.md index 1161c2a1..0c9421b0 100644 --- a/extensions.md +++ b/extensions.md @@ -64,7 +64,6 @@ extensions/ invoice-inbox/ calendar/ email/ - user-description-match/ restaurant/ ← Restaurant sector extensions food-cost/ earnings-per-liter/ @@ -334,9 +333,6 @@ extensions/ ← Extension source code (opt-in via conf calendar/ manifest.json index.ts - user-description-match/ - manifest.json - index.ts restaurant/ ← Restaurant sector food-cost/ manifest.json diff --git a/extensions.schema.json b/extensions.schema.json index d7c015b4..cddeed4f 100644 --- a/extensions.schema.json +++ b/extensions.schema.json @@ -23,8 +23,7 @@ "invoice-inbox", "calendar", "enable-banking", - "email", - "user-description-match" + "email" ] }, "description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory." diff --git a/extensions/general/ai-categorization/index.ts b/extensions/general/ai-categorization/index.ts index bba03149..a3c737c0 100644 --- a/extensions/general/ai-categorization/index.ts +++ b/extensions/general/ai-categorization/index.ts @@ -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) diff --git a/extensions/general/ai-categorization/lib/__tests__/description-analyzer.test.ts b/extensions/general/ai-categorization/lib/__tests__/description-analyzer.test.ts new file mode 100644 index 00000000..d49f3383 --- /dev/null +++ b/extensions/general/ai-categorization/lib/__tests__/description-analyzer.test.ts @@ -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) { + 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() + }) +}) diff --git a/extensions/general/ai-categorization/lib/description-analyzer.ts b/extensions/general/ai-categorization/lib/description-analyzer.ts new file mode 100644 index 00000000..2df7c254 --- /dev/null +++ b/extensions/general/ai-categorization/lib/description-analyzer.ts @@ -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([ + '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([ + '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 { + 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, 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, + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/extensions/general/user-description-match/index.ts b/extensions/general/user-description-match/index.ts deleted file mode 100644 index dd6ef94a..00000000 --- a/extensions/general/user-description-match/index.ts +++ /dev/null @@ -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 { - 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) } -} - -export async function saveSettings( - userId: string, - partial: Partial -): Promise { - 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 { - const { transaction, userId } = payload - const log = ctx?.log ?? console - - if (!transaction.merchant_name) return - - const settings = ctx - ? { ...(DEFAULT_SETTINGS), ...(await ctx.settings.get>() || {}) } - : 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) - }, -} diff --git a/extensions/general/user-description-match/manifest.json b/extensions/general/user-description-match/manifest.json deleted file mode 100644 index 13e85b0b..00000000 --- a/extensions/general/user-description-match/manifest.json +++ /dev/null @@ -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." - } -} diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts index 0ffcbe30..2da84e51 100644 --- a/lib/extensions/__tests__/sectors.test.ts +++ b/lib/extensions/__tests__/sectors.test.ts @@ -48,8 +48,8 @@ describe('sectors registry', () => { expect(SECTORS.length).toBe(1) }) - it('should have 9 total extensions', () => { - expect(getAllExtensions().length).toBe(9) + it('should have 8 total extensions', () => { + expect(getAllExtensions().length).toBe(8) }) it('should have unique slugs within each sector', () => { @@ -93,7 +93,7 @@ describe('sectors registry', () => { it('getExtensionsBySector returns extensions for a sector', () => { const extensions = getExtensionsBySector('general') - expect(extensions.length).toBe(9) + expect(extensions.length).toBe(8) }) it('all extensions have required fields', () => { diff --git a/lib/extensions/_generated/enabled-extensions.ts b/lib/extensions/_generated/enabled-extensions.ts index c7e3fd58..3a821899 100644 --- a/lib/extensions/_generated/enabled-extensions.ts +++ b/lib/extensions/_generated/enabled-extensions.ts @@ -9,5 +9,4 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet = new Set([ 'calendar', 'enable-banking', 'email', - 'user-description-match', ]) diff --git a/lib/extensions/_generated/extension-list.ts b/lib/extensions/_generated/extension-list.ts index 8d689e11..9c1e4218 100644 --- a/lib/extensions/_generated/extension-list.ts +++ b/lib/extensions/_generated/extension-list.ts @@ -8,7 +8,6 @@ import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox' import { calendarExtension } from '@/extensions/general/calendar' import { enableBankingExtension } from '@/extensions/general/enable-banking' import { emailExtension } from '@/extensions/general/email' -import { userDescriptionMatchExtension } from '@/extensions/general/user-description-match' export const FIRST_PARTY_EXTENSIONS: Extension[] = [ receiptOcrExtension, @@ -19,5 +18,4 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [ calendarExtension, enableBankingExtension, emailExtension, - userDescriptionMatchExtension, ] diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index baedd0fd..27bd686f 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -136,19 +136,5 @@ export const EXTENSION_DEFINITIONS: Record = { "company_settings" ] }, - { - "slug": "user-description-match", - "name": "Beskrivningsmatchning", - "sector": "general", - "category": "operations", - "icon": "TextSearch", - "dataPattern": "core", - "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.", - "readsCoreTables": [ - "transactions", - "mapping_rules" - ] - }, ], } diff --git a/lib/extensions/_generated/workspace-map.tsx b/lib/extensions/_generated/workspace-map.tsx index ae925c8e..6de7dc20 100644 --- a/lib/extensions/_generated/workspace-map.tsx +++ b/lib/extensions/_generated/workspace-map.tsx @@ -11,5 +11,4 @@ export const WORKSPACES: Record> 'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/DocumentInboxWorkspace')), 'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')), 'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')), - 'general/user-description-match': dynamic(() => import('@/components/extensions/general/UserDescriptionMatchWorkspace')), }