Files
accounted/lib/bookkeeping/mapping-engine.ts
T
Jakob Wennberg 0ed26c2eca fix(mcp,bookkeeping): FX-safe mapping-rule VAT + honest nullable output schemas (#1846)
Follow-up sweep to #1842 for sibling bugs of both classes.

FX denomination (same defect as categorize's vat_amount):
- mapping-engine buildResult computed input VAT, reverse-charge fiktiv
  moms AND the rutor 20-24 basbelopp pair from the transaction-currency
  amount while journal lines are SEK. Now resolved through the same
  lenient SEK ladder buildTransactionEntryLines uses for the gross.
  Reachable at the ledger only via env-gated auto-book (dev/test) and
  the mapping-rules evaluate API, but one flag from production.
- categorize-core's header doc claimed transaction.amount already IS
  SEK; rewritten, it was license for exactly this bug class.

Output-schema nullability (same defect as matched_supplier_id):
- get_payslip.calculation_breakdown: returned as explicit null before
  calculation (the schema description even said so) but typed object.
- get_document_content.mime_type/size_bytes: verbatim nullable columns.
- export_sie: company_name nullable, and org_number was returned but
  never declared under additionalProperties:false, so strict clients
  failed EVERY successful export.
- currency (two transaction listings) and voucher_series (verifikat
  listing): nullable columns with defaults, loosened defensively.
- New declaration-pinning test in output-schema.test.ts.

Known and deliberately not fixed here: skatteverket bank-counterpart
hint compares a foreign amount to SEK (display-only, missed hint);
own-account-detector cross-currency magnitude tie-break (heuristic);
invoice-inbox still withholds rule proposals on foreign rows (guard
can be lifted separately now that the defect it guards is fixed).


Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 15:14:21 +02:00

574 lines
21 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import {
generateInputVatLine,
generateReverseChargeLines,
generateReverseChargeBasisLines,
} from './vat-entries'
// Aliased: this file already has a local resolveSekAmountOrNull(transaction)
// for account-selection decisions, which refuses when unconvertible.
import { resolveSekAmount as resolveSekAmountLenient } from './currency-utils'
import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates'
import {
findCounterpartyTemplate,
buildMappingResultFromCounterpartyTemplate,
} from './counterparty-templates'
import { detectOwnAccountTransfer } from './own-account-detector'
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]
}
/**
* Resolve the SEK value of a transaction, or null when it cannot be
* established.
*
* Deliberately stricter than `resolveSekAmount()` in currency-utils: that
* helper falls back to the raw foreign amount for legacy rows, which is
* tolerable for a line amount (the entry still balances against itself) but
* never for a comparison against a SEK limit. Both the halva-prisbasbeloppet
* threshold and a rule's amount_min/amount_max band are SEK figures; feeding
* them an unconverted foreign number silently picks the wrong branch. When no
* rate is known we return null so the caller declines instead of guessing.
*/
function resolveSekAmountOrNull(transaction: Transaction): number | null {
const currency = (transaction.currency || 'SEK').toUpperCase()
if (currency === 'SEK') return transaction.amount
if (transaction.amount_sek != null) {
return Math.round(transaction.amount_sek * 100) / 100
}
if (transaction.exchange_rate != null && transaction.exchange_rate > 0) {
return Math.round(transaction.amount * transaction.exchange_rate * 100) / 100
}
return null
}
/**
* 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'
// Pre-step: detect intra-company transfers. When the counterparty IBAN
// matches another cash_accounts row for the same company, book both legs
// as a transfer between the two ledger accounts instead of running the
// priority rules (which would mis-categorize the outflow as an expense).
try {
const transfer = await detectOwnAccountTransfer(supabase, companyId, transaction)
if (transfer) {
const isFx =
(transaction.currency || '').toUpperCase() !==
(transfer.counterCurrency || '').toUpperCase()
return buildOwnAccountTransferResult(
transaction,
bankAccount,
transfer.counterLedgerAccount,
isFx,
)
}
} catch (err) {
// Non-fatal: falling through to normal categorization is correct when
// the detector fails. We log so an unexpected upstream error is visible.
log.warn('own-account transfer detection failed', {
companyId,
transactionId: transaction.id,
error: err instanceof Error ? err.message : String(err),
})
}
// 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). The description leg
// also tests original_description: since the ingest boundary started
// stripping the trailing channel phrase off the working title
// (classifyTransactionMethod), a rule written against the bank's full text
// ("Överföring via internet") only matches the immutable original.
if (rule.merchant_pattern) {
const merchantName = transaction.merchant_name || transaction.description || ''
const originalName = transaction.original_description || ''
try {
const regex = new RegExp(rule.merchant_pattern, 'i')
if (!regex.test(merchantName) && !(originalName && regex.test(originalName))) {
return false
}
} catch {
// Invalid regex, try simple includes
const needle = rule.merchant_pattern.toLowerCase()
if (
!merchantName.toLowerCase().includes(needle) &&
!originalName.toLowerCase().includes(needle)
) {
return false
}
}
}
// Description pattern matching: the working title OR the full bank original
// (see the merchant_pattern note above).
if (rule.description_pattern) {
const originalDescription = transaction.original_description || ''
try {
const regex = new RegExp(rule.description_pattern, 'i')
if (
!regex.test(transaction.description) &&
!(originalDescription && regex.test(originalDescription))
) {
return false
}
} catch {
const needle = rule.description_pattern.toLowerCase()
if (
!transaction.description.toLowerCase().includes(needle) &&
!originalDescription.toLowerCase().includes(needle)
) {
return false
}
}
}
// Amount threshold matching.
//
// `mapping_rules` has no currency column, so an amount_min/amount_max band
// can only mean SEK: the ledger currency. Compare it against the SEK value
// of the transaction, never the raw foreign amount (a 3000 EUR row would
// otherwise slip through a "max 5000" band that was written to mean kronor).
// When a non-SEK row carries neither amount_sek nor an exchange_rate the
// band is not evaluable, so the rule does not apply: skipping is the honest
// outcome, matching on an unconverted number is not.
if (rule.amount_min != null || rule.amount_max != null) {
const sekAmount = resolveSekAmountOrNull(transaction)
if (sekAmount === null) {
log.warn('mapping rule with a SEK amount band skipped: transaction has no SEK value', {
transactionId: transaction.id,
currency: transaction.currency,
ruleId: rule.id,
ruleName: rule.rule_name,
amountMin: rule.amount_min,
amountMax: rule.amount_max,
})
return false
}
const absAmount = Math.abs(sekAmount)
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 {
// VAT figures land on journal entry lines, which are always SEK, so they
// are derived from the SEK value of the transaction. The LENIENT resolver
// is deliberate: buildTransactionEntryLines resolves the gross with the
// same ladder, so the VAT lines and the bank leg can never disagree (a
// rateless legacy row degrades to today's behavior on both sides instead
// of unbalancing the net line). Mirrors buildMappingResultFromCategory.
const absSekAmount = Math.abs(resolveSekAmountLenient(
transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate
))
const isExpense = transaction.amount < 0
let debitAccount = rule.debit_account || (isExpense ? '6991' : '1930')
const creditAccount = rule.credit_account || (isExpense ? '1930' : '3900')
// Capitalization threshold for equipment (IL 18 kap 4 §, "inventarier av
// mindre värde"): below half prisbasbelopp it may be expensed straight to a
// 54xx förbrukningsinventarie account, above it must be capitalised to 12xx
// and depreciated.
//
// The threshold is a SEK figure, so the amount compared against it has to be
// SEK as well. Comparing a raw 3000 EUR laptop (about 34 500 kr) against the
// 29 600 kr limit for 2026 reads as "under the limit" and expenses a
// capital asset to 5410: no avskrivning, wrong balansräkning, and a
// skattemässig error. Only rules that can actually capitalise are affected.
let capitalizationUndecided = false
if (rule.capitalized_debit_account) {
const year = new Date(transaction.date).getFullYear()
const threshold = rule.capitalization_threshold ?? getCapitalizationThreshold(year)
const sekAmount = resolveSekAmountOrNull(transaction)
if (sekAmount === null) {
// Non-SEK row with neither amount_sek nor an exchange_rate. Both
// branches are a guess and a wrong guess is a depreciation and tax
// error, so decline to auto-classify and hand it to the user instead
// of faking a conversion.
capitalizationUndecided = true
log.warn('capitalization threshold undecidable: transaction has no SEK value', {
transactionId: transaction.id,
currency: transaction.currency,
ruleId: rule.id,
ruleName: rule.rule_name,
})
} else if (Math.abs(sekAmount) > threshold) {
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') {
// Reverse charge: emit BOTH the fiktiv-moms pair (2645/2614) AND the
// basbelopp pair (44xx|45xx / 4598). The basbelopp pair populates
// momsdeklaration rutor 20-24; without it Skatteverket rejects with
// FK004. Mapping rules don't carry supplier-country today, so we
// default to EU services: the most common reverse-charge scenario.
const rcRate = 0.25
const rcLines = generateReverseChargeLines(absSekAmount, rcRate, false)
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 || '',
})
}
// Skip basbelopp emission if the rule already books to a basis account.
if (!/^4[45]\d{2}$/.test(debitAccount)) {
const basisLines = generateReverseChargeBasisLines(absSekAmount, rcRate, 'eu_business')
for (const bl of basisLines) {
vatLines.push({
account_number: bl.account_number,
debit_amount: bl.debit_amount,
credit_amount: bl.credit_amount,
description: bl.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(absSekAmount, vatRate)
if (vatLine) {
vatLines.push({
account_number: vatLine.account_number,
debit_amount: vatLine.debit_amount,
credit_amount: vatLine.credit_amount,
description: vatLine.line_description || '',
})
}
}
}
// An undecidable capitalization check must not auto-post: drop the
// confidence under the auto-book bar (0.8 in lib/transactions/ingest.ts),
// force review, and say why in the suggestion label so the user sees the
// missing rate rather than a silently expensed asset.
return {
rule,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: rule.risk_level,
confidence: capitalizationUndecided
? Math.min(rule.confidence_score, 0.3)
: rule.confidence_score,
requires_review: capitalizationUndecided ? true : rule.requires_review,
default_private: rule.default_private,
vat_lines: vatLines,
description: capitalizationUndecided
? `${rule.rule_name} (granska: växelkurs saknas, går inte att avgöra om beloppet överstiger halva prisbasbeloppet)`
: 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',
}
}
/**
* Build a MappingResult for a detected own-account transfer.
*
* For an outflow (negative amount): debit the counter account, credit this
* side's settlement account. The counter side will book the mirror entry when
* its row is ingested.
*
* For an inflow (positive amount): debit this side's settlement account,
* credit the counter account.
*
* Confidence is high (0.95) because IBAN match against the company's own
* cash_accounts is an exact identity check, not a heuristic.
*
* `isFx` flips `requires_review` to true when the two legs sit on different
* currencies (e.g. SEK 1930 → EUR 1932). A cross-currency leg generally
* realises a kursvinst/kursförlust on 3960/7960 (ÅRL 4 kap 10 §) that the
* two-line transfer entry doesn't capture: a human must confirm the FX gain
* or loss line rather than auto-booking a potentially incomplete entry.
* Same-currency transfers stay auto-bookable.
*/
function buildOwnAccountTransferResult(
transaction: Transaction,
bankAccount: string,
counterAccount: string,
isFx: boolean = false,
): MappingResult {
const isOutflow = transaction.amount < 0
return {
rule: null,
debit_account: isOutflow ? counterAccount : bankAccount,
credit_account: isOutflow ? bankAccount : counterAccount,
risk_level: isFx ? 'MEDIUM' : 'LOW',
confidence: isFx ? 0.7 : 0.95,
requires_review: isFx,
default_private: false,
vat_lines: [],
description: isFx
? 'Överföring mellan egna konton (FX: granska kursvinst/förlust)'
: 'Överföring mellan egna konton',
}
}
/**
* 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).
*/
export 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
}
}
}