362 lines
13 KiB
TypeScript
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))
|
|
}
|