Files
accounted/lib/bookkeeping/__tests__/mapping-engine.test.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

955 lines
31 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockSupabase, makeTransaction } from '@/tests/helpers'
// Mock Supabase
const { supabase: mockSupabase, mockResult } = createMockSupabase()
// Mock booking-templates (needed by evaluateMappingRules)
vi.mock('../booking-templates', () => ({
findMatchingTemplates: vi.fn().mockReturnValue([]),
buildMappingResultFromTemplate: vi.fn(),
}))
// Mock counterparty-templates (needed by evaluateMappingRules)
vi.mock('../counterparty-templates', () => ({
findCounterpartyTemplate: vi.fn().mockResolvedValue(null),
buildMappingResultFromCounterpartyTemplate: vi.fn(),
}))
describe('mapping-engine', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('saveUserMappingRule', () => {
it('saves auto-learned rule without user description', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: null })
await saveUserMappingRule(mockSupabase as never, 'user-1', 'ICA Maxi', '5410', '1930', false)
// Verify insert was called via supabase.from().insert()
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
})
it('saves user-described rule with priority 5 and confidence 0.98', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: null })
await saveUserMappingRule(
mockSupabase as never,
'user-1',
'Restaurant XYZ',
'6071',
'1930',
false,
'business lunch with client',
'restaurant_dining'
)
// Verify from was called (first for delete, then for insert)
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
})
it('does not throw on insert error (non-critical)', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: { message: 'DB error' } })
// Should not throw
await expect(
saveUserMappingRule(mockSupabase as never, 'user-1', 'ICA Maxi', '5410', '1930', false)
).resolves.toBeUndefined()
})
it('escapes special regex characters in merchant name', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: null })
// Merchant name with regex special chars
await saveUserMappingRule(mockSupabase as never, 'user-1', 'Test (Pty) Ltd.', '5410', '1930', false)
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
})
})
describe('evaluateMappingRules', () => {
// Full mapping_rules row for pattern-matching tests; override per case.
const makeRule = (overrides: Record<string, unknown>) => ({
id: 'rule-1',
user_id: null,
rule_name: 'Pattern rule',
rule_type: 'description',
priority: 100,
mcc_codes: null,
merchant_pattern: null,
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '6540',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
...overrides,
})
it('matches a description_pattern that only appears in original_description', async () => {
// The ingest boundary strips the trailing channel phrase off the working
// title; a rule written against the bank's full text must keep firing
// via the immutable original.
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -100,
merchant_name: null,
description: 'Vercel Jul',
original_description: 'Vercel Jul Överföring via internet',
})
mockResult({
data: [makeRule({ description_pattern: 'Överföring via internet' })],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('6540')
})
it('matches a merchant_pattern that only appears in original_description', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -100,
merchant_name: null,
description: 'SPOTIFY AB',
original_description: 'SPOTIFY AB Kortköp',
})
mockResult({
data: [makeRule({ rule_type: 'merchant_name', merchant_pattern: 'Kortköp' })],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('6540')
})
it('invalid-regex substring fallback also scans original_description', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 'fee (2026' is an invalid regex (unclosed group) and a literal
// substring of the original only.
const tx = makeTransaction({
amount: -100,
merchant_name: null,
description: 'Stripe: Billing - Usage Fee',
original_description: 'Stripe: Billing - Usage Fee (2026-07-26)',
})
mockResult({
data: [makeRule({ description_pattern: 'fee (2026' })],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('6540')
})
it('does not match when the pattern appears in neither description nor original', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -100,
merchant_name: null,
description: 'Vercel Jul',
original_description: 'Vercel Jul Överföring via internet',
})
mockResult({
data: [makeRule({ description_pattern: 'Kortköp' })],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('6991') // default expense fallback
})
it('returns default result when no rules match (expense)', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({ amount: -100, merchant_name: 'Unknown' })
mockResult({ data: [], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('6991')
expect(result.credit_account).toBe('1930')
expect(result.confidence).toBe(0.1)
expect(result.requires_review).toBe(true)
})
it('returns VAT-neutral 3900 as default income account (not 3001)', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({ amount: 500, merchant_name: 'Unknown' })
mockResult({ data: [], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('1930')
expect(result.credit_account).toBe('3900')
expect(result.requires_review).toBe(true)
})
it('uses 2893 for default_private with aktiebolag entity type', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({ amount: -500, merchant_name: 'Private Purchase' })
mockResult({
data: [
{
id: 'rule-private',
user_id: null,
rule_name: 'Private fallback',
rule_type: 'merchant_name',
priority: 100,
mcc_codes: null,
merchant_pattern: 'Private',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: null,
credit_account: null,
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: true,
requires_review: false,
confidence_score: 0.8,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'aktiebolag')
expect(result.debit_account).toBe('2893')
expect(result.default_private).toBe(true)
})
it('uses 2013 for default_private with enskild_firma entity type', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({ amount: -500, merchant_name: 'Private Purchase' })
mockResult({
data: [
{
id: 'rule-private',
user_id: null,
rule_name: 'Private fallback',
rule_type: 'merchant_name',
priority: 100,
mcc_codes: null,
merchant_pattern: 'Private',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: null,
credit_account: null,
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: true,
requires_review: false,
confidence_score: 0.8,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('2013')
})
it('applies year-based capitalization threshold from prisbasbelopp', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 2024 threshold = 28,650. This amount exceeds it.
const tx = makeTransaction({
amount: -30000,
date: '2024-06-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
{
id: 'rule-cap',
user_id: null,
rule_name: 'Equipment',
rule_type: 'merchant_name',
priority: 50,
mcc_codes: null,
merchant_pattern: 'Equipment',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5410',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: '1250',
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// 30,000 > 28,650 (2024 half-PBB) → should capitalize to 1250
expect(result.debit_account).toBe('1250')
})
it('uses 2025 threshold for 2025 transactions', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 2025 threshold = 29,400. Amount of 29,000 is below it.
const tx = makeTransaction({
amount: -29000,
date: '2025-03-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
{
id: 'rule-cap',
user_id: null,
rule_name: 'Equipment',
rule_type: 'merchant_name',
priority: 50,
mcc_codes: null,
merchant_pattern: 'Equipment',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5410',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: '1250',
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// 29,000 < 29,400 (2025 half-PBB) → should NOT capitalize
expect(result.debit_account).toBe('5410')
})
it('matches merchant_pattern rule', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -299,
merchant_name: 'ICA Maxi',
description: 'ICA MAXI STOCKHOLM',
})
mockResult({
data: [
{
id: 'rule-1',
user_id: 'user-1',
rule_name: 'Learned: ICA Maxi',
rule_type: 'merchant_name',
priority: 10,
mcc_codes: null,
merchant_pattern: 'ICA Maxi',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5410',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'NONE',
default_private: false,
requires_review: false,
confidence_score: 0.95,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'auto',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('5410')
expect(result.credit_account).toBe('1930')
expect(result.confidence).toBe(0.95)
})
it('emits both fiktiv-moms and basbelopp lines for reverse_charge rules', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -1000,
merchant_name: 'AWS',
description: 'AWS EU-WEST-1',
})
mockResult({
data: [
{
id: 'rule-rc',
user_id: 'user-1',
rule_name: 'AWS reverse charge',
rule_type: 'merchant_name',
priority: 10,
mcc_codes: null,
merchant_pattern: 'AWS',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5421',
credit_account: '1930',
vat_treatment: 'reverse_charge',
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// Fiktiv-moms pair + basbelopp pair = 4 lines (FK004 guard)
expect(result.vat_lines).toHaveLength(4)
expect(result.vat_lines[0].account_number).toBe('2645')
expect(result.vat_lines[0].debit_amount).toBe(250)
expect(result.vat_lines[1].account_number).toBe('2614')
expect(result.vat_lines[1].credit_amount).toBe(250)
expect(result.vat_lines[2].account_number).toBe('4535')
expect(result.vat_lines[2].debit_amount).toBe(1000)
expect(result.vat_lines[3].account_number).toBe('4598')
expect(result.vat_lines[3].credit_amount).toBe(1000)
})
it('skips basbelopp emission when rule already debits a basis account', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -1000,
merchant_name: 'AWS',
})
mockResult({
data: [
{
id: 'rule-rc-basis',
user_id: 'user-1',
rule_name: 'AWS RC to basis',
rule_type: 'merchant_name',
priority: 10,
mcc_codes: null,
merchant_pattern: 'AWS',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '4535',
credit_account: '1930',
vat_treatment: 'reverse_charge',
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// Only fiktiv-moms pair: basbelopp already covered by the expense line
expect(result.vat_lines).toHaveLength(2)
expect(result.vat_lines[0].account_number).toBe('2645')
expect(result.vat_lines[1].account_number).toBe('2614')
})
// Journal entry lines are SEK; VAT from a rule match must be derived from
// the transaction's SEK value, not the raw foreign amount. Sibling of the
// buildMappingResultFromCategory FX fix (PR #1842).
it('derives rule input VAT from the SEK value for a foreign transaction', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -100,
currency: 'EUR',
exchange_rate: 11,
merchant_name: 'Adobe',
description: 'ADOBE SYSTEMS',
})
mockResult({
data: [
{
id: 'rule-fx-vat',
user_id: 'user-1',
rule_name: 'Adobe standard VAT',
rule_type: 'merchant_name',
priority: 10,
mcc_codes: null,
merchant_pattern: 'Adobe',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5420',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_debit_account: '2641',
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// 100 EUR at 11 = 1100 kr gross; 25% extraction = 220 kr, not 20
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(220)
})
it('derives reverse-charge fiktiv moms and basbelopp from the SEK value (amount_sek wins)', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -100,
currency: 'USD',
// bank settlement wins over exchange_rate, matching the gross line
amount_sek: -950,
exchange_rate: 10,
merchant_name: 'AWS',
description: 'AWS EU-WEST-1',
})
mockResult({
data: [
{
id: 'rule-rc-fx',
user_id: 'user-1',
rule_name: 'AWS reverse charge',
rule_type: 'merchant_name',
priority: 10,
mcc_codes: null,
merchant_pattern: 'AWS',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5421',
credit_account: '1930',
vat_treatment: 'reverse_charge',
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// 950 kr gross: fiktiv moms 237.50, basbelopp 950 (not 25/100 off USD)
expect(result.vat_lines).toHaveLength(4)
expect(result.vat_lines[0].account_number).toBe('2645')
expect(result.vat_lines[0].debit_amount).toBe(237.5)
expect(result.vat_lines[1].account_number).toBe('2614')
expect(result.vat_lines[1].credit_amount).toBe(237.5)
expect(result.vat_lines[2].debit_amount).toBe(950)
expect(result.vat_lines[3].credit_amount).toBe(950)
})
})
// Both the halva-prisbasbeloppet threshold (IL 18 kap 4 §) and a rule's
// amount_min/amount_max band are SEK figures. Before the fix they were
// compared against the raw transaction amount, so a 3000 EUR laptop
// (about 34 500 kr) read as under the 29 600 kr limit for 2026 and was
// expensed to 5410 instead of capitalised to 1250.
describe('SEK thresholds on foreign-currency transactions', () => {
function makeRule(overrides: Record<string, unknown> = {}) {
return {
id: 'rule-cap',
user_id: null,
rule_name: 'Equipment',
rule_type: 'merchant_name',
priority: 50,
mcc_codes: null,
merchant_pattern: 'Equipment',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5410',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: '1250',
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
...overrides,
}
}
it('capitalizes a SEK 34 500 laptop (over the 2026 half-PBB of 29 600)', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -34500,
currency: 'SEK',
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('1250')
expect(result.requires_review).toBe(false)
})
it('expenses a SEK 20 000 laptop (under the 2026 half-PBB of 29 600)', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -20000,
currency: 'SEK',
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('5410')
expect(result.requires_review).toBe(false)
})
it('capitalizes a 3000 EUR laptop when an exchange_rate is present', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 3000 EUR * 11.5 = 34 500 kr, over the 29 600 kr limit.
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: null,
exchange_rate: 11.5,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('1250')
expect(result.requires_review).toBe(false)
})
it('capitalizes a 3000 EUR laptop when amount_sek is pre-computed', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: -34500,
exchange_rate: null,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('1250')
})
it('does not silently expense a 3000 EUR laptop with no rate: declines to auto-classify', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: null,
exchange_rate: null,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// The rule alone would auto-book (confidence 0.9, requires_review false).
// Without a SEK value the capitalization branch is a guess, so the
// result must land under the 0.8 auto-book bar and explain itself.
expect(result.requires_review).toBe(true)
expect(result.confidence).toBeLessThan(0.8)
expect(result.description).toContain('växelkurs saknas')
})
it('leaves rules that cannot capitalize untouched when no rate is available', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: null,
exchange_rate: null,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule({ capitalized_debit_account: null })], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// No capitalization decision to make: the missing rate is irrelevant.
expect(result.debit_account).toBe('5410')
expect(result.requires_review).toBe(false)
expect(result.confidence).toBe(0.9)
})
it('evaluates a user amount band against SEK, not the foreign amount', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 3000 EUR = 34 500 kr, well over the rule's 5 000 kr ceiling. The raw
// amount (3000) would have slipped under it.
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: null,
exchange_rate: 11.5,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
makeRule({
rule_name: 'Small equipment',
amount_max: 5000,
capitalized_debit_account: null,
}),
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// No match: falls through to the uncategorized default.
expect(result.rule).toBeNull()
expect(result.debit_account).toBe('6991')
expect(result.requires_review).toBe(true)
})
it('matches a user amount band when the SEK value falls inside it', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 100 EUR * 11.5 = 1 150 kr, inside the 1 000-5 000 kr band.
const tx = makeTransaction({
amount: -100,
currency: 'EUR',
amount_sek: null,
exchange_rate: 11.5,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
makeRule({
rule_name: 'Small equipment',
amount_min: 1000,
amount_max: 5000,
capitalized_debit_account: null,
}),
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('5410')
expect(result.confidence).toBe(0.9)
})
it('skips an amount-band rule when the transaction has no SEK value', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: null,
exchange_rate: null,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
makeRule({
rule_name: 'Small equipment',
amount_max: 5000,
capitalized_debit_account: null,
}),
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// The band is unevaluable, so the rule does not apply. The transaction
// lands in the uncategorized default where the user picks it up.
expect(result.rule).toBeNull()
expect(result.requires_review).toBe(true)
})
it('still applies SEK amount bands unchanged for SEK transactions', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -3000,
currency: 'SEK',
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
makeRule({
rule_name: 'Small equipment',
amount_min: 1000,
amount_max: 5000,
capitalized_debit_account: null,
}),
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('5410')
})
})
})