Files
accounted/lib/bookkeeping/__tests__/mapping-engine.test.ts
T
Mattsson da39eb2d43 Bug/momsdek overflow (#465)
* feat(settings): add option for company name position in invoice PDF

* feat(migrations): add backfill for VAT account labels to correct bad seed data

* feat(migrations): add backfill for VAT account labels to correct bad seed data

* fix(ui): improve accessibility for company name position toggle in PDF settings

* fix(bookkeeping): align BAS 2026 reference data with official PDF

Reconciled lib/bookkeeping/bas-data/ against the BAS 2026 v1.1 official
chart (1286 accounts). All real discrepancies fixed:

- 2089 Fond för utvecklingsutgifter: k2_excluded → true
- 8417 Räntekostnader för dold räntekompensation: k2_excluded → true
- 1250, 1260 renamed to "(Fritt konto för Inventarier, verktyg och
  installationer)" — BAS 2026 freed these slots
- Periodiseringsfond 2120-2139: added year suffixes (2120 = "...2020"
  etc.) and added 8 missing accounts (2121-2127, 2129) for years
  2019, 2021-2027. Dropped phantom 2022/2024 prior-parser garbage.
- 4075-4078: EUland → EU-land
- 8411: förlagsoch → förlags- och

Verified: 1282 of 1286 PDF accounts match exactly after edits (remaining
4 are PDF-parser artifacts, not real data). Build clean.

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

* feat(migrations): backfill BAS 2026 account labels in chart_of_accounts

Companion to the TS reference fix. Updates existing companies' rows where
they still carry seed-data typos or generic names that don't match BAS 2026:

- 4075-4078: EUland → EU-land (hyphenation)
- 8411: förlagsoch → förlags- och (hyphenation)
- 2120, 2130-2137, 2139: rename "Periodiseringsfond" (generic, no year) to
  the BAS 2026 canonical name with year suffix

Defensive: every WHERE clause matches an EXACT current value. Rows that
have been manually renamed by users — including those with a wrong year
that may reference legacy fonds from an earlier BAS numbering cycle — are
left untouched. No row is deleted.

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

* feat(migrations): fix wrong-year labels on Periodiseringsfond accounts

Follow-up to 20260513140000. The first backfill only renamed accounts whose
name was the generic "Periodiseringsfond" (no year). Many customers were
seeded from an older BAS numbering cycle where 2126 = "2016", 2127 = "2017",
etc. — BAS 2026 reuses those account numbers for years 2026/2027.

This migration aligns the year tag with the BAS 2026 meaning of each
account number across 2120-2127, 2129, 2130-2137, 2139. Only rows whose
name still starts with "Periodiseringsfond" are touched — customers who
renamed the account to something custom keep their name.

Verified on staging: all 18 accounts now carry BAS 2026 canonical names.

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

* fix(data): update account names and descriptions for clarity and consistency

* fix(migrations): backfill account names for BAS 2026 freed accounts and refine Periodiseringsfond name matching

* feat: enhance VAT handling with reverse charge logic and supplier type support

* feat: implement VAT declaration validation rules and enhance moms box mapping

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:17:32 +02:00

455 lines
15 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', () => {
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')
})
})
})