From 2f953c10310006012835d016646029163ff8b228 Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 23 Feb 2026 12:58:50 +0100 Subject: [PATCH] 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 --- .../[id]/categorize/__tests__/route.test.ts | 27 +++++++ app/api/transactions/[id]/categorize/route.ts | 8 ++ .../__tests__/category-mapping.test.ts | 76 +++++++++++++++++++ lib/bookkeeping/category-mapping.ts | 16 +++- lib/bookkeeping/mapping-engine.ts | 4 +- lib/bookkeeping/transaction-entries.ts | 6 ++ 6 files changed, 133 insertions(+), 4 deletions(-) diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 35b2c1d7..a4eff82b 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -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', diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 3ce1dec4..b6abf39e 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -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) diff --git a/lib/bookkeeping/__tests__/category-mapping.test.ts b/lib/bookkeeping/__tests__/category-mapping.test.ts index 2ffa158f..3e05b61d 100644 --- a/lib/bookkeeping/__tests__/category-mapping.test.ts +++ b/lib/bookkeeping/__tests__/category-mapping.test.ts @@ -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') diff --git a/lib/bookkeeping/category-mapping.ts b/lib/bookkeeping/category-mapping.ts index f40c89c9..cbbed14d 100644 --- a/lib/bookkeeping/category-mapping.ts +++ b/lib/bookkeeping/category-mapping.ts @@ -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 diff --git a/lib/bookkeeping/mapping-engine.ts b/lib/bookkeeping/mapping-engine.ts index 733e2c2b..37fed2e0 100644 --- a/lib/bookkeeping/mapping-engine.ts +++ b/lib/bookkeeping/mapping-engine.ts @@ -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, diff --git a/lib/bookkeeping/transaction-entries.ts b/lib/bookkeeping/transaction-entries.ts index 18d0c008..411601ad 100644 --- a/lib/bookkeeping/transaction-entries.ts +++ b/lib/bookkeeping/transaction-entries.ts @@ -39,6 +39,12 @@ export async function createTransactionJournalEntry( transaction: Transaction, mappingResult: MappingResult ): Promise { + 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)