/** * AI Categorization Engine * * SERVER-ONLY: Uses the Anthropic SDK and must only be imported * in server components or API routes. * * Provider-abstracted AI categorization for Swedish BAS account mapping. * Uses Claude Haiku with structured tool outputs for reliable JSON. * Accepts pre-filtered candidate templates from embedding search (Tier 2) * instead of dumping all ~100 templates into the prompt. */ import 'server-only' import Anthropic from '@anthropic-ai/sdk' import { BOOKING_TEMPLATES, type BookingTemplate } from '@/lib/bookkeeping/booking-templates' import type { TransactionCategory, EntityType } from '@/types' // ============================================================ // Types // ============================================================ export interface TransactionForCategorization { id: string description: string amount: number date: string merchant_name: string | null mcc_code: number | null currency: string } export interface AccountUsageEntry { account_number: string count: number } export interface MerchantHistoryEntry { merchant_name: string category: string template_id: string | null count: number } export interface CategorizationContext { entityType: EntityType recentHistory: { description: string; category: string }[] } export interface EnrichedCategorizationContext extends CategorizationContext { candidateTemplates: BookingTemplate[] userAccountUsage: AccountUsageEntry[] merchantHistory: MerchantHistoryEntry[] } export interface CategorizationSuggestion { transactionId: string category: TransactionCategory basAccount: string taxCode: string | null confidence: number reasoning: string isPrivate: boolean templateId?: string } export interface CategorizationProvider { categorize( transactions: TransactionForCategorization[], context: CategorizationContext | EnrichedCategorizationContext ): Promise } // ============================================================ // BAS Account + Category Mapping (used in prompt) // ============================================================ function getCategoryAccountMap(entityType: EntityType): Record { const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991' return { income_services: { account: '3001', label: 'Tjänsteförsäljning' }, income_products: { account: '3001', label: 'Varuförsäljning' }, income_other: { account: '3900', label: 'Övriga intäkter' }, expense_equipment: { account: '5410', label: 'Förbrukningsinventarier' }, expense_software: { account: '5420', label: 'Programvara' }, expense_travel: { account: '5800', label: 'Resekostnader' }, expense_office: { account: '5010', label: 'Lokalhyra/kontorskostnad' }, expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' }, expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' }, expense_education: { account: educationAccount, label: 'Utbildning' }, expense_bank_fees: { account: '6570', label: 'Bankavgifter' }, expense_card_fees: { account: '6570', label: 'Kortavgifter' }, expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' }, expense_other: { account: '6991', label: 'Övriga kostnader' }, private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' }, } } /** * Build template reference from candidate templates (pre-filtered by embeddings) * or fall back to full template list if no candidates provided. */ function getTemplateReference( direction: 'expense' | 'income', candidateTemplates?: BookingTemplate[] ): string { const templates = candidateTemplates && candidateTemplates.length > 0 ? candidateTemplates : BOOKING_TEMPLATES return templates .filter((t) => t.direction === direction || t.direction === 'transfer') .map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`) .join('\n') } const NON_DEDUCTIBLE_RULES = ` ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt): - Kläder: Normalt inte avdragsgilla (RÅ 1988 ref. 35) - Gym/träning: Inte avdragsgilla som personlig kostnad (IL 9 kap 2§) - Kosmetika/hudvård: Normalt inte avdragsgillt - Frisör: Normalt privat kostnad - Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§) - Gåvor: Reklamgåvor max 300 kr/mottagare, representationsgåvor max 180 kr - Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill ` // ============================================================ // Classify Transaction Tool Schema // ============================================================ const CLASSIFY_TOOL: Anthropic.Tool = { name: 'classify_transactions', description: 'Classify a batch of bank transactions into Swedish BAS accounts and booking templates.', input_schema: { type: 'object' as const, properties: { suggestions: { type: 'array', items: { type: 'object', properties: { transactionId: { type: 'string', description: 'Transaction ID' }, templateId: { type: 'string', description: 'Booking template ID (from the provided templates list)' }, category: { type: 'string', description: 'Transaction category (e.g. expense_software, income_services, private)' }, basAccount: { type: 'string', description: 'BAS account number (4 digits)' }, taxCode: { type: ['string', 'null'], description: 'Tax code: MPI for deductible expenses with VAT, MP1 for income with VAT, null for VAT-exempt/private' }, confidence: { type: 'number', description: 'Confidence score 0.0-1.0' }, reasoning: { type: 'string', description: 'Short reasoning in Swedish' }, isPrivate: { type: 'boolean', description: 'Whether this is a private expense' }, }, required: ['transactionId', 'category', 'basAccount', 'confidence', 'reasoning', 'isPrivate'], }, }, }, required: ['suggestions'], }, } // ============================================================ // Anthropic Provider // ============================================================ const MAX_RETRIES = 3 const RETRY_DELAY_MS = 1000 const MAX_BATCH_SIZE = 20 export class AnthropicCategorizationProvider implements CategorizationProvider { private client: Anthropic private model: string constructor(model = 'claude-haiku-4-5-20251001') { this.client = new Anthropic() this.model = model } async categorize( transactions: TransactionForCategorization[], context: CategorizationContext | EnrichedCategorizationContext ): Promise { // Cap batch size const batch = transactions.slice(0, MAX_BATCH_SIZE) if (batch.length === 0) return [] const enriched = isEnrichedContext(context) ? context : null const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013' const categoryAccountMap = getCategoryAccountMap(context.entityType) // Build template references — use candidate templates if available const hasExpenses = batch.some((t) => t.amount < 0) const hasIncome = batch.some((t) => t.amount > 0) const candidates = enriched?.candidateTemplates const templateRef = [ hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense', candidates)}` : '', hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income', candidates)}` : '', ].filter(Boolean).join('\n\n') // Build account usage context const accountUsageContext = enriched?.userAccountUsage && enriched.userAccountUsage.length > 0 ? `\nAnvändarens mest använda konton:\n${enriched.userAccountUsage .slice(0, 15) .map((a) => `- ${a.account_number} (${a.count} bokningar)`) .join('\n')}` : '' // Build merchant history context const merchantHistoryContext = enriched?.merchantHistory && enriched.merchantHistory.length > 0 ? `\nTidigare kategorisering av dessa handlare:\n${enriched.merchantHistory .map((m) => `- "${m.merchant_name}" → ${m.category}${m.template_id ? ` (mall: ${m.template_id})` : ''} (${m.count}x)`) .join('\n')}` : '' const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen. Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto. BOKFÖRINGSMALLAR (id: namn → debitkonto/kreditkonto): ${templateRef} KATEGORIER (fallback om ingen mall matchar): ${Object.entries(categoryAccountMap) .map(([cat, info]) => `- ${cat}: ${info.account} (${info.label})`) .join('\n')} Företagsform: ${context.entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'} Privatkonto: ${privateAccount} MOMSHANTERING: - Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA - Övriga affärskostnader: Normalt 25% moms (ingående moms, MPI) - Intäkter: Normalt 25% moms (utgående moms, MP1) ${NON_DEDUCTIBLE_RULES} REGLER: 1. Negativa belopp = utgifter, positiva = intäkter 2. Markera transaktioner som troligen är privata med isPrivate: true 3. Ange confidence 0.0-1.0 baserat på hur säker du är 4. Ange kort reasoning på svenska 5. Om en transaktion liknar privat konsumtion (kläder, gym, etc.), sätt category: "private" 6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata 7. Ange templateId om en bokföringsmall matchar (föredra mallar framför generiska kategorier)` const historyContext = context.recentHistory.length > 0 ? `\nAnvändarens senaste kategoriseringar (lär dig mönster):\n${context.recentHistory .slice(0, 30) .map((h) => `- "${h.description}" → ${h.category}`) .join('\n')}` : '' const transactionList = batch .map( (t, i) => `${i + 1}. ID: ${t.id} Beskrivning: ${t.description} Belopp: ${t.amount} ${t.currency} Datum: ${t.date}${t.merchant_name ? `\n Handlare: ${t.merchant_name}` : ''}${t.mcc_code ? `\n MCC: ${t.mcc_code}` : ''}` ) .join('\n\n') const userPrompt = `Kategorisera följande transaktioner med classify_transactions-verktyget: ${historyContext}${accountUsageContext}${merchantHistoryContext} TRANSAKTIONER: ${transactionList}` let lastError: Error | null = null for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { const message = await this.client.messages.create({ model: this.model, max_tokens: 4096, system: [ { type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' }, }, ], tools: [CLASSIFY_TOOL], tool_choice: { type: 'tool', name: 'classify_transactions' }, messages: [ { role: 'user', content: userPrompt, }, ], }) // Extract tool_use block from response const toolUseBlock = message.content.find( (block) => block.type === 'tool_use' && block.name === 'classify_transactions' ) if (!toolUseBlock || toolUseBlock.type !== 'tool_use') { throw new Error('No tool_use block in AI response') } const input = toolUseBlock.input as { suggestions?: unknown[] } return this.validateSuggestions(input.suggestions || [], batch, context.entityType) } catch (error) { lastError = error instanceof Error ? error : new Error('Unknown error') if (attempt < MAX_RETRIES - 1) { await sleep(RETRY_DELAY_MS * (attempt + 1)) } } } throw new Error( `AI categorization failed after ${MAX_RETRIES} attempts: ${lastError?.message}` ) } private validateSuggestions( raw: unknown[], transactions: TransactionForCategorization[], entityType: EntityType ): CategorizationSuggestion[] { if (!Array.isArray(raw)) return [] const categoryAccountMap = getCategoryAccountMap(entityType) const validTransactionIds = new Set(transactions.map((t) => t.id)) const validCategories = new Set(Object.keys(categoryAccountMap).concat(['uncategorized'])) return raw .filter( (s): s is Record => s !== null && typeof s === 'object' && 'transactionId' in s ) .filter((s) => validTransactionIds.has(s.transactionId as string)) .map((s) => { const category = validCategories.has(s.category as string) ? (s.category as TransactionCategory) : 'expense_other' const accountInfo = categoryAccountMap[category] return { transactionId: s.transactionId as string, category, basAccount: accountInfo?.account || (s.basAccount as string) || '6991', taxCode: (s.taxCode as string) || null, confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)), reasoning: (s.reasoning as string) || '', isPrivate: category === 'private' || Boolean(s.isPrivate), templateId: (s.templateId as string) || undefined, } }) } } function isEnrichedContext( ctx: CategorizationContext | EnrichedCategorizationContext ): ctx is EnrichedCategorizationContext { return 'candidateTemplates' in ctx } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) }