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
parent 97bbba323f
commit a9242551eb
3 changed files with 69 additions and 4 deletions
+1
View File
@@ -767,3 +767,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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)
@@ -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(