feat: add foreign currency support, refactor bookkeeping engine, and improve invoice inbox document classification

- Add currency-utils module for SEK conversion with exchange rates
- Refactor createJournalEntry to use draft+commit flow preventing voucher number gaps (BFL 5 kap. 7§)
- Add foreign currency support to invoice entries with per-line SEK conversion
- Centralize category-to-account mapping into single source of truth
- Refactor invoice inbox to use shared document analyzer with document type classification (receipt, supplier invoice, government letter)
- Update mapping engine, supplier invoice entries, and transaction entries
- Fix report component rendering issues
- Add new validation schemas and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-27 09:50:06 +01:00
co-authored by Claude Opus 4.6
parent 547fd053ec
commit 6f4573f380
33 changed files with 1365 additions and 497 deletions
+26 -13
View File
@@ -14,9 +14,24 @@ import type {
EntityType,
VatJournalLine,
} from '@/types'
import { createLogger } from '@/lib/logger'
// Capitalization threshold in SEK (half-year rule: 29,400 for 2024)
const CAPITALIZATION_THRESHOLD = 29400
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
@@ -52,7 +67,7 @@ export async function evaluateMappingRules(
// Evaluate each rule in priority order
for (const rule of rules as MappingRule[]) {
if (matchesRule(rule, transaction)) {
return buildResult(rule, transaction)
return buildResult(rule, transaction, entityType)
}
}
@@ -141,25 +156,23 @@ function matchesRule(rule: MappingRule, transaction: Transaction): boolean {
/**
* Build a MappingResult from a matched rule
*/
function buildResult(rule: MappingRule, transaction: Transaction): MappingResult {
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')
let creditAccount = rule.credit_account || (isExpense ? '1930' : '3001')
const creditAccount = rule.credit_account || (isExpense ? '1930' : '3900')
// Check capitalization threshold for equipment
if (
rule.capitalization_threshold &&
absAmount > rule.capitalization_threshold &&
rule.capitalized_debit_account
) {
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, override to 2013
// If default_private, use entity-specific private account
if (rule.default_private && isExpense) {
debitAccount = '2013'
debitAccount = entityType === 'aktiebolag' ? '2893' : '2013'
}
// Generate VAT lines if applicable
@@ -215,7 +228,7 @@ function getDefaultResult(transaction: Transaction): MappingResult {
return {
rule: null,
debit_account: isExpense ? '6991' : '1930',
credit_account: isExpense ? '1930' : '3001',
credit_account: isExpense ? '1930' : '3900',
risk_level: 'MEDIUM',
confidence: 0.1,
requires_review: true,