ade4ad5971
* feat: add fiscal period backward chaining and entry date validation Support creating fiscal periods before the earliest existing period (backward chaining) for backfill scenarios, alongside the existing forward chaining. The engine now validates that entry dates fall within the selected fiscal period, with a Swedish error message. The journal entry form auto-selects the matching period and shows a warning with a CreatePeriodDialog when no period covers the entry date. * feat: support multi-bank-account for imports and reconciliation Plumb a configurable settlement account through the entire bank import pipeline — mapping engine, transaction entries, ingest, and reconciliation — so secondary bank accounts (e.g. 1931, 1932) work correctly instead of hardcoding 1930. Adds a get_unlinked_bank_lines RPC that generalizes the existing get_unlinked_1930_lines with a fallback for backwards compatibility. The bank file import UI now shows a bank account selector when multiple 19xx accounts exist. Also adds default_vat_code/sru_code to account creation and fixes uploadDocument argument order in enable-banking sync.
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
generateInputVatLine,
|
|
generateReverseChargeLines,
|
|
} from './vat-entries'
|
|
import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates'
|
|
import {
|
|
findCounterpartyTemplate,
|
|
buildMappingResultFromCounterpartyTemplate,
|
|
} from './counterparty-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. Counterparty templates (learned from history, fuzzy matching)
|
|
* 6. Static booking templates (keyword/MCC matching)
|
|
* 7. Default fallback (uncategorized)
|
|
*/
|
|
export async function evaluateMappingRules(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
transaction: Transaction,
|
|
entityType?: EntityType,
|
|
settlementAccount?: string
|
|
): Promise<MappingResult> {
|
|
const bankAccount = settlementAccount || '1930'
|
|
|
|
// 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(`company_id.eq.${companyId},company_id.is.null`)
|
|
.order('priority', { ascending: true })
|
|
|
|
if (error || !rules || rules.length === 0) {
|
|
// Try counterparty templates before static template fallback
|
|
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, companyId, transaction, entityType)
|
|
if (counterpartyResult) return applySettlementAccount(counterpartyResult, bankAccount)
|
|
|
|
const templateResult = evaluateTemplateRules(transaction, entityType)
|
|
if (templateResult) return applySettlementAccount(templateResult, bankAccount)
|
|
return getDefaultResult(transaction, bankAccount)
|
|
}
|
|
|
|
// Evaluate each rule in priority order
|
|
for (const rule of rules as MappingRule[]) {
|
|
if (matchesRule(rule, transaction)) {
|
|
return applySettlementAccount(buildResult(rule, transaction, entityType), bankAccount)
|
|
}
|
|
}
|
|
|
|
// Try counterparty templates before static template fallback
|
|
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, companyId, transaction, entityType)
|
|
if (counterpartyResult) return applySettlementAccount(counterpartyResult, bankAccount)
|
|
|
|
// Try template-based matching before default fallback
|
|
const templateResult = evaluateTemplateRules(transaction, entityType)
|
|
if (templateResult) return applySettlementAccount(templateResult, bankAccount)
|
|
|
|
return getDefaultResult(transaction, bankAccount)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* Evaluate counterparty templates as a fallback when no DB mapping rule matches.
|
|
* Source-aware threshold: auto_learned needs 0.6 (require more evidence),
|
|
* user_approved/sie_import use 0.4 (human has validated the pattern).
|
|
*/
|
|
async function evaluateCounterpartyTemplates(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
transaction: Transaction,
|
|
entityType?: EntityType
|
|
): Promise<MappingResult | null> {
|
|
try {
|
|
const match = await findCounterpartyTemplate(supabase, companyId, transaction)
|
|
if (!match) return null
|
|
|
|
const threshold = match.template.source === 'auto_learned' ? 0.6 : 0.4
|
|
if (match.confidence < threshold) return null
|
|
|
|
return buildMappingResultFromCounterpartyTemplate(
|
|
match,
|
|
transaction,
|
|
entityType || 'enskild_firma'
|
|
)
|
|
} catch {
|
|
// Non-critical — fall through to next fallback
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, bankAccount = '1930'): MappingResult {
|
|
const isExpense = transaction.amount < 0
|
|
|
|
return {
|
|
rule: null,
|
|
debit_account: isExpense ? '6991' : bankAccount,
|
|
credit_account: isExpense ? bankAccount : '3900',
|
|
risk_level: 'MEDIUM',
|
|
confidence: 0.1,
|
|
requires_review: true,
|
|
default_private: false,
|
|
vat_lines: [],
|
|
description: 'Obokförd transaktion',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace any default 1930 references in a mapping result with the actual settlement account.
|
|
* This allows mapping rules and templates that don't explicitly set a bank account
|
|
* to work correctly with secondary bank accounts (e.g. 1931).
|
|
*/
|
|
function applySettlementAccount(result: MappingResult, bankAccount: string): MappingResult {
|
|
if (bankAccount === '1930') return result
|
|
return {
|
|
...result,
|
|
debit_account: result.debit_account === '1930' ? bankAccount : result.debit_account,
|
|
credit_account: result.credit_account === '1930' ? bankAccount : result.credit_account,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
companyId: 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('company_id', companyId)
|
|
.eq('merchant_pattern', escapedMerchant)
|
|
.eq('source', 'user_description')
|
|
|
|
const { error } = await supabase.from('mapping_rules').insert({
|
|
company_id: companyId,
|
|
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({
|
|
company_id: companyId,
|
|
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
|
|
}
|
|
}
|
|
}
|