import { suggestCategory } from '@/lib/tax/expense-warnings' import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping' import { findMatchingTemplates, getTemplateById, type TemplateMatch } from '@/lib/bookkeeping/booking-templates' import { extensionRegistry } from '@/lib/extensions/registry' import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types' export interface SuggestedCategory { category: TransactionCategory label: string account: string | null confidence: number source: 'mapping_rule' | 'pattern' | 'history' | 'ai' match_reason?: string } const CATEGORY_LABELS: Record = { income_services: 'Tjänster', income_products: 'Produkter', income_other: 'Övriga intäkter', expense_equipment: 'Utrustning', expense_software: 'Programvara', expense_travel: 'Resor', expense_office: 'Kontor', expense_marketing: 'Marknadsföring', expense_professional_services: 'Konsulter', expense_education: 'Utbildning', expense_representation: 'Representation', expense_consumables: 'Material', expense_vehicle: 'Bil & drivmedel', expense_telecom: 'Telefon & internet', expense_bank_fees: 'Bankavgift', expense_card_fees: 'Kortavgift', expense_currency_exchange: 'Valutaväxling', expense_other: 'Övrigt', } /** * Get suggested categories for a transaction * Combines mapping rules, pattern matching, and user history */ export function getSuggestedCategories( transaction: Transaction, mappingRules: MappingRule[], categoryHistory: Record ): SuggestedCategory[] { const suggestions: SuggestedCategory[] = [] const seen = new Set() // 1. Check mapping rules (highest confidence) for (const rule of mappingRules) { if (!rule.is_active) continue let matches = false if (rule.merchant_pattern && transaction.merchant_name) { const pattern = new RegExp(rule.merchant_pattern, 'i') if (pattern.test(transaction.merchant_name)) { matches = true } } if (rule.description_pattern) { const pattern = new RegExp(rule.description_pattern, 'i') if (pattern.test(transaction.description)) { matches = true } } if (rule.mcc_codes && transaction.mcc_code) { if (rule.mcc_codes.includes(transaction.mcc_code)) { matches = true } } if (matches && rule.debit_account && !rule.default_private) { // Reverse-lookup: find category from debit account const category = accountToCategory(rule.debit_account, transaction.amount) if (category && !seen.has(category)) { seen.add(category) const suggestion: SuggestedCategory = { category: category as TransactionCategory, label: CATEGORY_LABELS[category] || category, account: rule.debit_account, confidence: rule.confidence_score || 0.8, source: 'mapping_rule', } if (rule.source === 'user_description' && rule.user_description) { suggestion.match_reason = `Matchad på din beskrivning: ${rule.user_description}` } suggestions.push(suggestion) } } } // 2. Pattern matching from expense-warnings const patternMatch = suggestCategory(transaction.description) if (patternMatch && !seen.has(patternMatch)) { seen.add(patternMatch) suggestions.push({ category: patternMatch as TransactionCategory, label: CATEGORY_LABELS[patternMatch] || patternMatch, account: getExpenseAccountForCategory(patternMatch as TransactionCategory), confidence: 0.6, source: 'pattern', }) } // 3. User history (most commonly used categories) const historyEntries = Object.entries(categoryHistory) .sort(([, a], [, b]) => b - a) .filter(([cat]) => !seen.has(cat)) for (const [cat, count] of historyEntries) { if (suggestions.length >= 4) break // Only suggest relevant direction (expense for negative, income for positive) if (transaction.amount < 0 && !cat.startsWith('expense_')) continue if (transaction.amount > 0 && !cat.startsWith('income_')) continue seen.add(cat) suggestions.push({ category: cat as TransactionCategory, label: CATEGORY_LABELS[cat] || cat, account: getExpenseAccountForCategory(cat as TransactionCategory), confidence: Math.min(0.5, count / 20), source: 'history', }) } // Sort by confidence, limit to top 4 return suggestions .sort((a, b) => b.confidence - a.confidence) .slice(0, 4) } /** * Reverse-lookup: find category from BAS account number */ function accountToCategory(account: string, amount: number): string | null { if (amount > 0) { // Income const incomeMap: Record = { '3001': 'income_services', '3900': 'income_other', } return incomeMap[account] || 'income_other' } // Expense const expenseMap: Record = { '5410': 'expense_equipment', '5420': 'expense_software', '5460': 'expense_consumables', '5611': 'expense_vehicle', '5800': 'expense_travel', '5010': 'expense_office', '5910': 'expense_marketing', '6071': 'expense_representation', '6072': 'expense_representation', '6200': 'expense_telecom', '6530': 'expense_professional_services', '6570': 'expense_bank_fees', '6991': 'expense_other', '7960': 'expense_currency_exchange', } return expenseMap[account] || null } /** * Source priority for tiebreaking — used when two suggestions * have the same confidence score. */ const SOURCE_PRIORITY: Record = { mapping_rule: 3, ai: 2, pattern: 1, history: 0, } /** * Merge AI-generated suggestions into existing suggestion list. * AI suggestions take priority over history-based ones. * Deduplicates by category, preserving the higher-confidence entry. * When transactionAmount is provided, filters out wrong-direction suggestions. */ export function mergeAiSuggestions( existing: SuggestedCategory[], aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[], transactionAmount?: number ): SuggestedCategory[] { const merged = [...existing] for (const ai of aiSuggestions) { // Skip suggestions that don't match transaction direction if (transactionAmount !== undefined) { if (transactionAmount > 0 && ai.category.startsWith('expense_')) continue if (transactionAmount < 0 && ai.category.startsWith('income_')) continue } const existingIdx = merged.findIndex((s) => s.category === ai.category) if (existingIdx !== -1) { // Upgrade existing entry if the AI has higher confidence if (ai.confidence > merged[existingIdx].confidence) { merged[existingIdx] = { ...merged[existingIdx], account: ai.basAccount || merged[existingIdx].account, confidence: ai.confidence, source: 'ai', } } continue } merged.push({ category: ai.category as TransactionCategory, label: CATEGORY_LABELS[ai.category] || ai.category, account: ai.basAccount || null, confidence: ai.confidence, source: 'ai', }) } // Sort by confidence first, then by source priority as tiebreaker return merged .sort((a, b) => { const confidenceDiff = b.confidence - a.confidence if (confidenceDiff !== 0) return confidenceDiff return SOURCE_PRIORITY[b.source] - SOURCE_PRIORITY[a.source] }) .slice(0, 5) } // ============================================================ // Template Suggestions // ============================================================ export interface SuggestedTemplate { template_id: string name_sv: string name_en: string group: string debit_account: string credit_account: string confidence: number description_sv: string risk_level: string requires_review: boolean } /** * Get recently used templates from mapping rules. * Extracts unique template_id values and returns them as suggestions. */ export function getRecentlyUsedTemplates( mappingRules: MappingRule[], entityType?: EntityType, direction?: 'expense' | 'income' | 'transfer' ): SuggestedTemplate[] { const seen = new Set() const results: SuggestedTemplate[] = [] // Sort by most recent (highest priority first) const sorted = [...mappingRules] .filter((r) => r.is_active && r.template_id) .sort((a, b) => (b.confidence_score || 0) - (a.confidence_score || 0)) for (const rule of sorted) { if (!rule.template_id || seen.has(rule.template_id)) continue seen.add(rule.template_id) const template = getTemplateById(rule.template_id) if (!template) continue // Filter by entity applicability if (entityType && template.entity_applicability !== 'all' && template.entity_applicability !== entityType) continue // Filter by direction if (direction && template.direction !== direction && template.direction !== 'transfer') continue results.push({ template_id: template.id, name_sv: template.name_sv, name_en: template.name_en, group: template.group, debit_account: template.debit_account, credit_account: template.credit_account, confidence: 0.85, description_sv: template.description_sv, risk_level: template.risk_level, requires_review: template.requires_review, }) if (results.length >= 5) break } return results } /** * Get suggested booking templates for a transaction. * Keyword matching as primary, AI embedding search as optional enhancer. */ export async function getSuggestedTemplates( transaction: Transaction, entityType?: EntityType, mappingRules?: MappingRule[] ): Promise { const seen = new Set() const results: SuggestedTemplate[] = [] // 1. Boost recently-used templates from mapping rules if (mappingRules) { const direction = transaction.amount < 0 ? 'expense' : 'income' const recent = getRecentlyUsedTemplates(mappingRules, entityType, direction) for (const r of recent) { if (!seen.has(r.template_id)) { seen.add(r.template_id) results.push(r) } } } // 2. Keyword + MCC matching (always available, no API keys needed) const keywordMatches = findMatchingTemplates(transaction, entityType) for (const m of keywordMatches) { if (!seen.has(m.template.id)) { seen.add(m.template.id) results.push({ template_id: m.template.id, name_sv: m.template.name_sv, name_en: m.template.name_en, group: m.template.group, debit_account: m.template.debit_account, credit_account: m.template.credit_account, confidence: m.confidence, description_sv: m.template.description_sv, risk_level: m.template.risk_level, requires_review: m.template.requires_review, }) } } // 3. If AI extension loaded, merge in embedding-based matches (higher confidence) try { const aiExt = extensionRegistry.get('ai-categorization') if (aiExt?.services?.findSimilarTemplates) { const aiMatches: TemplateMatch[] = await aiExt.services.findSimilarTemplates(transaction, entityType) for (const m of aiMatches) { const existing = results.find((r) => r.template_id === m.template.id) if (existing) { // AI match upgrades confidence if higher if (m.confidence > existing.confidence) { existing.confidence = m.confidence } } else { results.push({ template_id: m.template.id, name_sv: m.template.name_sv, name_en: m.template.name_en, group: m.template.group, debit_account: m.template.debit_account, credit_account: m.template.credit_account, confidence: m.confidence, description_sv: m.template.description_sv, risk_level: m.template.risk_level, requires_review: m.template.requires_review, }) } } } } catch { // AI enhancement is non-blocking } return results .sort((a, b) => b.confidence - a.confidence) .slice(0, 10) }