feat(reports): list a booked 8999 in Resultatrapport instead of hiding it (#2457)

* feat(reports): list a booked 8999 in Resultatrapport instead of hiding it

Resultatrapport and dimension-pnl filtered account 8999 out and printed a
computed result row, so a user who books or imports the omföring of årets
resultat by hand saw huvudboken and the account-level report disagree.

Why it occurred: the filter was copied from the formal Resultaträkning,
where it is right (ÅRL's uppställningsform has no 8999 line). In the
operational report it hid a real balance. Our own bokslut verifikat never
posts 8999 (it zeroes each P&L account straight against 2099), so the only
8999 balances that exist are manual or SIE-imported ones, exactly the case
the report suppressed.

What was removed: the exclusion itself, in both operational reports, so
they keep reconciling. The XLSX bottom row is renamed to "Beräknat resultat"
to match the UI and PDF. Beräknat resultat now reads zero after such an
omföring, the Fortnox/Visma resultatrapport convention.

Why this and not the proposed shape: the user asked about Resultaträkning,
which stays as is on purpose. The bigger version (Stage 2 of #1051, showing
the bokslut verifikat via exclude-final) would zero every row of a closed
year given our closing-entry shape and is a separate decision; recorded in
DECISIONS.md.

Fixes #2455

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t

* test(reports): pin the deliberate 8999 gap between Resultatrapport and Resultaträkning

The cross-surface agreement test claimed the two operational reports are
identical; after #2455 they differ by exactly a booked 8999 omföring, and
the fixture had no such row so the invariant went silently false. Pin the
gap explicitly, and note in DECISIONS.md that this supersedes the
2026-07-29 same-profit line.

Refs #2455

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-09 13:57:38 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent e996d70955
commit 9a8291f454
7 changed files with 90 additions and 20 deletions
@@ -19,6 +19,12 @@
* is asserted explicitly, so when Stage 2 moves generateIncomeStatement to
* 'exclude-final' this test says exactly which expectations must change instead
* of failing vaguely.
*
* One deliberate exception inside the operational family (#2455): a booked or
* imported 8999 omföring is listed by Resultatrapport (account-level, reads 0
* after the omföring like a Fortnox/Visma resultatrapport) but excluded by
* Resultaträkning (ÅRL uppställningsform, årets resultat is always computed).
* That gap is pinned below too.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -40,6 +46,7 @@ import {
EXPECTED,
PRE_CLOSING_ROWS,
rowsForMode,
tbRow,
} from './closed-year-fixture'
const COMPANY_ID = 'company-1'
@@ -155,6 +162,35 @@ describe('operational surfaces agree with each other', () => {
expect(is.net_result).toBe(rr.net_result_current)
})
it('differ by exactly a booked 8999 omföring, the one deliberate gap (#2455)', async () => {
// Same closed year, plus a manual/imported omföring: D 8999 / K 2099.
const omforing = EXPECTED.resultAfterFinancial
vi.mocked(generateTrialBalance).mockImplementation(async (_s, _c, _p, opts) => ({
rows:
opts.closingEntry === 'exclude-all-year-end'
? [
...rowsForMode(opts.closingEntry),
tbRow('8999', 'Årets resultat', omforing),
tbRow('2099', 'Årets resultat', -omforing),
]
: rowsForMode(opts.closingEntry),
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
}))
const is = await generateIncomeStatement(makeSupabase(), COMPANY_ID, PERIOD_ID)
const rr = await generateResultatrapport(makeSupabase(), COMPANY_ID, PERIOD_ID)
// Resultaträkning ignores 8999 and still reports the computed result.
expect(is.net_result).toBe(EXPECTED.resultAfterFinancial)
// Resultatrapport lists the row and its beräknat resultat reads zero.
const row8999 = rr.groups.flatMap((g) => g.rows).find((r) => r.account_number === '8999')
expect(row8999?.current_period).toBe(-omforing)
expect(rr.net_result_current).toBe(0)
expect(is.net_result - rr.net_result_current).toBe(omforing)
})
it('and the same revenue as the statutory family', async () => {
const is = await generateIncomeStatement(makeSupabase(), COMPANY_ID, PERIOD_ID)
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
+33 -3
View File
@@ -116,7 +116,6 @@ describe('generateDimensionPnl', () => {
tbRow({ account_number: '3001', account_class: 3, closing_credit: 1000 }),
tbRow({ account_number: '4010', account_name: 'Inköp', account_class: 4, closing_debit: 400 }),
tbRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 900 }),
tbRow({ account_number: '8999', account_name: 'Årets resultat', account_class: 8, closing_debit: 600 }),
]),
)
@@ -146,12 +145,43 @@ describe('generateDimensionPnl', () => {
}
expect(report.net_per_column).toEqual([200, 300, 100])
// net_total = resultatrapport semantics over classes 3-8 excl 8999:
// +1000 (3001) − 400 (4010) = 600. 1930 (class 1) and 8999 excluded.
// net_total = resultatrapport semantics over classes 3-8:
// +1000 (3001) − 400 (4010) = 600. 1930 (class 1) excluded.
expect(report.net_total).toBe(600)
expect(report.period).toEqual({ start: '2026-01-01', end: '2026-12-31' })
})
it('lists a booked 8999 in the untagged bucket and nets it into net_total (#2455)', async () => {
mockResults = {
fiscal_periods: [{ data: PERIOD, error: null }],
dimensions: [{ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt' }, error: null }],
dimension_values: [{ data: [{ code: 'P001', name: 'Villa Almgren' }], error: null }],
journal_entry_lines: [
{
data: [
{ id: 'l1', account_number: '3001', debit_amount: 0, credit_amount: 1000, dimensions: { '6': 'P001' } },
],
error: null,
},
],
}
mockTrialBalance.mockResolvedValue(
tb([
tbRow({ account_number: '3001', account_class: 3, closing_credit: 1000 }),
// Manual omföring of årets resultat: 8999 debit, never dimension-tagged.
tbRow({ account_number: '8999', account_name: 'Årets resultat', account_class: 8, closing_debit: 1000 }),
]),
)
const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '6')
const row8999 = report.groups.flatMap((g) => g.rows).find((r) => r.account_number === '8999')
expect(row8999?.total).toBe(-1000)
// Same scope as resultatrapport: the omföring zeroes the result.
expect(report.net_total).toBe(0)
expect(report.net_per_column).toEqual([1000, -1000])
})
it('drops the untagged column when every krona is tagged', async () => {
mockResults = {
fiscal_periods: [{ data: PERIOD, error: null }],
@@ -172,7 +172,7 @@ describe('generateResultatrapport', () => {
expect(discontinued.prior_period).toBe(5000)
})
it('excludes account 8999 (year-end closing account)', async () => {
it('lists a booked 8999 as a class-8 row and nets it into beräknat resultat (#2455)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
@@ -190,8 +190,11 @@ describe('generateResultatrapport', () => {
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
const class8 = report.groups.find((g) => g.class === 8)
expect(class8).toBeUndefined()
expect(report.net_result_current).toBe(100000)
const row8999 = class8?.rows.find((r) => r.account_number === '8999')
expect(row8999?.current_period).toBe(-100000)
// The omföring of årets resultat zeroes the operational result, exactly
// as a Fortnox/Visma resultatrapport reads after bokslut.
expect(report.net_result_current).toBe(0)
})
it('ignores balance accounts (class 1-2)', async () => {
+4 -4
View File
@@ -131,7 +131,7 @@ export async function generateDimensionPnl(
})
// Bucket raw amounts per (account, code). Only accounts present in the P&L
// trial-balance scope count: anything else (balance accounts, 8999) is out.
// trial-balance scope count: anything else (balance accounts) is out.
const buckets = new Map<string, Map<string, { debit: number; credit: number }>>()
const codesSeen = new Set<string>()
for (const line of taggedLines) {
@@ -235,10 +235,10 @@ export async function generateDimensionPnl(
}
}
// Same scope as resultatrapport's filterPnl, 8999 included (#2455): the
// Totalt column must reconcile with that report's "Beräknat resultat".
function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] {
return rows.filter(
(r) => r.account_class >= 3 && r.account_class <= 8 && r.account_number !== '8999'
)
return rows.filter((r) => r.account_class >= 3 && r.account_class <= 8)
}
// credit − debit: revenue positive, expenses negative: resultatrapport's
+9 -9
View File
@@ -26,9 +26,14 @@ const CLASS_LABELS: Record<number, string> = {
* keeps account numbers and is meant for ongoing reconciliation, not for
* årsbokslut/årsredovisning.
*
* Account 8999 is excluded: it's the year-end closing account that moves
* årets resultat into equity (2099). Including its balance would double-count
* the result. Same exclusion as generateIncomeStatement.
* Account 8999 is listed like any other class-8 account (#2455). It only
* carries a balance when the user books or imports the omföring of årets
* resultat by hand (our own bokslut verifikat zeroes each P&L account straight
* against 2099 and never touches 8999). Listing it makes "Beräknat resultat"
* read zero after that omföring, the Fortnox/Visma resultatrapport
* convention, and keeps this report in agreement with huvudboken. The formal
* Resultaträkning (generateIncomeStatement) still excludes 8999: ÅRL's
* uppställningsform has no such line, årets resultat is always computed there.
*/
export async function generateResultatrapport(
supabase: SupabaseClient,
@@ -204,12 +209,7 @@ export async function generateResultatrapport(
}
function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] {
return rows.filter(
(r) =>
r.account_class >= 3 &&
r.account_class <= 8 &&
r.account_number !== '8999'
)
return rows.filter((r) => r.account_class >= 3 && r.account_class <= 8)
}
/**