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 <noreply@anthropic.com>

* docs: record balance-sheet residual classification decision (#1333)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-04 19:34:18 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 97bbba323f
commit a9242551eb
3 changed files with 69 additions and 4 deletions
@@ -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: [
+10 -4
View File
@@ -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(