Add auto-detecting CSV parsers for Länsförsäkringar, ICA Banken, Skandia, and Lunar. Refine SEB detection to avoid false matches. Update bank file upload UI with new bank options and export instructions. Include booking templates, improved AI categorization, and transaction review enhancements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
221 lines
6.7 KiB
TypeScript
221 lines
6.7 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 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.
|
|
* Uses multi-signal matching (MCC, keywords, description patterns).
|
|
*/
|
|
export function getSuggestedTemplates(
|
|
transaction: Transaction,
|
|
entityType?: EntityType
|
|
): SuggestedTemplate[] {
|
|
const 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,
|
|
}))
|
|
}
|