Files
accounted/lib/bookkeeping/mapping-engine.ts
T
Jakob WennbergandClaude Opus 4.6 d8e0a22495 feat: counterparty templates, Skatteverket extension, complete VAT form (#117)
* feat: separate AR/AP/accounting into distinct nav groups (#92)

Split the flat "Finans" sidebar group into three visually distinct
sections — Försäljning (AR), Inköp (AP), and Redovisning — so users
coming from Fortnox immediately find customer invoicing and supplier
invoices as top-level concepts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: journal entry detail view, correction chain, and account name display

- Add journal entry detail page at /bookkeeping/[id] with full entry view
- Add correction chain API and component showing storno relationships
- Add JournalEntryStatusBadge component for entry status display
- Show debit/credit account names in template picker and review dialogs
- Expand client-side BAS account name mapping with additional accounts
- Show account codes on transaction inbox suggestion buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback — N+1 query, duplicate name, nav dedup

- Batch reverse-lookup into single query per BFS iteration (was N+1)
- Differentiate account 2393 from 2893 in display names
- Extract shared loop for desktop/mobile nav group rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: counterparty templates, Skatteverket extension, VAT form completeness, and UI cleanup

- Add counterparty-based categorization templates (learned from user approvals
  and auto-ingestion) with fuzzy matching in the mapping engine
- Add Skatteverket extension for direct VAT declaration submission via API
- Complete VAT declaration form with all 30 SKV 4700 boxes (ruta 08, 35-42, 50, 60-62)
- Fix ruta 49 formula to include import VAT (ruta 60+61+62)
- Simplify dashboard UI: remove redundant icons from stat cards, customer cards,
  invoice list, supplier invoices; use Badge variants consistently
- Add SkatteverketPanel component to reports page
- Add categorization_templates and skatteverket_tokens migrations
- Update tests and helpers for new types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback — VAT detection, migration timestamps, dedup

- Fix detectVatTreatment to derive actual rate (12%/6%) from VAT line
  description instead of hardcoding standard_25
- Rename skatteverket_tokens migration to 20260324120001 to avoid
  duplicate timestamp with categorization_templates (fixes Supabase
  deployment failure)
- Make refreshAccessToken accept previousRefreshCount param to enforce
  refresh limit contract at the type level
- Fix rate limiter TOCTOU by claiming slot before await
- Extract formatRedovisare/formatRedovisningsperiod to shared
  lib/skatteverket/format.ts — eliminates duplication between
  mappers.ts and SkatteverketPanel.tsx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:41:12 +01:00

355 lines
11 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,
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 counterparty templates before static template fallback
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, userId, transaction, entityType)
if (counterpartyResult) return counterpartyResult
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 counterparty templates before static template fallback
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, userId, transaction, entityType)
if (counterpartyResult) return counterpartyResult
// 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
}
/**
* 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,
userId: string,
transaction: Transaction,
entityType?: EntityType
): Promise<MappingResult | null> {
try {
const match = await findCounterpartyTemplate(supabase, userId, 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): 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
}
}
}