From a9242551eb08512ad0cd25bc04b4fe0cd836339c Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 4 Aug 2026 19:34:18 +0200 Subject: [PATCH] fix(reports): stop double-counting arets resultat on open years (#1401) * fix(reports): stop double-counting arets resultat on open years The balance sheet computed the synthetic Arets resultat section purely from class 3-8 rows while 2099's posted balance already sat inside the class 2 Eget kapital sections. When a resultatavslut was posted to 2099 on an open period with its counter-line outside class 3-8 (class 0/9 or missing class), the result was counted twice and the report raised a false imbalance whose differens equaled the 2099 balance. The period-result filter now takes every row NOT in class 1-2, written as a negated range so null/undefined account_class rows are included. The invisible counter-line of a mangled resultatavslut then offsets inside the period result and 2099 is never counted twice, while a genuinely untransferred prior-year result still produces a real differens and the existing diagnosis still fires. Co-Authored-By: Claude Fable 5 * docs: record balance-sheet residual classification decision (#1333) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + lib/reports/__tests__/balance-sheet.test.ts | 58 +++++++++++++++++++++ lib/reports/balance-sheet.ts | 14 +++-- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 3ee90a2e..514107fa 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -767,3 +767,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-03] The disposal RPC validates metadata invariants (non-negative proceeds and VAT, VAT requires a treatment, VAT never above gross proceeds, scrap carries no proceeds) rather than deriving register metadata from the journal lines: duplicating the TS disposal planner in SQL would drift, and once disposed_at is set the register row is frozen by trigger, so corrections go through storno plus a new disposal rather than register edits. [2026-08-03] Tax depreciation (issue #324) is elected per fiscal period on a pooled snapshot chain, not per asset: fiscal_periods carries method, rule, opening, base, deduction and closing values, with DB guards enforcing method continuity and opening equal to the previous closing; assets keep depreciation_method linear for book depreciation, and company_settings.tax_depreciation_method was dropped because a second company-level method meant an admin/member RLS mismatch and a non-atomic second write; the annual snapshot chain is authoritative. [2026-08-03] kompletteringsregel_20 with a positive basis and zero acquisition cohorts is refused rather than computed: reducing over an empty cohort set would claim a full write-off the cohort evidence does not support (IL 18 kap. 17 §), so such periods require manual review instead of an automatic deduction. +[2026-08-04] Balance-sheet synthetic result = complement of classes 1-2 (class 0/9/null rows included), not classes 3-8: a resultatavslut posted to 2099 without zeroing class 3-8 then self-cancels inside the residual instead of double-counting equity; mirrors balansrapport's residual definition (#1333) diff --git a/lib/reports/__tests__/balance-sheet.test.ts b/lib/reports/__tests__/balance-sheet.test.ts index 82e56641..d47e689b 100644 --- a/lib/reports/__tests__/balance-sheet.test.ts +++ b/lib/reports/__tests__/balance-sheet.test.ts @@ -181,6 +181,64 @@ describe('generateBalanceSheet', () => { expect(report.total_equity_liabilities).toBe(32500) }) + it('does not double-count arets resultat when a resultatavslut on 2099 has its counter-line outside class 3-8 (issue #1333)', async () => { + // Production shape from issue #1333: a resultatavslut was posted on an + // open year, crediting 2099 with the year's result while the counter-line + // landed on a class 9 account. 2099 already carries the result inside the + // class 2 sections; the synthetic "Arets resultat" section must not add it + // again. The class 9 row offsets the class 3-8 net so periodResult is 0. + mockTrialBalance.mockResolvedValue({ + rows: [ + makeRow({ account_number: '1930', account_name: 'Företagskonto', account_class: 1, closing_debit: 105000, closing_credit: 0 }), + makeRow({ account_number: '2010', account_name: 'Eget kapital', account_class: 2, closing_credit: 100000, closing_debit: 0 }), + makeRow({ account_number: '2099', account_name: 'Årets resultat', account_class: 2, closing_credit: 5000, closing_debit: 0 }), + makeRow({ account_number: '3001', account_name: 'Försäljning', account_class: 3, closing_credit: 5000, closing_debit: 0 }), + makeRow({ account_number: '9999', account_name: 'Motkonto resultatavslut', account_class: 9, closing_debit: 5000, closing_credit: 0 }), + ], + totalDebit: 110000, + totalCredit: 110000, + isBalanced: true, + }) + + const report = await generateBalanceSheet(supabase, 'company-1', 'period-1') + + expect(report.total_assets).toBe(105000) + expect(report.total_equity_liabilities).toBe(105000) + // No synthetic section: 2099 already holds the result inside Eget kapital + const syntheticSection = report.equity_liability_sections.find(s => s.title === 'Årets resultat') + expect(syntheticSection).toBeUndefined() + const equitySection = report.equity_liability_sections.find(s => s.title === 'Eget kapital')! + expect(equitySection.subtotal).toBe(105000) + expect(report.imbalance_diagnosis).toBeUndefined() + expect(mockFindUntransferred).not.toHaveBeenCalled() + }) + + it('includes rows with null account_class in the period result so they offset a mangled resultatavslut', async () => { + // Same shape as above but the counter-line sits on an account whose + // class could not be resolved (null). The negated class 1-2 filter must + // keep it in the period result so the resultatavslut self-cancels. + mockTrialBalance.mockResolvedValue({ + rows: [ + makeRow({ account_number: '1930', account_name: 'Företagskonto', account_class: 1, closing_debit: 105000, closing_credit: 0 }), + makeRow({ account_number: '2010', account_name: 'Eget kapital', account_class: 2, closing_credit: 100000, closing_debit: 0 }), + makeRow({ account_number: '2099', account_name: 'Årets resultat', account_class: 2, closing_credit: 5000, closing_debit: 0 }), + makeRow({ account_number: '3001', account_name: 'Försäljning', account_class: 3, closing_credit: 5000, closing_debit: 0 }), + makeRow({ account_number: '0999', account_name: 'Motkonto utan klass', account_class: null as unknown as number, closing_debit: 5000, closing_credit: 0 }), + ], + totalDebit: 110000, + totalCredit: 110000, + isBalanced: true, + }) + + const report = await generateBalanceSheet(supabase, 'company-1', 'period-1') + + expect(report.total_assets).toBe(105000) + expect(report.total_equity_liabilities).toBe(105000) + expect(report.equity_liability_sections.find(s => s.title === 'Årets resultat')).toBeUndefined() + expect(report.imbalance_diagnosis).toBeUndefined() + expect(mockFindUntransferred).not.toHaveBeenCalled() + }) + it('handles negative asset balance (net credit on class 1 account)', async () => { mockTrialBalance.mockResolvedValue({ rows: [ diff --git a/lib/reports/balance-sheet.ts b/lib/reports/balance-sheet.ts index 012057f8..a5726887 100644 --- a/lib/reports/balance-sheet.ts +++ b/lib/reports/balance-sheet.ts @@ -69,11 +69,17 @@ export async function generateBalanceSheet( 'credit' // Equity/liabilities have credit normal balance ) - // Calculate period result from income/expense accounts (class 3-8) - // Before year-end closing, this result lives on class 3-8 accounts and must - // be included in equity for the balance sheet to balance. + // Calculate the period result from every row OUTSIDE the balance-sheet + // classes (1-2), not just class 3-8. Invariant: synthetic result = + // everything outside the balance-sheet classes, so a resultatavslut that + // was posted to 2099 but whose counter-line landed on a class 0/9 or + // class-less account self-cancels here instead of double-counting the + // result (2099 already carries it inside the class 2 sections). The + // negated range is deliberate: it keeps null/undefined account_class rows + // in the result. A genuinely untransferred prior-year result still yields + // a real differens and the imbalance diagnosis below. const incomeExpenseRows = rows.filter( - (r) => r.account_class >= 3 && r.account_class <= 8 + (r) => !(r.account_class >= 1 && r.account_class <= 2) ) const periodResult = Math.round( incomeExpenseRows.reduce(