- Update BAS account catalog with comprehensive SRU codes and K2 flags - Add currency revaluation service with tests and API route - Add expenses page and account deletion API - Enhance booking templates with new patterns and improved tests - Improve transaction categorization with template picker and description matching - Polish dashboard, onboarding, import, and transaction UIs - Refactor year-end service for multi-step closing - Move SRU generator to ne-bilaga, remove standalone SRU export - Remove unused dev docs, mock data, and extension hooks - Add invoice delivery note sequences migration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
549 lines
19 KiB
TypeScript
549 lines
19 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { makeTransaction } from '@/tests/helpers'
|
|
import {
|
|
BOOKING_TEMPLATES,
|
|
getTemplateById,
|
|
getTemplatesByGroup,
|
|
getTemplatesByMcc,
|
|
getTemplateGroups,
|
|
searchTemplates,
|
|
findMatchingTemplates,
|
|
buildMappingResultFromTemplate,
|
|
getCommonTemplates,
|
|
getAdvancedTemplates,
|
|
validateTemplateForEntity,
|
|
type BookingTemplate,
|
|
} from '../booking-templates'
|
|
|
|
// ============================================================
|
|
// Template Data Integrity
|
|
// ============================================================
|
|
|
|
describe('BOOKING_TEMPLATES data integrity', () => {
|
|
it('has exactly 51 templates', () => {
|
|
expect(BOOKING_TEMPLATES).toHaveLength(51)
|
|
})
|
|
|
|
it('all template IDs are unique', () => {
|
|
const ids = BOOKING_TEMPLATES.map((t) => t.id)
|
|
expect(new Set(ids).size).toBe(ids.length)
|
|
})
|
|
|
|
it('all templates have valid required fields', () => {
|
|
for (const t of BOOKING_TEMPLATES) {
|
|
expect(t.id).toBeTruthy()
|
|
expect(t.name_sv).toBeTruthy()
|
|
expect(t.name_en).toBeTruthy()
|
|
expect(t.group).toBeTruthy()
|
|
expect(['expense', 'income', 'transfer']).toContain(t.direction)
|
|
expect(['all', 'enskild_firma', 'aktiebolag']).toContain(t.entity_applicability)
|
|
expect(t.debit_account).toMatch(/^\d{4}$/)
|
|
expect(t.credit_account).toMatch(/^\d{4}$/)
|
|
expect(['full', 'non_deductible', 'conditional']).toContain(t.deductibility)
|
|
expect(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH']).toContain(t.risk_level)
|
|
expect(typeof t.requires_review).toBe('boolean')
|
|
expect(t.impact_score).toBeGreaterThanOrEqual(1)
|
|
expect(t.impact_score).toBeLessThanOrEqual(10)
|
|
expect(t.auto_match_confidence).toBeGreaterThanOrEqual(0.5)
|
|
expect(t.auto_match_confidence).toBeLessThanOrEqual(1.0)
|
|
expect(typeof t.default_private).toBe('boolean')
|
|
expect(t.fallback_category).toBeTruthy()
|
|
expect(t.description_sv).toBeTruthy()
|
|
expect(typeof t.common).toBe('boolean')
|
|
expect(Array.isArray(t.mcc_codes)).toBe(true)
|
|
expect(Array.isArray(t.keywords)).toBe(true)
|
|
expect(t.keywords.length).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('all AB-specific override accounts are valid 4-digit strings', () => {
|
|
for (const t of BOOKING_TEMPLATES) {
|
|
if (t.debit_account_ab) {
|
|
expect(t.debit_account_ab).toMatch(/^\d{4}$/)
|
|
}
|
|
if (t.credit_account_ab) {
|
|
expect(t.credit_account_ab).toMatch(/^\d{4}$/)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('vat_rate is consistent with vat_treatment', () => {
|
|
for (const t of BOOKING_TEMPLATES) {
|
|
if (t.vat_treatment === 'standard_25') {
|
|
expect(t.vat_rate).toBe(0.25)
|
|
} else if (t.vat_treatment === 'reduced_12') {
|
|
expect(t.vat_rate).toBe(0.12)
|
|
} else if (t.vat_treatment === 'reduced_6') {
|
|
expect(t.vat_rate).toBe(0.06)
|
|
} else if (t.vat_treatment === 'reverse_charge' || t.vat_treatment === 'export' || t.vat_treatment === 'exempt' || t.vat_treatment === null) {
|
|
expect(t.vat_rate).toBe(0)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Lookup Functions
|
|
// ============================================================
|
|
|
|
describe('getTemplateById', () => {
|
|
it('returns correct template for known ID', () => {
|
|
const t = getTemplateById('it_saas_subscription')
|
|
expect(t).toBeDefined()
|
|
expect(t!.name_sv).toBe('Programvara / SaaS')
|
|
expect(t!.debit_account).toBe('5420')
|
|
})
|
|
|
|
it('returns undefined for unknown ID', () => {
|
|
expect(getTemplateById('nonexistent')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('getTemplatesByGroup', () => {
|
|
it('returns templates for the premises group', () => {
|
|
const templates = getTemplatesByGroup('premises')
|
|
expect(templates.length).toBeGreaterThan(0)
|
|
for (const t of templates) {
|
|
expect(t.group).toBe('premises')
|
|
}
|
|
})
|
|
|
|
it('returns empty array for non-existent group', () => {
|
|
expect(getTemplatesByGroup('nonexistent' as never)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('getTemplatesByMcc', () => {
|
|
it('returns templates for MCC 5541 (fuel)', () => {
|
|
const templates = getTemplatesByMcc(5541)
|
|
expect(templates.length).toBeGreaterThan(0)
|
|
expect(templates.some((t) => t.id === 'vehicle_fuel')).toBe(true)
|
|
})
|
|
|
|
it('returns empty array for unknown MCC', () => {
|
|
expect(getTemplatesByMcc(9999)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('getTemplateGroups', () => {
|
|
it('returns all 17 groups', () => {
|
|
const groups = getTemplateGroups()
|
|
expect(groups).toHaveLength(17)
|
|
for (const g of groups) {
|
|
expect(g.group).toBeTruthy()
|
|
expect(g.label_sv).toBeTruthy()
|
|
expect(g.label_en).toBeTruthy()
|
|
expect(Array.isArray(g.templates)).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('every template is in exactly one group', () => {
|
|
const groups = getTemplateGroups()
|
|
const allTemplates = groups.flatMap((g) => g.templates)
|
|
expect(allTemplates).toHaveLength(51)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Search
|
|
// ============================================================
|
|
|
|
describe('searchTemplates', () => {
|
|
it('finds templates by Swedish name', () => {
|
|
const results = searchTemplates('lokalhyra')
|
|
expect(results.length).toBeGreaterThan(0)
|
|
expect(results.some((t) => t.id === 'premises_rent')).toBe(true)
|
|
})
|
|
|
|
it('finds templates by English name', () => {
|
|
const results = searchTemplates('software')
|
|
expect(results.length).toBeGreaterThan(0)
|
|
expect(results.some((t) => t.id === 'it_saas_subscription')).toBe(true)
|
|
})
|
|
|
|
it('finds templates by keywords', () => {
|
|
const results = searchTemplates('spotify')
|
|
expect(results.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('returns empty for empty query', () => {
|
|
expect(searchTemplates('')).toEqual([])
|
|
})
|
|
|
|
it('filters by entity type', () => {
|
|
const results = searchTemplates('pension', 'enskild_firma')
|
|
// Should include EF-specific and 'all', but not AB-only
|
|
for (const t of results) {
|
|
expect(t.entity_applicability).not.toBe('aktiebolag')
|
|
}
|
|
})
|
|
|
|
it('supports multi-token search', () => {
|
|
const results = searchTemplates('annonsering EU')
|
|
expect(results.some((t) => t.id === 'marketing_online_ads_eu')).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// findMatchingTemplates
|
|
// ============================================================
|
|
|
|
describe('findMatchingTemplates', () => {
|
|
it('matches by MCC code with high confidence', () => {
|
|
const tx = makeTransaction({
|
|
amount: -500,
|
|
mcc_code: 5541,
|
|
description: 'Gas station',
|
|
merchant_name: 'OKQ8',
|
|
})
|
|
const matches = findMatchingTemplates(tx)
|
|
expect(matches.length).toBeGreaterThan(0)
|
|
expect(matches[0].template.id).toBe('vehicle_fuel')
|
|
expect(matches[0].confidence).toBeGreaterThan(0.3)
|
|
})
|
|
|
|
it('matches by keywords in description', () => {
|
|
const tx = makeTransaction({
|
|
amount: -299,
|
|
description: 'Google Ads campaign',
|
|
merchant_name: 'Google',
|
|
})
|
|
const matches = findMatchingTemplates(tx)
|
|
expect(matches.some((m) => m.template.id === 'marketing_online_ads_eu')).toBe(true)
|
|
})
|
|
|
|
it('returns empty for a transaction with no signals', () => {
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
description: 'XYZ123ABC',
|
|
mcc_code: null,
|
|
merchant_name: null,
|
|
})
|
|
const matches = findMatchingTemplates(tx)
|
|
expect(matches).toEqual([])
|
|
})
|
|
|
|
it('filters by entity type', () => {
|
|
const tx = makeTransaction({
|
|
amount: -5000,
|
|
description: 'Löneutbetalning',
|
|
})
|
|
const matches = findMatchingTemplates(tx, 'enskild_firma')
|
|
// Personnel salary is AB-only, should not appear
|
|
for (const m of matches) {
|
|
expect(m.template.entity_applicability).not.toBe('aktiebolag')
|
|
}
|
|
})
|
|
|
|
it('does not match expense templates for positive amounts', () => {
|
|
const tx = makeTransaction({
|
|
amount: 1000,
|
|
description: 'Bensin okq8',
|
|
mcc_code: 5541,
|
|
})
|
|
const matches = findMatchingTemplates(tx)
|
|
// vehicle_fuel is an expense template, should not match positive amount
|
|
expect(matches.every((m) => m.template.direction !== 'expense')).toBe(true)
|
|
})
|
|
|
|
it('returns max 10 results', () => {
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
description: 'software subscription cloud hosting domain',
|
|
mcc_code: 5817,
|
|
})
|
|
const matches = findMatchingTemplates(tx)
|
|
expect(matches.length).toBeLessThanOrEqual(10)
|
|
})
|
|
|
|
it('results are sorted by confidence descending', () => {
|
|
const tx = makeTransaction({
|
|
amount: -999,
|
|
description: 'Google cloud hosting',
|
|
mcc_code: 4816,
|
|
})
|
|
const matches = findMatchingTemplates(tx)
|
|
for (let i = 1; i < matches.length; i++) {
|
|
expect(matches[i - 1].confidence).toBeGreaterThanOrEqual(matches[i].confidence)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// buildMappingResultFromTemplate
|
|
// ============================================================
|
|
|
|
describe('buildMappingResultFromTemplate', () => {
|
|
const getTemplate = (id: string): BookingTemplate => {
|
|
const t = getTemplateById(id)
|
|
if (!t) throw new Error(`Template not found: ${id}`)
|
|
return t
|
|
}
|
|
|
|
it('produces valid MappingResult for expense with 25% VAT', () => {
|
|
const template = getTemplate('it_saas_subscription')
|
|
const tx = makeTransaction({ amount: -1250 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.debit_account).toBe('5420')
|
|
expect(result.credit_account).toBe('1930')
|
|
expect(result.template_id).toBe('it_saas_subscription')
|
|
expect(result.rule).toBeNull()
|
|
expect(result.confidence).toBe(1.0)
|
|
expect(result.vat_lines).toHaveLength(1)
|
|
expect(result.vat_lines[0].account_number).toBe('2641')
|
|
expect(result.vat_lines[0].debit_amount).toBe(250) // 1250 * 0.25 / 1.25 = 250
|
|
})
|
|
|
|
it('produces valid MappingResult for expense with 12% VAT', () => {
|
|
const template = getTemplate('travel_hotel')
|
|
const tx = makeTransaction({ amount: -1120 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.debit_account).toBe('5820')
|
|
expect(result.vat_lines).toHaveLength(1)
|
|
expect(result.vat_lines[0].account_number).toBe('2641')
|
|
expect(result.vat_lines[0].debit_amount).toBe(120) // 1120 * 0.12 / 1.12 = 120
|
|
})
|
|
|
|
it('produces valid MappingResult for expense with 6% VAT', () => {
|
|
const template = getTemplate('travel_transport')
|
|
const tx = makeTransaction({ amount: -530 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.vat_lines).toHaveLength(1)
|
|
expect(result.vat_lines[0].account_number).toBe('2641')
|
|
expect(result.vat_lines[0].debit_amount).toBe(30) // 530 * 0.06 / 1.06 = 30
|
|
})
|
|
|
|
it('produces reverse charge lines for EU purchases', () => {
|
|
const template = getTemplate('it_saas_eu')
|
|
const tx = makeTransaction({ amount: -1000 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.vat_lines).toHaveLength(2)
|
|
// Fiktiv ingående moms
|
|
expect(result.vat_lines[0].account_number).toBe('2645')
|
|
expect(result.vat_lines[0].debit_amount).toBe(250)
|
|
// Fiktiv utgående moms
|
|
expect(result.vat_lines[1].account_number).toBe('2614')
|
|
expect(result.vat_lines[1].credit_amount).toBe(250)
|
|
})
|
|
|
|
it('produces no VAT lines for exempt expenses', () => {
|
|
const template = getTemplate('premises_rent')
|
|
const tx = makeTransaction({ amount: -10000 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.vat_lines).toHaveLength(0)
|
|
})
|
|
|
|
it('produces no VAT lines for non-deductible templates', () => {
|
|
const template = getTemplate('private_withdrawal_ef')
|
|
const tx = makeTransaction({ amount: -5000 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.vat_lines).toHaveLength(0)
|
|
expect(result.default_private).toBe(true)
|
|
})
|
|
|
|
it('produces output VAT lines for income with 25% VAT', () => {
|
|
const template = getTemplate('revenue_standard_25')
|
|
const tx = makeTransaction({ amount: 12500 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.debit_account).toBe('1930')
|
|
expect(result.credit_account).toBe('3001')
|
|
expect(result.vat_lines).toHaveLength(1)
|
|
expect(result.vat_lines[0].account_number).toBe('2611')
|
|
expect(result.vat_lines[0].credit_amount).toBe(2500) // 12500 * 0.25 / 1.25 = 2500
|
|
})
|
|
|
|
it('produces output VAT lines for income with 12% VAT', () => {
|
|
const template = getTemplate('revenue_reduced_12')
|
|
const tx = makeTransaction({ amount: 1120 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.vat_lines).toHaveLength(1)
|
|
expect(result.vat_lines[0].account_number).toBe('2621')
|
|
expect(result.vat_lines[0].credit_amount).toBe(120)
|
|
})
|
|
|
|
it('resolves AB-specific accounts for aktiebolag', () => {
|
|
const template = getTemplate('education_course')
|
|
const tx = makeTransaction({ amount: -5000 })
|
|
|
|
const efResult = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
expect(efResult.debit_account).toBe('6991')
|
|
|
|
const abResult = buildMappingResultFromTemplate(template, tx, 'aktiebolag')
|
|
expect(abResult.debit_account).toBe('7610')
|
|
})
|
|
|
|
it('resolves AB-specific private account', () => {
|
|
const template = getTemplate('private_expense')
|
|
const tx = makeTransaction({ amount: -300 })
|
|
|
|
const efResult = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
expect(efResult.debit_account).toBe('2013')
|
|
|
|
const abResult = buildMappingResultFromTemplate(template, tx, 'aktiebolag')
|
|
expect(abResult.debit_account).toBe('2893')
|
|
})
|
|
|
|
it('includes template_id in the MappingResult', () => {
|
|
const template = getTemplate('bank_fees')
|
|
const tx = makeTransaction({ amount: -49 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.template_id).toBe('bank_fees')
|
|
expect(result.rule).toBeNull()
|
|
})
|
|
|
|
it('sets description with template name and transaction description', () => {
|
|
const template = getTemplate('vehicle_fuel')
|
|
const tx = makeTransaction({ amount: -800, description: 'OKQ8 tankstation' })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.description).toBe('Drivmedel & Laddning: OKQ8 tankstation')
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Template Curation Helpers
|
|
// ============================================================
|
|
|
|
describe('getCommonTemplates', () => {
|
|
it('returns only templates with common: true', () => {
|
|
const common = getCommonTemplates()
|
|
expect(common.length).toBeGreaterThan(0)
|
|
for (const t of common) {
|
|
expect(t.common).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('filters by entity type', () => {
|
|
const efCommon = getCommonTemplates('enskild_firma')
|
|
for (const t of efCommon) {
|
|
expect(t.entity_applicability).not.toBe('aktiebolag')
|
|
}
|
|
})
|
|
|
|
it('filters by direction', () => {
|
|
const expenses = getCommonTemplates(undefined, 'expense')
|
|
for (const t of expenses) {
|
|
expect(t.direction).toBe('expense')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('getAdvancedTemplates', () => {
|
|
it('returns only templates with common: false', () => {
|
|
const advanced = getAdvancedTemplates()
|
|
expect(advanced.length).toBeGreaterThan(0)
|
|
for (const t of advanced) {
|
|
expect(t.common).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('common + advanced = all templates (for a given entity/direction)', () => {
|
|
const common = getCommonTemplates()
|
|
const advanced = getAdvancedTemplates()
|
|
expect(common.length + advanced.length).toBe(BOOKING_TEMPLATES.length)
|
|
})
|
|
})
|
|
|
|
describe('validateTemplateForEntity', () => {
|
|
it('accepts template with entity_applicability "all"', () => {
|
|
const template = getTemplateById('premises_rent')!
|
|
const result = validateTemplateForEntity(template, 'aktiebolag')
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('accepts EF template for EF entity', () => {
|
|
const template = getTemplateById('private_withdrawal_ef')!
|
|
const result = validateTemplateForEntity(template, 'enskild_firma')
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('rejects EF template for AB entity', () => {
|
|
const template = getTemplateById('private_withdrawal_ef')!
|
|
const result = validateTemplateForEntity(template, 'aktiebolag')
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('enskild_firma')
|
|
})
|
|
|
|
it('rejects AB template for EF entity', () => {
|
|
const template = getTemplateById('personnel_salary')!
|
|
const result = validateTemplateForEntity(template, 'enskild_firma')
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('aktiebolag')
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// New/Split Templates
|
|
// ============================================================
|
|
|
|
describe('new and split templates', () => {
|
|
it('has marketing_online_ads_eu with reverse_charge', () => {
|
|
const t = getTemplateById('marketing_online_ads_eu')
|
|
expect(t).toBeDefined()
|
|
expect(t!.vat_treatment).toBe('reverse_charge')
|
|
expect(t!.common).toBe(true)
|
|
expect(t!.requires_vat_registration_data).toBe(true)
|
|
})
|
|
|
|
it('has marketing_online_ads_domestic with standard_25', () => {
|
|
const t = getTemplateById('marketing_online_ads_domestic')
|
|
expect(t).toBeDefined()
|
|
expect(t!.vat_treatment).toBe('standard_25')
|
|
expect(t!.common).toBe(false)
|
|
})
|
|
|
|
it('has representation_internal with account 7622', () => {
|
|
const t = getTemplateById('representation_internal')
|
|
expect(t).toBeDefined()
|
|
expect(t!.debit_account).toBe('7622')
|
|
expect(t!.vat_treatment).toBeNull()
|
|
expect(t!.common).toBe(true)
|
|
})
|
|
|
|
it('has shareholder_loan_received (AB, D:1930 K:2393)', () => {
|
|
const t = getTemplateById('shareholder_loan_received')
|
|
expect(t).toBeDefined()
|
|
expect(t!.debit_account).toBe('1930')
|
|
expect(t!.credit_account).toBe('2393')
|
|
expect(t!.entity_applicability).toBe('aktiebolag')
|
|
expect(t!.common).toBe(true)
|
|
})
|
|
|
|
it('has shareholder_loan_disbursed (AB, D:1680 K:1930)', () => {
|
|
const t = getTemplateById('shareholder_loan_disbursed')
|
|
expect(t).toBeDefined()
|
|
expect(t!.debit_account).toBe('1680')
|
|
expect(t!.credit_account).toBe('1930')
|
|
expect(t!.entity_applicability).toBe('aktiebolag')
|
|
expect(t!.common).toBe(false)
|
|
})
|
|
|
|
it('personnel_employer_tax uses debit account 2731 (liability clearing)', () => {
|
|
const t = getTemplateById('personnel_employer_tax')
|
|
expect(t).toBeDefined()
|
|
expect(t!.debit_account).toBe('2731')
|
|
})
|
|
|
|
it('personnel_salary has special_rules_sv warning', () => {
|
|
const t = getTemplateById('personnel_salary')
|
|
expect(t).toBeDefined()
|
|
expect(t!.special_rules_sv).toContain('nettolön')
|
|
expect(t!.requires_review).toBe(true)
|
|
})
|
|
|
|
it('representation_external has updated deductibility note with VAT cap', () => {
|
|
const t = getTemplateById('representation_external')
|
|
expect(t).toBeDefined()
|
|
expect(t!.deductibility_note_sv).toContain('46 kr/person')
|
|
})
|
|
})
|