Files
accounted/lib/bokslut/__tests__/dispositions-proposal-builder.test.ts
T
0de766c6a4 fix(bokslut): stop step 3 (Dispositioner) from failing for every pre-2025 räkenskapsår (#1777)
The schablonintäkt SLR table only had closing years 2025 and 2026, and the
dispositions builder consulted it unconditionally, so every aktiebolag
running the year-end wizard for 2024 or earlier got "Ett oväntat serverfel
uppstod" at the Dispositioner step (126 open FY2024 periods on prod, plus
older years), even when the company holds no periodiseringsfonder at all.

- Backfill SCHABLONINTAKT_RATE_BY_CLOSING_YEAR for 2020-2024 from
  Riksgälden's 30 November SLR (2019: -0.09 %, 2020: -0.10 %, 2021: 0.23 %,
  all floored to 0.5 %; 2022: 1.94 %; 2023: 2.62 %). 2019 and earlier stay
  unmapped: the 100 %-of-SLR rule keys on beskattningsår starting
  2019-01-01+, so a 2019 closing can be a brutet år under the old 72 %.
- Resolve the rate lazily (resolveSchablonintaktRate): a company without
  an opening 212X balance never touches the table, so an unmapped year can
  no longer break a no-fond bokslut. Used by the builder and all three POST
  item paths; POST overrides still win.
- Typed SchablonintaktRateNotConfiguredError with registry code
  SCHABLONINTAKT_RATE_NOT_CONFIGURED (500, Swedish message) so the rare
  fond-holding-company-on-unmapped-year case tells the user what is wrong
  instead of a generic server error, while still surfacing in runtime-error
  clustering for the December table update.
- Tests: rate table + resolver units, new builder test (no-fond FY2024 and
  unmapped-year cases, SLR folded into the tax base), GET route tests.


Claude-Session: https://claude.ai/code/session_01SyEZHx14jBvibkZmz8uAUC

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:47:29 +02:00

