import type { SupabaseClient } from '@supabase/supabase-js' import { generateInputVatLine, generateReverseChargeLines, extractNetAmount, extractVatAmount, } from './vat-entries' import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates' import type { MappingRule, MappingResult, RiskLevel, Transaction, EntityType, VatJournalLine, } from '@/types' import { createLogger } from '@/lib/logger' const log = createLogger('mapping-engine') // Half of prisbasbelopp per year (used for capitalization threshold) const PRISBASBELOPP_HALVES: Record = { 2024: 28650, // PBB 57,300 2025: 29400, // PBB 58,800 2026: 29600, // PBB 59,200 } const LATEST_KNOWN_YEAR = 2026 function getCapitalizationThreshold(year: number): number { const threshold = PRISBASBELOPP_HALVES[year] if (threshold) return threshold log.warn(`No prisbasbelopp for ${year}, using ${LATEST_KNOWN_YEAR} value`) return PRISBASBELOPP_HALVES[LATEST_KNOWN_YEAR] } /** * Evaluate all mapping rules against a transaction and return the best match * * Evaluation order (by priority): * 1. User override rules (priority 1-49) * 2. MCC code rules (priority 50-69) * 3. Merchant name pattern rules (priority 70-89) * 4. Amount threshold rules (priority 90-99) * 5. Risk fallback (priority 100) → 2013 (private) */ export async function evaluateMappingRules( supabase: SupabaseClient, userId: string, transaction: Transaction, entityType?: EntityType ): Promise { // Fetch all active rules (user-specific + system defaults), ordered by priority const { data: rules, error } = await supabase .from('mapping_rules') .select('*') .eq('is_active', true) .or(`user_id.eq.${userId},user_id.is.null`) .order('priority', { ascending: true }) if (error || !rules || rules.length === 0) { // Try template-based matching before default fallback const templateResult = evaluateTemplateRules(transaction, entityType) if (templateResult) return templateResult return getDefaultResult(transaction) } // Evaluate each rule in priority order for (const rule of rules as MappingRule[]) { if (matchesRule(rule, transaction)) { return buildResult(rule, transaction, entityType) } } // Try template-based matching before default fallback const templateResult = evaluateTemplateRules(transaction, entityType) if (templateResult) return templateResult return getDefaultResult(transaction) } /** * Evaluate booking templates as a fallback when no DB mapping rule matches. * Returns the best template match if confidence >= 0.3, otherwise null. */ function evaluateTemplateRules( transaction: Transaction, entityType?: EntityType ): MappingResult | null { const matches = findMatchingTemplates(transaction, entityType) if (matches.length === 0 || matches[0].confidence < 0.3) return null const best = matches[0] const result = buildMappingResultFromTemplate( best.template, transaction, entityType || 'enskild_firma' ) // Override the confidence with the auto-match confidence (not 1.0) result.confidence = best.confidence return result } /** * Check if a transaction matches a mapping rule */ function matchesRule(rule: MappingRule, transaction: Transaction): boolean { // MCC code matching if (rule.mcc_codes && rule.mcc_codes.length > 0) { if (!transaction.mcc_code || !rule.mcc_codes.includes(transaction.mcc_code)) { return false } } // Merchant name pattern matching (case-insensitive) if (rule.merchant_pattern) { const merchantName = transaction.merchant_name || transaction.description || '' try { const regex = new RegExp(rule.merchant_pattern, 'i') if (!regex.test(merchantName)) { return false } } catch { // Invalid regex, try simple includes if (!merchantName.toLowerCase().includes(rule.merchant_pattern.toLowerCase())) { return false } } } // Description pattern matching if (rule.description_pattern) { try { const regex = new RegExp(rule.description_pattern, 'i') if (!regex.test(transaction.description)) { return false } } catch { if (!transaction.description.toLowerCase().includes(rule.description_pattern.toLowerCase())) { return false } } } // Amount threshold matching const absAmount = Math.abs(transaction.amount) if (rule.amount_min != null && absAmount < rule.amount_min) { return false } if (rule.amount_max != null && absAmount > rule.amount_max) { return false } return true } /** * Build a MappingResult from a matched rule */ function buildResult(rule: MappingRule, transaction: Transaction, entityType?: EntityType): MappingResult { const absAmount = Math.abs(transaction.amount) const isExpense = transaction.amount < 0 let debitAccount = rule.debit_account || (isExpense ? '6991' : '1930') const creditAccount = rule.credit_account || (isExpense ? '1930' : '3900') // Check capitalization threshold for equipment const year = new Date(transaction.date).getFullYear() const threshold = rule.capitalization_threshold ?? getCapitalizationThreshold(year) if (absAmount > threshold && rule.capitalized_debit_account) { debitAccount = rule.capitalized_debit_account } // If default_private, use entity-specific private account if (rule.default_private && isExpense) { debitAccount = entityType === 'aktiebolag' ? '2893' : '2013' } // Generate VAT lines if applicable const vatLines: VatJournalLine[] = [] if (isExpense && !rule.default_private && rule.vat_treatment) { if (rule.vat_treatment === 'reverse_charge') { // EU reverse charge: fiktiv moms (offsetting entries) const rcLines = generateReverseChargeLines(absAmount) for (const rcl of rcLines) { vatLines.push({ account_number: rcl.account_number, debit_amount: rcl.debit_amount, credit_amount: rcl.credit_amount, description: rcl.line_description || '', }) } } else if (rule.vat_treatment === 'standard_25' || rule.vat_treatment === 'reduced_12' || rule.vat_treatment === 'reduced_6') { const vatRate = rule.vat_treatment === 'standard_25' ? 0.25 : rule.vat_treatment === 'reduced_12' ? 0.12 : 0.06 const vatLine = generateInputVatLine(absAmount, vatRate) if (vatLine) { vatLines.push({ account_number: vatLine.account_number, debit_amount: vatLine.debit_amount, credit_amount: vatLine.credit_amount, description: vatLine.line_description || '', }) } } } return { rule, debit_account: debitAccount, credit_account: creditAccount, risk_level: rule.risk_level, confidence: rule.confidence_score, requires_review: rule.requires_review, default_private: rule.default_private, vat_lines: vatLines, description: rule.rule_name, } } /** * Default result when no rule matches (uncategorized) */ function getDefaultResult(transaction: Transaction): MappingResult { const isExpense = transaction.amount < 0 return { rule: null, debit_account: isExpense ? '6991' : '1930', credit_account: isExpense ? '1930' : '3900', risk_level: 'MEDIUM', confidence: 0.1, requires_review: true, default_private: false, vat_lines: [], description: 'Obokförd transaktion', } } /** * Save a user-level mapping rule learned from categorization. * * When userDescription is provided, the rule gets: * - source: 'user_description' (instead of 'auto') * - priority: 5 (beats auto-learned at 10) * - confidence_score: 0.98 * - The original user text and template_id stored for UI display * * User-described rules for the same merchant replace prior user-described rules * (latest description wins). */ export async function saveUserMappingRule( supabase: SupabaseClient, userId: string, merchantName: string, debitAccount: string, creditAccount: string, isPrivate: boolean, userDescription?: string, templateId?: string ): Promise { // Escape special regex characters in merchant name const escapedMerchant = merchantName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') if (userDescription) { // Delete existing user_description rule for this merchant (latest wins) await supabase .from('mapping_rules') .delete() .eq('user_id', userId) .eq('merchant_pattern', escapedMerchant) .eq('source', 'user_description') const { error } = await supabase.from('mapping_rules').insert({ user_id: userId, rule_name: `Described: ${merchantName}`, rule_type: 'merchant_name', priority: 5, merchant_pattern: escapedMerchant, debit_account: debitAccount, credit_account: creditAccount, risk_level: 'NONE', default_private: isPrivate, requires_review: false, confidence_score: 0.98, source: 'user_description', user_description: userDescription, template_id: templateId || null, }) if (error) { // Silently fail — saving learned rules is non-critical } } else { const { error } = await supabase.from('mapping_rules').insert({ user_id: userId, rule_name: `Learned: ${merchantName}`, rule_type: 'merchant_name', priority: 10, merchant_pattern: escapedMerchant, debit_account: debitAccount, credit_account: creditAccount, risk_level: 'NONE', default_private: isPrivate, requires_review: false, confidence_score: 0.95, source: 'auto', }) if (error) { // Silently fail — saving learned rules is non-critical } } }