091d043c85
Broad update across dashboard pages, components, extensions, and lib code. Includes ESLint config additions, onboarding flow redesign, settings page refactor, help page content expansion, dead code removal, and test mock fixes. Adds dev docs and public assets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
313 lines
9.5 KiB
TypeScript
313 lines
9.5 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
generateInputVatLine,
|
|
generateReverseChargeLines,
|
|
} from './vat-entries'
|
|
import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates'
|
|
import type {
|
|
MappingRule,
|
|
MappingResult,
|
|
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<number, number> = {
|
|
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<MappingResult> {
|
|
// 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<void> {
|
|
// 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
|
|
}
|
|
}
|
|
}
|