Files
accounted/extensions/general/ai-categorization/lib/description-analyzer.ts
T
Jakob Wennberg 66a4027f1e feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup
- Update BAS account catalog with comprehensive SRU codes and K2 flags
- Add currency revaluation service with tests and API route
- Add expenses page and account deletion API
- Enhance booking templates with new patterns and improved tests
- Improve transaction categorization with template picker and description matching
- Polish dashboard, onboarding, import, and transaction UIs
- Refactor year-end service for multi-step closing
- Move SRU generator to ne-bilaga, remove standalone SRU export
- Remove unused dev docs, mock data, and extension hooks
- Add invoice delivery note sequences migration

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

278 lines
10 KiB
TypeScript

/**
* 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 är expert på svensk bokföring enligt BAS-kontoplanen. Analysera användarens beskrivning av en banktransaktion och returnera ett bokföringsförslag.
VANLIGA BAS-KONTON:
Utgifter: 5010 Lokalhyra | 5410 Förbrukningsinventarier | 5420 Programvara | 5460 Förbrukningsvaror | 5611 Bil/drivmedel | 5800 Resekostnader | 5910 Annonsering | 6071 Representation mat | 6200 Telefon/internet | 6530 Redovisning/konsult | 6570 Bankavgifter | 6991 Övriga kostnader | ${entityType === 'aktiebolag' ? '7610' : '6991'} Utbildning
Intäkter: 3001 Försäljning 25% | 3002 Försäljning 12% | 3003 Försäljning 6% | 3305 Export | 3308 EU-tjänster | 3900 Övriga intäkter
Moms: 2611 Utg moms 25% | 2621 Utg moms 12% | 2631 Utg moms 6% | 2641 Ing moms | 2645 Beräknad ing moms
Skulder: 2350 Skulder till kreditinstitut (banklån, Almi) | 2440 Leverantörsskulder (ENBART för leverantörsfakturor)
Övrigt: 1510 Kundfordringar | 1930 Företagskonto | 8410 Räntekostnader | ${privateAccount} Privat
MOMSREGLER:
- standard_25: Normala varor/tjänster (25%)
- reduced_12: Livsmedel, hotell, konstverk (12%)
- reduced_6: Böcker, tidningar, kollektivtrafik, kultur (6%)
- reverse_charge: Tjänsteköp från utlandet/EU
- export: Försäljning utanför Sverige
- exempt: Momsfritt (bank, försäkring, sjukvård, utbildning)
VARNINGSREGLER:
- Representation/måltider: Max 300 kr/person exkl moms för avdragsrätt (IL 16 kap 2§)
- Gåvor: Reklamgåvor max 300 kr, representationsgåvor max 180 kr
- Blandad användning (telefon/dator): Bara yrkesmässig del avdragsgill
- Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA (exempt)
Företagsform: ${entityLabel}
Privatkonto: ${privateAccount}
REGLER:
1. Negativt belopp = utgift: debitera kostnadskonto, kreditera 1930
2. Positivt belopp = intäkt: debitera 1930, kreditera intäktskonto
3. Ge ett klart reasoning på svenska som förklarar valet
4. Lägg till warnings för avdragsbegränsningar 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:
- Användarens beskrivning: "${input.description}"
- Banktext: "${input.transactionDescription}"
- Belopp: ${input.transactionAmount} ${input.currency}
- Datum: ${input.transactionDate}${input.merchantName ? `\n- Handlare: ${input.merchantName}` : ''}
Analysera och returnera bokföringsförslag 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 bokförd på standardkonto' : 'Intäkt bokförd på 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))
}