fix: validate account mappings and handle reverse charge in categorization

Add validation for non-empty debit/credit accounts before creating journal
entries. Generate fiktiv moms lines (2645/2614) for EU reverse charge expenses.
Change default unmapped expense account from 6900 to 6991. Add tests for
reverse charge handling and exhaustive category mapping coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Emil
2026-02-23 12:58:50 +01:00
co-authored by Claude Opus 4.6
parent 3c036059ab
commit 2f953c1031
6 changed files with 133 additions and 4 deletions
@@ -228,6 +228,33 @@ describe('POST /api/transactions/[id]/categorize', () => {
expect(body.error).toBe('Failed to update transaction')
})
it('returns 400 when mapping result has empty debit_account', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
journal_entry_id: null,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
mockBuildMappingResultFromCategory.mockReturnValue({
...defaultMappingResult,
debit_account: '',
})
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: true, category: 'expense_software' },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Invalid account mapping: debit and credit accounts are required')
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
})
it('categorizes as private when is_business is false', async () => {
const tx = makeTransaction({
id: 'tx-1',
@@ -199,6 +199,14 @@ export async function POST(
}
}
// Validate that both accounts are present before proceeding
if (!mappingResult.debit_account || !mappingResult.credit_account) {
return NextResponse.json(
{ error: 'Invalid account mapping: debit and credit accounts are required' },
{ status: 400 }
)
}
// Ensure fiscal period exists for the transaction date
await ensureFiscalPeriod(supabase, user.id, transaction.date, fiscalYearStartMonth)
@@ -4,7 +4,10 @@ import {
getExpenseAccountForCategory,
getDefaultAccountForCategory,
getDefaultVatTreatmentForCategory,
buildMappingResultFromCategory,
} from '../category-mapping'
import { makeTransaction } from '@/tests/helpers'
import type { TransactionCategory } from '@/types'
describe('getCategoryAccountMapping', () => {
describe('income_products uses correct account', () => {
@@ -81,6 +84,79 @@ describe('getDefaultAccountForCategory', () => {
})
})
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')
+14 -2
View File
@@ -1,5 +1,5 @@
import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType, VatTreatment } from '@/types'
import { getVatRate } from './vat-entries'
import { getVatRate, generateReverseChargeLines } from './vat-entries'
/**
* Maps TransactionCategory to BAS accounts for journal entry creation
@@ -176,7 +176,19 @@ export function buildMappingResultFromCategory(
const treatment = mapping.vatTreatment as VatTreatment | null
if (isBusiness && treatment) {
const vatRate = getVatRate(treatment)
if (vatRate > 0) {
if (treatment === 'reverse_charge' && transaction.amount < 0) {
// EU reverse charge: fiktiv moms (offsetting entries)
const absAmount = Math.abs(transaction.amount)
const rcLines = generateReverseChargeLines(absAmount)
for (const rcl of rcLines) {
vatLines.push({
account_number: rcl.account_number,
debit_amount: rcl.debit_amount,
credit_amount: rcl.credit_amount,
description: rcl.line_description || '',
})
}
} else if (vatRate > 0) {
const grossAmount = Math.abs(transaction.amount)
const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100
+2 -2
View File
@@ -114,7 +114,7 @@ function buildResult(rule: MappingRule, transaction: Transaction): MappingResult
const absAmount = Math.abs(transaction.amount)
const isExpense = transaction.amount < 0
let debitAccount = rule.debit_account || (isExpense ? '6900' : '1930')
let debitAccount = rule.debit_account || (isExpense ? '6991' : '1930')
let creditAccount = rule.credit_account || (isExpense ? '1930' : '3001')
// Check capitalization threshold for equipment
@@ -183,7 +183,7 @@ function getDefaultResult(transaction: Transaction): MappingResult {
return {
rule: null,
debit_account: isExpense ? '6900' : '1930',
debit_account: isExpense ? '6991' : '1930',
credit_account: isExpense ? '1930' : '3001',
risk_level: 'MEDIUM',
confidence: 0.1,
+6
View File
@@ -39,6 +39,12 @@ export async function createTransactionJournalEntry(
transaction: Transaction,
mappingResult: MappingResult
): Promise<JournalEntry | null> {
if (!mappingResult.debit_account || !mappingResult.credit_account) {
throw new Error(
`Invalid mapping result: debit_account="${mappingResult.debit_account}", credit_account="${mappingResult.credit_account}". Both must be non-empty.`
)
}
const fiscalPeriodId = await findFiscalPeriod(userId, transaction.date)
if (!fiscalPeriodId) {
console.warn('No open fiscal period found for transaction date:', transaction.date)