Files
accounted/extensions/general/ai-categorization/categorizer.ts
T

362 lines
13 KiB
TypeScript

/**
* 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<CategorizationSuggestion[]>
}
// ============================================================
// BAS Account + Category Mapping (used in prompt)
// ============================================================
function getCategoryAccountMap(entityType: EntityType): Record<string, { account: string; label: string }> {
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<CategorizationSuggestion[]> {
// 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<string, unknown> =>
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}