Add journal entry preview, human-readable account names, auto-apply VAT, fallback template suggestions, example prompts, invoice match comparison, and batch result feedback. Also includes user-description-match extension, describe/batch-describe API routes, improved AI categorization with multi- suggestion support, and template embedding search. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
274 lines
8.5 KiB
TypeScript
274 lines
8.5 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'
|
|
match_reason?: string
|
|
}
|
|
|
|
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_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<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)
|
|
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<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',
|
|
'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 sorting — higher-quality sources rank first.
|
|
* AI and mapping rules always beat history-based guesses.
|
|
*/
|
|
const SOURCE_PRIORITY: Record<SuggestedCategory['source'], number> = {
|
|
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 seen = new Set<string>(existing.map((s) => s.category))
|
|
const merged = [...existing]
|
|
|
|
for (const ai of aiSuggestions) {
|
|
if (seen.has(ai.category)) continue
|
|
|
|
// 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
|
|
}
|
|
|
|
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',
|
|
})
|
|
}
|
|
|
|
// Sort by source priority first, then by confidence within the same tier
|
|
return merged
|
|
.sort((a, b) => {
|
|
const priorityDiff = SOURCE_PRIORITY[b.source] - SOURCE_PRIORITY[a.source]
|
|
if (priorityDiff !== 0) return priorityDiff
|
|
return 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,
|
|
}))
|
|
}
|