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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user