183 lines
6.9 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.
/**
* buildDispositionsProposal: the shared core of GET /bokslutsdispositioner
* and the MCP preview tool. These tests pin the schablonintäkt rate
* behaviour that broke every pre-2025 bokslut in production: the SLR table
* only had 2025/2026, and the builder consulted it unconditionally, so a
* räkenskapsår 2024 AB with no periodiseringsfonder at all got a generic 500
* at step 3 of the wizard.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExistingFond } from '../reserves/periodiseringsfond-service'
vi.mock('@/lib/reports/income-statement', () => ({
generateIncomeStatement: vi.fn(),
}))
vi.mock('@/lib/bokslut/tax-provision/bolagsskatt-calculator', () => ({
calculateBolagsskatt: vi.fn(),
getBookedBolagsskatt: vi.fn(),
sumPostedYearEndDispositions: vi.fn(),
}))
vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({
loadTaxAdjustmentSnapshot: vi.fn(),
}))
vi.mock('@/lib/bokslut/tax-provision/sarskild-loneskatt-calculator', () => ({
calculateSarskildLoneskatt: vi.fn(),
}))
vi.mock('@/lib/bokslut/reserves/overavskrivningar-calculator', () => ({
calculateOveravskrivningar: vi.fn(),
}))
vi.mock('@/lib/bokslut/reserves/periodiseringsfond-service', async (importOriginal) => {
const actual = await importOriginal<
typeof import('@/lib/bokslut/reserves/periodiseringsfond-service')
>()
return { ...actual, listExistingPeriodiseringsfonder: vi.fn() }
})
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import {
calculateBolagsskatt,
getBookedBolagsskatt,
sumPostedYearEndDispositions,
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
import { loadTaxAdjustmentSnapshot } from '@/lib/bokslut/tax-provision/tax-adjustment-service'
import { calculateSarskildLoneskatt } from '@/lib/bokslut/tax-provision/sarskild-loneskatt-calculator'
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
import {
listExistingPeriodiseringsfonder,
SchablonintaktRateNotConfiguredError,
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { buildDispositionsProposal } from '../dispositions-proposal-builder'
function supabaseFor(period: { period_start: string; period_end: string }, entityType = 'aktiebolag') {
const periodBuilder = {
select: vi.fn(),
eq: vi.fn(),
single: vi.fn().mockResolvedValue({
data: {
id: 'period-1',
name: `Räkenskapsår ${period.period_end.slice(0, 4)}`,
period_start: period.period_start,
period_end: period.period_end,
opening_balance_entry_id: null,
},
error: null,
}),
}
periodBuilder.select.mockReturnValue(periodBuilder)
periodBuilder.eq.mockReturnValue(periodBuilder)
const settingsBuilder = {
select: vi.fn(),
eq: vi.fn(),
maybeSingle: vi.fn().mockResolvedValue({ data: { entity_type: entityType }, error: null }),
}
settingsBuilder.select.mockReturnValue(settingsBuilder)
settingsBuilder.eq.mockReturnValue(settingsBuilder)
return {
from: vi.fn((table: string) => (table === 'company_settings' ? settingsBuilder : periodBuilder)),
} as unknown as SupabaseClient
}
const fond2120 = (opening_balance: number): ExistingFond => ({
account_number: '2120',
cohort_year: 2020,
balance: opening_balance,
opening_balance,
must_return_this_year: false,
})
const bolagsskattProposal = {
kind: 'bolagsskatt' as const,
label: 'Bolagsskatt 20,6 %',
description: 'Skatt på årets skattemässiga resultat.',
amount: 20_600,
lines: [
{ account_number: '8910', debit_amount: 20_600, credit_amount: 0 },
{ account_number: '2512', debit_amount: 0, credit_amount: 20_600 },
],
warnings: [],
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 100_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
vi.mocked(sumPostedYearEndDispositions).mockResolvedValue({
total: 0,
slpPortion: 0,
taxProvisionPortion: 0,
})
vi.mocked(loadTaxAdjustmentSnapshot).mockResolvedValue({
items: [],
nonDeductibleExpenses: 0,
nonTaxableIncome: 0,
})
vi.mocked(getBookedBolagsskatt).mockResolvedValue(0)
vi.mocked(listExistingPeriodiseringsfonder).mockResolvedValue([])
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
status: 'not_applicable',
proposal: null,
currentReserve: 0,
currentPeriodChange: 0,
targetReserve: 0,
maximumSignedChange: 0,
})
vi.mocked(calculateSarskildLoneskatt).mockResolvedValue(null)
vi.mocked(calculateBolagsskatt).mockResolvedValue(bolagsskattProposal)
})
describe('buildDispositionsProposal: schablonintäkt rate resolution', () => {
it('builds the FY2024 proposal for an AB without periodiseringsfonder (the Väla Redovisning case)', async () => {
const supabase = supabaseFor({ period_start: '2024-01-01', period_end: '2024-12-31' })
const result = await buildDispositionsProposal(supabase, 'company-1', 'period-1')
expect(result.entityType).toBe('aktiebolag')
expect(result.proposals.map((p) => p.kind)).toContain('bolagsskatt')
// Inga fonder: no schablonintäkt reaches the tax base.
expect(vi.mocked(calculateBolagsskatt)).toHaveBeenCalledWith(
supabase,
'company-1',
'period-1',
expect.objectContaining({
manualAdjustments: expect.objectContaining({ schablonintaktPeriodiseringsfond: 0 }),
}),
)
})
it('does not consult the SLR table for a no-fond company even on an unmapped closing year', async () => {
const supabase = supabaseFor({ period_start: '2019-01-01', period_end: '2019-12-31' })
await expect(
buildDispositionsProposal(supabase, 'company-1', 'period-1'),
).resolves.toMatchObject({ entityType: 'aktiebolag' })
})
it('folds the closing-year SLR into the tax base when fonder carried an opening balance', async () => {
vi.mocked(listExistingPeriodiseringsfonder).mockResolvedValue([fond2120(100_000)])
const supabase = supabaseFor({ period_start: '2024-01-01', period_end: '2024-12-31' })
await buildDispositionsProposal(supabase, 'company-1', 'period-1')
// 100 000 × 2.62 % (SLR 2023-11-30) = 2 620 kr schablonintäkt, INK2S 4.6a.
expect(vi.mocked(calculateBolagsskatt)).toHaveBeenCalledWith(
supabase,
'company-1',
'period-1',
expect.objectContaining({
manualAdjustments: expect.objectContaining({ schablonintaktPeriodiseringsfond: 2_620 }),
}),
)
})
it('fails closed with the typed registry error when fonder exist and the year is unmapped', async () => {
vi.mocked(listExistingPeriodiseringsfonder).mockResolvedValue([fond2120(100_000)])
const supabase = supabaseFor({ period_start: '2019-01-01', period_end: '2019-12-31' })
await expect(
buildDispositionsProposal(supabase, 'company-1', 'period-1'),
).rejects.toBeInstanceOf(SchablonintaktRateNotConfiguredError)
})
})