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
+1 -2
View File
@@ -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
+59 -15
View File
@@ -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<TemplateMatch[]> {
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<DescriptionAnalysisResult | null> {
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,
@@ -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 (
<EmptyExtensionState
title="Beskrivningsmatchning"
description="Beskriv transaktioner med egna ord vid kategorisering. Systemet lär sig automatiskt och applicerar på framtida transaktioner från samma leverantör."
icon={<TextSearch className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
)
}
@@ -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<DescribeResult | null>(null)
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
const [selection, setSelection] = useState<Selection | null>(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<string, unknown>
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 (
<Dialog open={open} onOpenChange={(isBooking || isBatchApplying) ? undefined : handleOpenChange}>
@@ -361,7 +428,72 @@ export default function DescribeTransactionDialog({
)}
<div className="overflow-y-auto max-h-[40vh] space-y-2 pr-1">
{describeResult.templates.length === 0 ? (
{/* AI Suggestion Card */}
{aiSuggestion && (
<Card
className={`cursor-pointer transition-colors hover:border-primary/50 ${
isAiSelected ? 'border-primary bg-primary/5' : ''
}`}
onClick={() => setSelection({ type: 'ai' })}
>
<CardContent className="py-3 px-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 mb-1">
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
AI-forslag
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{aiSuggestion.reasoning}
</p>
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
D: {formatAccountWithName(aiSuggestion.debit_account)}
</Badge>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
K: {formatAccountWithName(aiSuggestion.credit_account)}
</Badge>
{aiSuggestion.vat_treatment && aiSuggestion.vat_treatment !== 'exempt' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Moms {Math.round(getVatRateFromTreatment(aiSuggestion.vat_treatment) * 100)}%
</Badge>
)}
{aiSuggestion.vat_treatment === 'exempt' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Momsfritt
</Badge>
)}
</div>
{aiSuggestion.warnings.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{aiSuggestion.warnings.map((warning, i) => (
<Badge key={i} variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
{warning}
</Badge>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge
variant={aiSuggestion.confidence >= 0.7 ? 'default' : 'outline'}
className="text-[10px] px-1.5 py-0"
>
{Math.round(aiSuggestion.confidence * 100)}%
</Badge>
{isAiSelected && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Template cards */}
{describeResult.templates.length === 0 && !aiSuggestion ? (
<p className="text-sm text-muted-foreground text-center py-4">
Inga matchande mallar hittades. Forsok med en annan beskrivning.
</p>
@@ -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 })}
>
<CardContent className="py-3 px-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm">{template.name_sv}</p>
<div className="flex items-center gap-1.5">
<p className="font-medium text-sm">{template.name_sv}</p>
{aiAgreesWithTop && template.template_id === topTemplate.template_id && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
AI bekraftar
</Badge>
)}
</div>
{template.description_sv && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{template.description_sv}
@@ -446,19 +585,33 @@ export default function DescribeTransactionDialog({
)}
</div>
{/* Journal entry preview for selected template */}
{selectedTemplateId && (() => {
const tmpl = describeResult.templates.find(t => t.template_id === selectedTemplateId)
if (!tmpl) return null
return (
<JournalEntryPreview
amount={transaction.amount}
currency={transaction.currency}
templateDebitAccount={tmpl.debit_account}
templateCreditAccount={tmpl.credit_account}
templateVatRate={tmpl.vat_rate}
/>
)
{/* Journal entry preview for selected template or AI suggestion */}
{selection && (() => {
if (selection.type === 'ai' && aiSuggestion) {
return (
<JournalEntryPreview
amount={transaction.amount}
currency={transaction.currency}
templateDebitAccount={aiSuggestion.debit_account}
templateCreditAccount={aiSuggestion.credit_account}
templateVatRate={getVatRateFromTreatment(aiSuggestion.vat_treatment)}
/>
)
}
if (selection.type === 'template') {
const tmpl = describeResult.templates.find(t => t.template_id === selection.templateId)
if (!tmpl) return null
return (
<JournalEntryPreview
amount={transaction.amount}
currency={transaction.currency}
templateDebitAccount={tmpl.debit_account}
templateCreditAccount={tmpl.credit_account}
templateVatRate={tmpl.vat_rate}
/>
)
}
return null
})()}
<div className="flex gap-2 pt-2">
@@ -467,7 +620,7 @@ export default function DescribeTransactionDialog({
className="flex-shrink-0"
onClick={() => {
setStep('describe')
setSelectedTemplateId(null)
setSelection(null)
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
@@ -475,7 +628,7 @@ export default function DescribeTransactionDialog({
</Button>
<Button
className="flex-1"
disabled={!selectedTemplateId || isBooking}
disabled={!selection || isBooking}
onClick={handleBook}
>
{isBooking ? (
@@ -497,17 +650,19 @@ export default function DescribeTransactionDialog({
<p className="text-sm font-medium">Transaktionen ar bokford!</p>
</div>
<p className="text-sm text-muted-foreground">
Det finns ytterligare{' '}
<span className="font-medium text-foreground">
{describeResult.batch_candidate_count}
</span>{' '}
obokforda transaktioner fran{' '}
<span className="font-medium text-foreground">
{describeResult.merchant_name}
</span>
. Anvand samma mall?
</p>
{canBatchApply && (
<p className="text-sm text-muted-foreground">
Det finns ytterligare{' '}
<span className="font-medium text-foreground">
{describeResult.batch_candidate_count}
</span>{' '}
obokforda transaktioner fran{' '}
<span className="font-medium text-foreground">
{describeResult.merchant_name}
</span>
. Anvand samma mall?
</p>
)}
<div className="flex gap-2">
<Button
@@ -516,20 +671,22 @@ export default function DescribeTransactionDialog({
onClick={handleSkipBatch}
disabled={isBatchApplying}
>
Nej, bara den har
</Button>
<Button
className="flex-1"
onClick={handleBatchApply}
disabled={isBatchApplying}
>
{isBatchApplying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isBatchApplying
? 'Bokfor...'
: `Ja, bokfor alla ${describeResult.batch_candidate_count} st`}
{canBatchApply ? 'Nej, bara den har' : 'Stang'}
</Button>
{canBatchApply && (
<Button
className="flex-1"
onClick={handleBatchApply}
disabled={isBatchApplying}
>
{isBatchApplying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isBatchApplying
? 'Bokfor...'
: `Ja, bokfor alla ${describeResult.batch_candidate_count} st`}
</Button>
)}
</div>
</div>
)}
-4
View File
@@ -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) │ │
+1 -1
View File
@@ -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"]}
-4
View File
@@ -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
+1 -2
View File
@@ -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."
@@ -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."
}
}
+3 -3
View File
@@ -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', () => {
@@ -9,5 +9,4 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'calendar',
'enable-banking',
'email',
'user-description-match',
])
@@ -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,
]
@@ -136,19 +136,5 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"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"
]
},
],
}
@@ -11,5 +11,4 @@ export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>>
'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')),
}