Files
accounted/lib/reports/__tests__/cross-surface-agreement.test.ts
T
MattssonandClaude Fable 5.1 9a8291f454 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>
2026-09-09 13:57:38 +02:00

221 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Cross-surface agreement on one closed fiscal year.
*
* Every problem the year-end pipeline has produced for a real customer was a
* disagreement between two screens, not a single wrong screen: the
* årsredovisning said one thing and INK2 said another, so the customer became
* the reconciliation engine. Per-surface tests cannot catch that; this file
* tests the agreement itself.
*
* There are deliberately TWO families, and they are allowed to disagree with
* each other while Stage 2 of #1051 is outstanding (DECISIONS.md:632):
*
* statutory (closingEntry 'exclude-final' + a post-closing balance sheet)
* reports årets resultat AFTER bokslutsdispositioner and skatt.
* operational (closingEntry 'exclude-all-year-end')
* reports the result BEFORE them.
*
* Within a family the numbers must be identical. The gap BETWEEN the families
* 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'
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({
loadTaxAdjustmentSnapshot: vi.fn(),
}))
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { loadTaxAdjustmentSnapshot } from '@/lib/bokslut/tax-provision/tax-adjustment-service'
import { generateIncomeStatement } from '../income-statement'
import { generateResultatrapport } from '../resultatrapport'
import { generateINK2Declaration } from '../ink2/ink2-engine'
import { mapTrialBalancesToK2 } from '@/lib/bokslut/ixbrl/k2-mapper'
import {
CLOSED_ROWS,
EXPECTED,
PRE_CLOSING_ROWS,
rowsForMode,
tbRow,
} from './closed-year-fixture'
const COMPANY_ID = 'company-1'
const PERIOD_ID = 'period-1'
function makeSupabase() {
const period = {
id: PERIOD_ID,
name: 'Räkenskapsår 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
is_closed: true,
closing_entry_id: 'closing-1',
previous_period_id: null,
}
const settings = {
company_name: 'Testbolaget',
org_number: '5560000000',
entity_type: 'aktiebolag',
address_line1: 'Testgatan 1',
postal_code: '11122',
city: 'Stockholm',
email: 'test@example.com',
}
function chain(result: unknown): Record<string, unknown> {
const c: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'neq', 'or', 'order', 'limit', 'contains']) {
c[m] = () => c
}
c.single = async () => result
c.maybeSingle = async () => result
c.range = async () => result
return c
}
return {
from: (table: string) => {
if (table === 'fiscal_periods') return chain({ data: period, error: null })
if (table === 'company_settings') return chain({ data: settings, error: null })
if (table === 'companies') return chain({ data: { entity_type: 'aktiebolag' }, error: null })
if (table === 'journal_entries') return chain({ data: { status: 'posted' }, error: null })
return chain({ data: [], error: null })
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(loadTaxAdjustmentSnapshot).mockResolvedValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ nonDeductibleExpenses: 0, nonTaxableIncome: 0 } as any,
)
vi.mocked(generateTrialBalance).mockImplementation(async (_s, _c, _p, opts) => ({
rows: rowsForMode(opts.closingEntry),
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
}))
})
describe('statutory surfaces agree on årets resultat', () => {
it('INK2R 7450 and the K2 årsredovisning report the same figure', async () => {
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
expect(ink2.ink2r['7450']).toBe(EXPECTED.netResult)
expect(k2.totals.aretsResultat.current).toBe(EXPECTED.netResult)
expect(ink2.ink2r['7450']).toBe(k2.totals.aretsResultat.current)
})
it('both put the same figure in fritt eget kapital via 2099', async () => {
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
expect(ink2.ink2r['7302']).toBe(EXPECTED.netResult)
expect(k2.totals.frittEgetKapital.current).toBe(EXPECTED.netResult)
})
it('both reclassify the credit skattekonto into skatteskulder', async () => {
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
// 2512 − 2518 = 10 000, plus 1630's reclassified credit of 20 000.
expect(ink2.ink2r['7368']).toBe(10_000 + EXPECTED.taxAccountCredit)
expect(k2.br['Skatteskulder'].current).toBe(10_000 + EXPECTED.taxAccountCredit)
})
it('both reclassify the input-VAT debit into övriga fordringar', async () => {
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
expect(ink2.ink2r['7261']).toBe(EXPECTED.inputVatDebit)
expect(k2.br['OvrigaFordringarKortfristiga'].current).toBe(EXPECTED.inputVatDebit)
})
it('both balance', async () => {
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
expect(ink2.totals.totalAssets).toBe(ink2.totals.totalEquityLiabilities)
expect(k2.totals.tillgangar.current).toBe(k2.totals.egetKapitalSkulder.current)
expect(ink2.totals.totalAssets).toBe(k2.totals.tillgangar.current)
})
})
describe('operational surfaces agree with each other', () => {
it('Resultaträkning and Resultatrapport report the same result', async () => {
const is = await generateIncomeStatement(makeSupabase(), COMPANY_ID, PERIOD_ID)
const rr = await generateResultatrapport(makeSupabase(), COMPANY_ID, PERIOD_ID)
expect(is.net_result).toBe(EXPECTED.resultAfterFinancial)
expect(rr.net_result_current).toBe(EXPECTED.resultAfterFinancial)
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)
// Nettoomsättning is unaffected by the dispositions/tax split, so this one
// figure must match across BOTH families.
expect(is.total_revenue).toBe(ink2.ink2r['7410'])
})
})
describe('the known gap between the two families', () => {
it('is exactly bokslutsdispositioner plus skatt', async () => {
const is = await generateIncomeStatement(makeSupabase(), COMPANY_ID, PERIOD_ID)
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
const gap = is.net_result - ink2.ink2r['7450']
const dispositionsAndTax = ink2.ink2r['7525'] + ink2.ink2r['7528']
expect(gap).toBe(dispositionsAndTax)
expect(gap).toBe(160_000) // 100 000 periodiseringsfond + 60 000 skatt
// This gap is Stage 2 of #1051, deliberately outstanding
// (DECISIONS.md:632). When generateIncomeStatement moves to
// 'exclude-final', the operational family joins the statutory one and this
// expectation becomes gap === 0.
})
})