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') }) })