a9bf24ce0b
Expand supplier invoice module with overdue cron job, credit note journal entries, and event emissions on approve/mark-paid/create flows. Add entity type (EF/AB) awareness to transaction categorization UI and category mapping logic. Add comprehensive tests for supplier-invoice-entries, transaction-entries, and expanded API route coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
getCategoryAccountMapping,
|
|
getExpenseAccountForCategory,
|
|
getDefaultAccountForCategory,
|
|
getDefaultVatTreatmentForCategory,
|
|
buildMappingResultFromCategory,
|
|
} from '../category-mapping'
|
|
import { makeTransaction } from '@/tests/helpers'
|
|
import type { TransactionCategory, VatTreatment } from '@/types'
|
|
|
|
describe('getCategoryAccountMapping', () => {
|
|
describe('income_products uses correct account', () => {
|
|
it('maps income_products to 3001 (25% moms)', () => {
|
|
const result = getCategoryAccountMapping('income_products', 1000, true)
|
|
expect(result.creditAccount).toBe('3001')
|
|
})
|
|
|
|
it('income_products matches income_services account', () => {
|
|
const products = getCategoryAccountMapping('income_products', 1000, true)
|
|
const services = getCategoryAccountMapping('income_services', 1000, true)
|
|
expect(products.creditAccount).toBe(services.creditAccount)
|
|
})
|
|
})
|
|
|
|
describe('expense_office maps to 6110 (Kontorsförbrukning)', () => {
|
|
it('maps expense_office to 6110 (not 5010 Lokalhyra)', () => {
|
|
const result = getCategoryAccountMapping('expense_office', -500, true)
|
|
expect(result.debitAccount).toBe('6110')
|
|
})
|
|
})
|
|
|
|
describe('expense_education entity-type-aware', () => {
|
|
it('defaults to 6991 for enskild_firma', () => {
|
|
const result = getCategoryAccountMapping('expense_education', -500, true, 'enskild_firma')
|
|
expect(result.debitAccount).toBe('6991')
|
|
})
|
|
|
|
it('uses 7610 for aktiebolag', () => {
|
|
const result = getCategoryAccountMapping('expense_education', -500, true, 'aktiebolag')
|
|
expect(result.debitAccount).toBe('7610')
|
|
})
|
|
|
|
it('defaults to 6991 when no entityType provided', () => {
|
|
const result = getCategoryAccountMapping('expense_education', -500, true)
|
|
expect(result.debitAccount).toBe('6991')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('getExpenseAccountForCategory', () => {
|
|
it('returns null for non-expense categories', () => {
|
|
expect(getExpenseAccountForCategory('income_services')).toBeNull()
|
|
})
|
|
|
|
it('returns correct accounts for expense categories', () => {
|
|
expect(getExpenseAccountForCategory('expense_equipment')).toBe('5410')
|
|
expect(getExpenseAccountForCategory('expense_office')).toBe('6110')
|
|
expect(getExpenseAccountForCategory('expense_bank_fees')).toBe('6570')
|
|
})
|
|
})
|
|
|
|
describe('getDefaultAccountForCategory', () => {
|
|
it('returns expense account for expense categories', () => {
|
|
expect(getDefaultAccountForCategory('expense_equipment')).toBe('5410')
|
|
expect(getDefaultAccountForCategory('expense_software')).toBe('5420')
|
|
expect(getDefaultAccountForCategory('expense_travel')).toBe('5800')
|
|
expect(getDefaultAccountForCategory('expense_office')).toBe('6110')
|
|
expect(getDefaultAccountForCategory('expense_bank_fees')).toBe('6570')
|
|
})
|
|
|
|
it('returns income account for income categories', () => {
|
|
expect(getDefaultAccountForCategory('income_services')).toBe('3001')
|
|
expect(getDefaultAccountForCategory('income_products')).toBe('3001')
|
|
expect(getDefaultAccountForCategory('income_other')).toBe('3900')
|
|
})
|
|
|
|
it('returns private account for enskild firma', () => {
|
|
expect(getDefaultAccountForCategory('private', 'enskild_firma')).toBe('2013')
|
|
})
|
|
|
|
it('returns private account for aktiebolag', () => {
|
|
expect(getDefaultAccountForCategory('private', 'aktiebolag')).toBe('2893')
|
|
})
|
|
|
|
it('returns entity-specific education account', () => {
|
|
expect(getDefaultAccountForCategory('expense_education', 'enskild_firma')).toBe('6991')
|
|
expect(getDefaultAccountForCategory('expense_education', 'aktiebolag')).toBe('7610')
|
|
})
|
|
|
|
it('returns fallback for uncategorized', () => {
|
|
expect(getDefaultAccountForCategory('uncategorized')).toBe('6991')
|
|
})
|
|
})
|
|
|
|
describe('buildMappingResultFromCategory', () => {
|
|
describe('reverse charge handling', () => {
|
|
it('generates fiktiv moms lines for reverse charge expense', () => {
|
|
const tx = makeTransaction({ amount: -1000 })
|
|
const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'reverse_charge')
|
|
|
|
expect(result.vat_lines).toHaveLength(2)
|
|
|
|
const debitLine = result.vat_lines.find((l) => l.account_number === '2645')
|
|
expect(debitLine).toBeDefined()
|
|
expect(debitLine!.debit_amount).toBe(250)
|
|
expect(debitLine!.credit_amount).toBe(0)
|
|
|
|
const creditLine = result.vat_lines.find((l) => l.account_number === '2614')
|
|
expect(creditLine).toBeDefined()
|
|
expect(creditLine!.debit_amount).toBe(0)
|
|
expect(creditLine!.credit_amount).toBe(250)
|
|
})
|
|
|
|
it('does not generate regular input VAT (2641) for reverse charge', () => {
|
|
const tx = makeTransaction({ amount: -1000 })
|
|
const result = buildMappingResultFromCategory('expense_equipment', tx, true, 'enskild_firma', 'reverse_charge')
|
|
|
|
const hasRegularVat = result.vat_lines.some((l) => l.account_number === '2641')
|
|
expect(hasRegularVat).toBe(false)
|
|
})
|
|
|
|
it('does not generate VAT lines for reverse charge on income', () => {
|
|
const tx = makeTransaction({ amount: 1000 })
|
|
const result = buildMappingResultFromCategory('income_services', tx, true, 'enskild_firma', 'reverse_charge')
|
|
|
|
expect(result.vat_lines).toHaveLength(0)
|
|
})
|
|
|
|
it('does not generate VAT lines for reverse charge on private transactions', () => {
|
|
const tx = makeTransaction({ amount: -1000 })
|
|
const result = buildMappingResultFromCategory('expense_software', tx, false, 'enskild_firma', 'reverse_charge')
|
|
|
|
expect(result.vat_lines).toHaveLength(0)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('buildMappingResultFromCategory returns non-empty accounts', () => {
|
|
const allCategories: TransactionCategory[] = [
|
|
'income_services',
|
|
'income_products',
|
|
'income_other',
|
|
'expense_equipment',
|
|
'expense_software',
|
|
'expense_travel',
|
|
'expense_office',
|
|
'expense_marketing',
|
|
'expense_professional_services',
|
|
'expense_education',
|
|
'expense_bank_fees',
|
|
'expense_card_fees',
|
|
'expense_currency_exchange',
|
|
'expense_other',
|
|
'private',
|
|
'uncategorized',
|
|
]
|
|
|
|
it.each(allCategories)('returns non-empty debit_account and credit_account for "%s"', (category) => {
|
|
const tx = makeTransaction({ amount: category.startsWith('income') ? 1000 : -1000 })
|
|
const isBusiness = category !== 'private'
|
|
const result = buildMappingResultFromCategory(category, tx, isBusiness)
|
|
|
|
expect(result.debit_account).toBeTruthy()
|
|
expect(result.credit_account).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('getDefaultVatTreatmentForCategory', () => {
|
|
it('returns standard_25 for regular expense categories', () => {
|
|
expect(getDefaultVatTreatmentForCategory('expense_equipment')).toBe('standard_25')
|
|
expect(getDefaultVatTreatmentForCategory('expense_software')).toBe('standard_25')
|
|
expect(getDefaultVatTreatmentForCategory('expense_travel')).toBe('standard_25')
|
|
})
|
|
|
|
it('returns standard_25 for income categories', () => {
|
|
expect(getDefaultVatTreatmentForCategory('income_services')).toBe('standard_25')
|
|
expect(getDefaultVatTreatmentForCategory('income_products')).toBe('standard_25')
|
|
})
|
|
|
|
it('returns null for VAT-exempt categories', () => {
|
|
expect(getDefaultVatTreatmentForCategory('expense_bank_fees')).toBeNull()
|
|
expect(getDefaultVatTreatmentForCategory('expense_card_fees')).toBeNull()
|
|
expect(getDefaultVatTreatmentForCategory('expense_currency_exchange')).toBeNull()
|
|
expect(getDefaultVatTreatmentForCategory('expense_representation')).toBeNull()
|
|
})
|
|
|
|
it('returns null for private transactions', () => {
|
|
expect(getDefaultVatTreatmentForCategory('private')).toBeNull()
|
|
})
|
|
|
|
it('returns null for uncategorized', () => {
|
|
expect(getDefaultVatTreatmentForCategory('uncategorized')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('representation VAT (ML 8:9 — illegal since 2017)', () => {
|
|
it('getDefaultVatTreatmentForCategory returns null for representation', () => {
|
|
expect(getDefaultVatTreatmentForCategory('expense_representation')).toBeNull()
|
|
})
|
|
|
|
it('getCategoryAccountMapping has vatTreatment: null for representation', () => {
|
|
const result = getCategoryAccountMapping('expense_representation', -500, true)
|
|
expect(result.vatTreatment).toBeNull()
|
|
expect(result.vatDebitAccount).toBeNull()
|
|
})
|
|
|
|
it('buildMappingResultFromCategory generates no VAT lines for representation', () => {
|
|
const tx = makeTransaction({ amount: -500 })
|
|
const result = buildMappingResultFromCategory('expense_representation', tx, true)
|
|
expect(result.vat_lines).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('income account resolves by VAT treatment', () => {
|
|
const cases: [VatTreatment, string][] = [
|
|
['standard_25', '3001'],
|
|
['reduced_12', '3002'],
|
|
['reduced_6', '3003'],
|
|
['export', '3305'],
|
|
['reverse_charge', '3308'],
|
|
['exempt', '3004'],
|
|
]
|
|
|
|
it.each(cases)('income_services with %s maps to %s', (vat, expectedAccount) => {
|
|
const result = getCategoryAccountMapping('income_services', 1000, true, 'enskild_firma', vat)
|
|
expect(result.creditAccount).toBe(expectedAccount)
|
|
})
|
|
|
|
it.each(cases)('income_products with %s maps to %s', (vat, expectedAccount) => {
|
|
const result = getCategoryAccountMapping('income_products', 1000, true, 'enskild_firma', vat)
|
|
expect(result.creditAccount).toBe(expectedAccount)
|
|
})
|
|
|
|
it('income_other always returns 3900 regardless of VAT treatment', () => {
|
|
for (const vat of ['standard_25', 'reduced_12', 'reduced_6', 'export', 'reverse_charge', 'exempt'] as VatTreatment[]) {
|
|
const result = getCategoryAccountMapping('income_other', 1000, true, 'enskild_firma', vat)
|
|
expect(result.creditAccount).toBe('3900')
|
|
}
|
|
})
|
|
|
|
it('defaults to 3001 when no vatTreatment provided', () => {
|
|
const result = getCategoryAccountMapping('income_services', 1000, true)
|
|
expect(result.creditAccount).toBe('3001')
|
|
})
|
|
})
|
|
|
|
describe('private transaction accounts by entity type and direction', () => {
|
|
it('EF withdrawal (amount < 0) uses 2013', () => {
|
|
const result = getCategoryAccountMapping('private', -500, false, 'enskild_firma')
|
|
expect(result.debitAccount).toBe('2013')
|
|
expect(result.creditAccount).toBe('1930')
|
|
})
|
|
|
|
it('EF deposit (amount > 0) uses 2018', () => {
|
|
const result = getCategoryAccountMapping('private', 500, false, 'enskild_firma')
|
|
expect(result.debitAccount).toBe('1930')
|
|
expect(result.creditAccount).toBe('2018')
|
|
})
|
|
|
|
it('AB uses 2893 for both withdrawal and deposit', () => {
|
|
const withdrawal = getCategoryAccountMapping('private', -500, false, 'aktiebolag')
|
|
expect(withdrawal.debitAccount).toBe('2893')
|
|
|
|
const deposit = getCategoryAccountMapping('private', 500, false, 'aktiebolag')
|
|
expect(deposit.creditAccount).toBe('2893')
|
|
})
|
|
|
|
it('getDefaultAccountForCategory still returns 2013 for EF (default/withdrawal account)', () => {
|
|
expect(getDefaultAccountForCategory('private', 'enskild_firma')).toBe('2013')
|
|
})
|
|
})
|