* fix(currency): use transaction-date FX rate and post mall bookings in SEK (#442) Three related defects in non-SEK transaction handling: 1. Ingest priced every transaction in a batch at today's rate because fetchMultipleRates() was called without a date. Switch to a per- (currency, date) fetch via fetchExchangeRate() + Promise.allSettled, so each row stores Riksbanken's rate as of its own transaction date. 2. buildMappingResultFromTemplate emitted VAT/reverse-charge/output-VAT lines from Math.abs(transaction.amount) — the source-currency amount. Mall bookings on USD/EUR transactions therefore posted the journal entry in the wrong currency. Resolve to SEK once via resolveSekAmount() and reuse for every line, matching what createTransactionJournalEntry already does for the settlement line. 3. The Granska bokföring modal and the journal-entry detail page lacked a clear audit trail. The verifikation preview now renders in SEK with a separate original-currency subtitle in the header; the detail page gains a dedicated "Valutaomräkning" card above Kontorader showing the rate and original total. Also: replace truncate with break-all on the modal description so long UUID-style strings (e.g. BALANCE_CASHBACK-…) wrap instead of forcing horizontal overscroll. Tests: 4 new booking-templates cases (USD 25%, EUR fallback via exchange_rate, EU reverse-charge on USD, output VAT on USD income) and 2 new ingest cases (per-pair fetch dedup; no fetch for SEK rows). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(currency): wire SEK conversion into SwipeCategorizationView (#442 follow-up) Address Greptile review: - SwipeCategorizationView rendered JournalEntryPreview without amountSek, so the swipe flow had the same defect QuickReviewDialog had — a USD transaction showed the verifikation in source currency labeled SEK. Now resolves SEK once via resolveSekAmount, passes it to the preview, and shows SEK as the headline with the original-currency subtitle (parallel to QuickReviewDialog). - Drop the now-dead `currency` prop from JournalEntryPreviewProps and its remaining call site in QuickReviewDialog — the component hardcodes 'SEK' on display, so the prop was misleading. - Correct the misleading inline comment in ingest.ts: network failures resolve inside fetchExchangeRate to getFallbackRate() (non-null), so the key only stays unset on empty-observation responses or outright rejections. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
679 lines
24 KiB
TypeScript
679 lines
24 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 (fiktiv moms + basbelopp)', () => {
|
|
const template = getTemplate('it_saas_eu')
|
|
const tx = makeTransaction({ amount: -1000 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
// Four lines: fiktiv-moms pair + basbelopp pair. Without the basbelopp
|
|
// pair the deklaration is rejected with FK004 (ruta 30-32 without 20-24).
|
|
expect(result.vat_lines).toHaveLength(4)
|
|
// Fiktiv ingående moms (EU: 2645)
|
|
expect(result.vat_lines[0].account_number).toBe('2645')
|
|
expect(result.vat_lines[0].debit_amount).toBe(250)
|
|
// Fiktiv utgående moms (25%: 2614)
|
|
expect(result.vat_lines[1].account_number).toBe('2614')
|
|
expect(result.vat_lines[1].credit_amount).toBe(250)
|
|
// Basbelopp EU services 25% → ruta 21
|
|
expect(result.vat_lines[2].account_number).toBe('4535')
|
|
expect(result.vat_lines[2].debit_amount).toBe(1000)
|
|
// Motkonto basbelopp
|
|
expect(result.vat_lines[3].account_number).toBe('4598')
|
|
expect(result.vat_lines[3].credit_amount).toBe(1000)
|
|
})
|
|
|
|
it('defaults to eu_business supplier type when not set on template', () => {
|
|
// it_cloud_hosting has no explicit reverse_charge_supplier_type
|
|
const template = getTemplate('it_cloud_hosting')
|
|
const tx = makeTransaction({ amount: -800 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.vat_lines).toHaveLength(4)
|
|
// Defaults to EU services → 4535
|
|
expect(result.vat_lines[2].account_number).toBe('4535')
|
|
expect(result.vat_lines[2].debit_amount).toBe(800)
|
|
})
|
|
|
|
it('uses 4531 basbelopp for non-EU supplier type', () => {
|
|
const template: BookingTemplate = {
|
|
...getTemplate('it_cloud_hosting'),
|
|
reverse_charge_supplier_type: 'non_eu_business',
|
|
}
|
|
const tx = makeTransaction({ amount: -1000 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.vat_lines).toHaveLength(4)
|
|
expect(result.vat_lines[0].account_number).toBe('2645') // non-EU still uses 2645
|
|
expect(result.vat_lines[2].account_number).toBe('4531') // non-EU services → ruta 22
|
|
})
|
|
|
|
it('uses 4425 basbelopp and 2647 for domestic (swedish) reverse charge', () => {
|
|
const template: BookingTemplate = {
|
|
...getTemplate('it_cloud_hosting'),
|
|
reverse_charge_supplier_type: 'swedish_business',
|
|
}
|
|
const tx = makeTransaction({ amount: -1000 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
expect(result.vat_lines).toHaveLength(4)
|
|
// Domestic RC uses 2647 (ML 16 kap) for the input pair
|
|
expect(result.vat_lines[0].account_number).toBe('2647')
|
|
// Domestic services (byggtjänster) → 4425, ruta 24
|
|
expect(result.vat_lines[2].account_number).toBe('4425')
|
|
})
|
|
|
|
it('skips basbelopp emission when template already debits a basis account', () => {
|
|
const template: BookingTemplate = {
|
|
...getTemplate('it_cloud_hosting'),
|
|
debit_account: '4535', // user-customized template that books directly to basis
|
|
}
|
|
const tx = makeTransaction({ amount: -1000 })
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
// Only the fiktiv-moms pair — basbelopp would double-count.
|
|
expect(result.vat_lines).toHaveLength(2)
|
|
expect(result.vat_lines[0].account_number).toBe('2645')
|
|
expect(result.vat_lines[1].account_number).toBe('2614')
|
|
})
|
|
|
|
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')
|
|
})
|
|
|
|
// Foreign-currency transactions: the mall must always emit SEK amounts
|
|
// (issue #442). Previously buildMappingResultFromTemplate used
|
|
// Math.abs(transaction.amount) — which is in the source currency — to
|
|
// compute VAT lines, producing a verifikation in mixed currencies.
|
|
it('emits SEK amounts when transaction currency is USD (issue #442)', () => {
|
|
const template = getTemplate('it_saas_subscription') // 25% input VAT
|
|
const tx = makeTransaction({
|
|
amount: -125, // -125 USD
|
|
currency: 'USD',
|
|
amount_sek: -1250, // pre-converted to SEK
|
|
exchange_rate: 10,
|
|
})
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
// VAT line debit must be 250 SEK (1250 * 0.25 / 1.25), not 25 USD
|
|
expect(result.vat_lines).toHaveLength(1)
|
|
expect(result.vat_lines[0].account_number).toBe('2641')
|
|
expect(result.vat_lines[0].debit_amount).toBe(250)
|
|
})
|
|
|
|
it('falls back to amount * exchange_rate when amount_sek is missing', () => {
|
|
const template = getTemplate('it_saas_subscription')
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
currency: 'EUR',
|
|
amount_sek: null,
|
|
exchange_rate: 11.5,
|
|
})
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
// 100 * 11.5 = 1150 SEK; 1150 * 0.25 / 1.25 = 230 SEK
|
|
expect(result.vat_lines[0].debit_amount).toBe(230)
|
|
})
|
|
|
|
it('emits SEK amounts for EU reverse-charge on a non-SEK transaction', () => {
|
|
const template = getTemplate('it_saas_eu')
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
currency: 'USD',
|
|
amount_sek: -1000,
|
|
exchange_rate: 10,
|
|
})
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
// Fiktiv-moms pair sized at 25% of 1000 SEK = 250, not 25 USD
|
|
expect(result.vat_lines).toHaveLength(4)
|
|
expect(result.vat_lines[0].debit_amount).toBe(250) // 2645
|
|
expect(result.vat_lines[1].credit_amount).toBe(250) // 2614
|
|
expect(result.vat_lines[2].debit_amount).toBe(1000) // 4535 basbelopp
|
|
expect(result.vat_lines[3].credit_amount).toBe(1000) // 4598
|
|
})
|
|
|
|
it('emits SEK amounts for output VAT on non-SEK income', () => {
|
|
const template = getTemplate('revenue_standard_25')
|
|
const tx = makeTransaction({
|
|
amount: 100,
|
|
currency: 'USD',
|
|
amount_sek: 1000,
|
|
exchange_rate: 10,
|
|
})
|
|
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
|
|
|
// Output VAT at 25% on 1000 SEK = 200, not 20 USD
|
|
expect(result.vat_lines).toHaveLength(1)
|
|
expect(result.vat_lines[0].account_number).toBe('2611')
|
|
expect(result.vat_lines[0].credit_amount).toBe(200)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// 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')
|
|
})
|
|
})
|