Files
accounted/lib/transactions/category-suggestions.ts
T

234 lines
7.0 KiB
TypeScript

import { suggestCategory } from '@/lib/tax/expense-warnings'
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
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'
}
const CATEGORY_LABELS: Record<string, string> = {
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_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<string, number>
): SuggestedCategory[] {
const suggestions: SuggestedCategory[] = []
const seen = new Set<string>()
// 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)
suggestions.push({
category: category as TransactionCategory,
label: CATEGORY_LABELS[category] || category,
account: rule.debit_account,
confidence: rule.confidence_score || 0.8,
source: 'mapping_rule',
})
}
}
}
// 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<string, string> = {
'3001': 'income_services',
'3900': 'income_other',
}
return incomeMap[account] || 'income_other'
}
// Expense
const expenseMap: Record<string, string> = {
'5410': 'expense_equipment',
'5420': 'expense_software',
'5800': 'expense_travel',
'5010': 'expense_office',
'5910': 'expense_marketing',
'6530': 'expense_professional_services',
'6570': 'expense_bank_fees',
'6991': 'expense_other',
'7960': 'expense_currency_exchange',
}
return expenseMap[account] || null
}
/**
* Merge AI-generated suggestions into existing suggestion list.
* Deduplicates by category, preserving the higher-confidence entry.
*/
export function mergeAiSuggestions(
existing: SuggestedCategory[],
aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[]
): SuggestedCategory[] {
const seen = new Set<string>(existing.map((s) => s.category))
const merged = [...existing]
for (const ai of aiSuggestions) {
if (seen.has(ai.category)) continue
seen.add(ai.category)
merged.push({
category: ai.category as TransactionCategory,
label: CATEGORY_LABELS[ai.category] || ai.category,
account: ai.basAccount || null,
confidence: ai.confidence,
source: 'ai',
})
}
return merged
.sort((a, b) => b.confidence - a.confidence)
.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 suggested booking templates for a transaction.
* Tries embedding-based semantic search first, falls back to keyword matching.
*/
export async function getSuggestedTemplates(
transaction: Transaction,
entityType?: EntityType
): Promise<SuggestedTemplate[]> {
let matches: TemplateMatch[]
try {
matches = await findSimilarTemplates(transaction, entityType)
} catch {
matches = []
}
// Fall back to keyword matching if embedding search returns nothing
if (matches.length === 0) {
matches = findMatchingTemplates(transaction, entityType)
}
return matches.map((m: TemplateMatch) => ({
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,
}))
}