* 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>
221 lines
9.2 KiB
TypeScript
221 lines
9.2 KiB
TypeScript
/**
|
||
* 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.
|
||
})
|
||
})
|