From 5279dfb494a311d22b28299c87795b838560c0ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Flod=C3=A9n?= Date: Thu, 25 Jun 2026 12:40:01 +0200 Subject: [PATCH] fix(bookkeeping): gnubok_categorize_transaction now handles incoming expense refunds correctly (#761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incoming expense refunds (positive amount on an expense category) previously booked with inverted debit/credit — crediting the bank and debiting the expense account — which both imbalanced the book and reported negative bank flow. getCategoryAccountMapping now detects amount > 0 on expense categories and returns the reversed mapping: debit 1930, credit expense account, with 2641 as vatCreditAccount so ingående moms is correctly reversed on the VAT line. The VAT line description is "Återföring ingående moms X%" rather than the income-side "Utgående moms" label. Signed-off-by: Jonas Flodén Co-authored-by: Claude Sonnet 4.6 --- .../__tests__/category-mapping.test.ts | 58 +++++++++++++++++++ lib/bookkeeping/category-mapping.ts | 20 ++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/lib/bookkeeping/__tests__/category-mapping.test.ts b/lib/bookkeeping/__tests__/category-mapping.test.ts index 13053cd9..47815646 100644 --- a/lib/bookkeeping/__tests__/category-mapping.test.ts +++ b/lib/bookkeeping/__tests__/category-mapping.test.ts @@ -381,6 +381,64 @@ describe('private transaction accounts by entity type and direction', () => { }) }) +describe('incoming expense refund (positive amount, expense category)', () => { + it('getCategoryAccountMapping swaps accounts: bank debited, expense account credited', () => { + const result = getCategoryAccountMapping('expense_software', 500, true) + expect(result.debitAccount).toBe('1930') + expect(result.creditAccount).toBe('5420') + }) + + it('getCategoryAccountMapping sets vatCreditAccount 2641 and clears vatDebitAccount for refund', () => { + const result = getCategoryAccountMapping('expense_software', 500, true) + expect(result.vatDebitAccount).toBeNull() + expect(result.vatCreditAccount).toBe('2641') + }) + + it('VAT-exempt expense refund (bank_fees) has no VAT accounts', () => { + const result = getCategoryAccountMapping('expense_bank_fees', 100, true) + expect(result.debitAccount).toBe('1930') + expect(result.creditAccount).toBe('6570') + expect(result.vatDebitAccount).toBeNull() + expect(result.vatCreditAccount).toBeNull() + }) + + it('buildMappingResultFromCategory generates credit line on 2641 for expense refund', () => { + const tx = makeTransaction({ amount: 1000 }) + const result = buildMappingResultFromCategory('expense_software', tx, true) + expect(result.vat_lines).toHaveLength(1) + expect(result.vat_lines[0].account_number).toBe('2641') + expect(result.vat_lines[0].credit_amount).toBe(200) + expect(result.vat_lines[0].debit_amount).toBe(0) + }) + + it('buildMappingResultFromCategory uses återföring description for expense refund VAT', () => { + const tx = makeTransaction({ amount: 1000 }) + const result = buildMappingResultFromCategory('expense_software', tx, true) + expect(result.vat_lines[0].description).toBe('Återföring ingående moms 25%') + }) + + it('buildMappingResultFromCategory generates no VAT line for VAT-exempt expense refund', () => { + const tx = makeTransaction({ amount: 100 }) + const result = buildMappingResultFromCategory('expense_bank_fees', tx, true) + expect(result.vat_lines).toHaveLength(0) + }) + + it('buildMappingResultFromCategory maps debit/credit correctly (bank debited, expense credited)', () => { + const tx = makeTransaction({ amount: 1250 }) + const result = buildMappingResultFromCategory('expense_software', tx, true) + expect(result.debit_account).toBe('1930') + expect(result.credit_account).toBe('5420') + }) + + it('vat_amount override on expense refund uses återföring description', () => { + const tx = makeTransaction({ amount: 1250 }) + const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'standard_25', 200) + expect(result.vat_lines).toHaveLength(1) + expect(result.vat_lines[0].credit_amount).toBe(200) + expect(result.vat_lines[0].description).toBe('Återföring ingående moms (enligt underlag)') + }) +}) + describe('category default → leaf account guarantee', () => { // BAS encodes the parent/leaf distinction in account_name via the // "(gruppkonto)" suffix. Auditors and Skatteverket downstream reporting diff --git a/lib/bookkeeping/category-mapping.ts b/lib/bookkeeping/category-mapping.ts index 73e6d4c0..e34e920b 100644 --- a/lib/bookkeeping/category-mapping.ts +++ b/lib/bookkeeping/category-mapping.ts @@ -141,6 +141,18 @@ export function getCategoryAccountMapping( // Note: income tax deduction was abolished 2017 (IL 16 kap 2 §), but VAT deduction remains. const resolvedVat = vatTreatment ?? (isVatExempt ? null : category === 'expense_representation' ? 'reduced_12' : 'standard_25') + if (amount > 0) { + // Incoming refund: bank receives money, expense account is reduced (credited). + // Ingående moms is reversed — credit 2641 instead of debit. + return { + debitAccount: BANK_ACCOUNT, + creditAccount: expenseAccount, + vatTreatment: resolvedVat, + vatDebitAccount: null, + vatCreditAccount: resolvedVat ? '2641' : null, + } + } + return { debitAccount: expenseAccount, creditAccount: BANK_ACCOUNT, @@ -291,12 +303,16 @@ export function buildMappingResultFromCategory( description: hasVatOverride ? 'Ingående moms (enligt underlag)' : `Ingående moms ${vatRate * 100}%`, }) } else if (vatAmount > 0 && transaction.amount > 0 && mapping.vatCreditAccount) { - // Income: Utgående moms (output VAT) + // Income output VAT, or expense refund reversing ingående moms (both credit vatCreditAccount). + // Distinguish by account: 2641 = reversed ingående moms, 2611/2621/2631 = utgående moms. + const isExpenseRefund = mapping.vatCreditAccount === '2641' vatLines.push({ account_number: mapping.vatCreditAccount, debit_amount: 0, credit_amount: vatAmount, - description: hasVatOverride ? 'Utgående moms (enligt underlag)' : `Utgående moms ${vatRate * 100}%`, + description: isExpenseRefund + ? (hasVatOverride ? 'Återföring ingående moms (enligt underlag)' : `Återföring ingående moms ${vatRate * 100}%`) + : (hasVatOverride ? 'Utgående moms (enligt underlag)' : `Utgående moms ${vatRate * 100}%`), }) } }