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>
This commit is contained in:
Jakob Wennberg
2026-08-21 11:47:29 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent f11a78ec50
commit 0de766c6a4
8 changed files with 380 additions and 21 deletions
+1
View File
@@ -1137,3 +1137,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-20] Reverted #1765: the company switcher is NOT mounted at the top of the desktop sidebar. Founder call after seeing it live: switching belongs in the bottom user block only (the UserMenu flyout), so the sidebar top stays brand + collapse and the nav starts immediately below. #1664's "one-click from the top" framing is therefore declined, not merely unimplemented; the logo title tooltip went back with the revert since it shipped inside the same commit. Do not re-add a top-of-sidebar switcher from #1664 without a new founder decision.
[2026-08-20] Fortnox voucher-attachment scopes (Arkivplats + Koppla filer) are requested per authorize call from the underlag follow-up only, never from an ordinary connect, and gated on FORTNOX_DOCUMENT_SCOPES_APPROVED in lib/providers/fortnox/oauth.ts (the portal-registration switch). Two reasons: Fortnox derives customer licence requirements from what the integration requests, so an all-connects request would put an Arkivplats licence in front of customers who never import a receipt (the portal says so in as many words); and a scope the registered app lacks makes authorize reject with invalid_scope before login, so keeping it off the default connect caps the blast radius at the underlag flow instead of every Fortnox connection (incident 2026-08-13). A document consent is always a superset of an ordinary one, because the callback overwrites the consent's tokens in place and a narrower grant would revoke the migration's own ledger access. While the flag is false the attachment 403 reports PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE with no action offered, instead of reconnect advice for a permission we never ask for: that advice sent Klura AB around the OAuth loop four times and to buy the Fortnox Arkiv module for nothing (support case 2026-08-20). Portal registration alone changes nothing observable, which is why turning the scopes on and back off that day neither caused nor fixed the error.
[2026-08-21] Flipped FORTNOX_DOCUMENT_SCOPES_APPROVED to true: Arkivplats and Koppla filer are now enabled for integration 39254 in the Fortnox Developer Portal (founder confirmed). Only the opt-in underlag reconnect requests them, so the ordinary connect is unchanged and no customer is asked for an Arkivplats licence to connect. Set it back to false if the portal ever loses the scopes, since authorize then rejects with invalid_scope before login.
[2026-08-21] SCHABLONINTAKT_RATE_BY_CLOSING_YEAR backfilled 2020-2024 (SLR 30 Nov per Riksgalden: -0.09/-0.10/0.23 floored to 0.5 %, 1.94 %, 2.62 %) and the rate now resolves lazily (resolveSchablonintaktRate: 0 when no 212X account carried an opening balance): the table only covered 2025/2026 and the builder consulted it unconditionally, so every AB closing a pre-2025 year got a generic 500 at bokslut step 3 (126 open FY2024 periods on prod, incl. a byra trial). 2019 and earlier stay unmapped on purpose: the 100 %-of-SLR rule keys on beskattningsar STARTING 2019-01-01+ (prop. 2017/18:245), so a 2019 closing can be a brutet ar under the old 72 % factor. Unmapped-year-with-fonder now raises SCHABLONINTAKT_RATE_NOT_CONFIGURED (typed, 500 so runtime-error clustering still flags the missed December update) instead of INTERNAL_ERROR.
@@ -68,13 +68,25 @@ import {
sumPostedYearEndDispositions,
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { listExistingPeriodiseringsfonder } from '@/lib/bokslut/reserves/periodiseringsfond-service'
import {
listExistingPeriodiseringsfonder,
SchablonintaktRateNotConfiguredError,
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { POST, PUT } from '../route'
import { GET, POST, PUT } from '../route'
const idParams = { params: Promise.resolve({ id: 'period-1' }) }
function get() {
return GET(
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/bokslutsdispositioner', {
method: 'GET',
}),
idParams,
)
}
function post(body: unknown) {
return POST(
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/bokslutsdispositioner', {
@@ -200,6 +212,43 @@ beforeEach(() => {
>)
})
describe('GET /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: null,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await get()
expect(res.status).toBe(401)
})
it('returns the proposal snapshot', async () => {
const { status, body } = await parseJsonResponse<{ data: { entityType: string } }>(await get())
expect(status).toBe(200)
expect(body.data.entityType).toBe('aktiebolag')
})
it('returns 404 when the builder cannot find the period', async () => {
vi.mocked(buildDispositionsProposal).mockRejectedValue(new Error('Fiscal period not found'))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await get())
expect(status).toBe(404)
expect(body.error.code).toBe('PERIOD_NOT_FOUND')
})
it('surfaces a missing SLR year as a typed Swedish error, not a generic 500', async () => {
vi.mocked(buildDispositionsProposal).mockRejectedValue(
new SchablonintaktRateNotConfiguredError(2030),
)
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(
await get(),
)
expect(status).toBe(500)
expect(body.error.code).toBe('SCHABLONINTAKT_RATE_NOT_CONFIGURED')
expect(body.error.message).toMatch(/Statslåneräntan/)
})
})
describe('PUT /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () => {
const validBody = {
manualAdjustments: { nonDeductibleExpenses: 0, nonTaxableIncome: 0 },
@@ -17,10 +17,10 @@ import {
import { calculateSarskildLoneskatt } from '@/lib/bokslut/tax-provision/sarskild-loneskatt-calculator'
import {
getPeriodiseringsfondCohortAccount,
getSchablonintaktRate,
listExistingPeriodiseringsfonder,
proposeAvsattning,
proposeAteforing,
resolveSchablonintaktRate,
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-service'
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
@@ -32,10 +32,11 @@ import type { JournalEntry } from '@/types'
/**
* The schablonintäkt rate (IL 30 kap 6a §) defaults per fiscal year via
* getSchablonintaktRate (statslåneräntan 30 nov året före det kalenderår
* beskattningsåret går ut, lägst 0.5 %). Caller can override per request
* via `schablonintaktRate` in the POST body; a future Riksbanken
* integration will fetch the rate automatically.
* resolveSchablonintaktRate (statslåneräntan 30 nov året före det kalenderår
* beskattningsåret går ut, lägst 0.5 %; only consulted when the company holds
* fonder at the start of the year). Caller can override per request via
* `schablonintaktRate` in the POST body; a future Riksbanken integration
* will fetch the rate automatically.
*
* Canonical bokslut order. Each calculator re-reads the trial balance to
* derive its base, so earlier items must post before later items see their
@@ -333,9 +334,9 @@ async function computeProposal(
period.opening_balance_entry_id,
),
])
const schablonintaktRate = resolveSchablonintaktRate(fiscalYear, existingFonder)
const schablonintakt = existingFonder.reduce(
(sum, fund) =>
sum + Math.max(0, fund.opening_balance) * getSchablonintaktRate(fiscalYear),
(sum, fund) => sum + Math.max(0, fund.opening_balance) * schablonintaktRate,
0,
)
const manuallyBookedTax = Math.max(
@@ -380,7 +381,11 @@ async function computeProposal(
period.period_start,
period.opening_balance_entry_id,
)
const schablonintaktRate = item.schablonintaktRate ?? getSchablonintaktRate(fiscalYear)
const schablonintaktRate = resolveSchablonintaktRate(
fiscalYear,
existing,
item.schablonintaktRate,
)
// Schablonintäkt applies to the fond balance at the START of the tax
// year (IL 30 kap 6a §): opening balances, regardless of what has
// been avsatt or återfört during the period.
@@ -442,7 +447,7 @@ async function computeProposal(
)
const result = proposeAteforing(existing, {
returns: item.returns,
schablonintaktRate: item.schablonintaktRate ?? getSchablonintaktRate(fiscalYear),
schablonintaktRate: resolveSchablonintaktRate(fiscalYear, existing, item.schablonintaktRate),
})
// Combine multiple cohort reversals into a single voucher with multiple
// lines so we don't blow up voucher numbering, but each fond is its own
@@ -0,0 +1,182 @@
/**
* 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)
})
})
@@ -6,6 +6,8 @@ import {
listExistingPeriodiseringsfonder,
getPeriodiseringsfondCohortAccount,
getSchablonintaktRate,
resolveSchablonintaktRate,
SchablonintaktRateNotConfiguredError,
PFOND_AB_RATE,
PFOND_MAX_HOLD_YEARS,
type ExistingFond,
@@ -35,8 +37,62 @@ describe('getSchablonintaktRate', () => {
expect(getSchablonintaktRate(2026)).toBe(0.0255)
})
it('fails closed for unmapped years: a statutory rate is never guessed', () => {
it('covers every closing year since the 100 %-of-SLR rule (2020-2024), floored at 0.5 %', () => {
// SLR 30 Nov of the preceding year per Riksgälden: 2019 -0.09 %,
// 2020 -0.10 %, 2021 0.23 % (all floored), 2022 1.94 %, 2023 2.62 %.
expect(getSchablonintaktRate(2020)).toBe(0.005)
expect(getSchablonintaktRate(2021)).toBe(0.005)
expect(getSchablonintaktRate(2022)).toBe(0.005)
expect(getSchablonintaktRate(2023)).toBe(0.0194)
expect(getSchablonintaktRate(2024)).toBe(0.0262)
})
it('fails closed for unmapped years with a typed, registry-coded error', () => {
expect(() => getSchablonintaktRate(2030)).toThrow(/not configured/)
expect(() => getSchablonintaktRate(2030)).toThrow(SchablonintaktRateNotConfiguredError)
// 2019 closings can be brutet år under the old 72 %-of-SLR factor: kept unmapped.
expect(() => getSchablonintaktRate(2019)).toThrow(SchablonintaktRateNotConfiguredError)
try {
getSchablonintaktRate(2030)
} catch (err) {
expect((err as SchablonintaktRateNotConfiguredError).code).toBe(
'SCHABLONINTAKT_RATE_NOT_CONFIGURED',
)
expect((err as SchablonintaktRateNotConfiguredError).fiscalYear).toBe(2030)
}
})
})
describe('resolveSchablonintaktRate', () => {
const fond = (opening_balance: number): ExistingFond => ({
account_number: '2120',
cohort_year: 2020,
balance: opening_balance,
opening_balance,
must_return_this_year: false,
})
it('returns 0 without consulting the table when no fond has an opening balance', () => {
// The common no-fond AB: an unmapped year must never break its bokslut.
expect(resolveSchablonintaktRate(2019, [])).toBe(0)
expect(resolveSchablonintaktRate(2030, [])).toBe(0)
// A fond avsatt in THIS bokslut (opening 0) yields no schablonintäkt either.
expect(resolveSchablonintaktRate(2030, [fond(0)])).toBe(0)
})
it('returns the table rate when a fond carried an opening balance', () => {
expect(resolveSchablonintaktRate(2024, [fond(100_000)])).toBe(0.0262)
})
it('still fails closed for an unmapped year when fonder exist', () => {
expect(() => resolveSchablonintaktRate(2030, [fond(100_000)])).toThrow(
SchablonintaktRateNotConfiguredError,
)
})
it('prefers a caller-supplied override regardless of the table', () => {
expect(resolveSchablonintaktRate(2030, [fond(100_000)], 0.03)).toBe(0.03)
expect(resolveSchablonintaktRate(2024, [], 0.03)).toBe(0.03)
})
})
+4 -2
View File
@@ -9,10 +9,10 @@ import { loadTaxAdjustmentSnapshot } from './tax-provision/tax-adjustment-servic
import { calculateSarskildLoneskatt } from './tax-provision/sarskild-loneskatt-calculator'
import {
getPeriodiseringsfondCohortAccount,
getSchablonintaktRate,
listExistingPeriodiseringsfonder,
proposeAvsattning,
proposeAteforing,
resolveSchablonintaktRate,
} from './reserves/periodiseringsfond-service'
import { calculateOveravskrivningar } from './reserves/overavskrivningar-calculator'
import type { CompletedDisposition, DispositionsProposal, ProposedDisposition } from './types'
@@ -98,8 +98,10 @@ export async function buildDispositionsProposal(
period.period_start,
period.opening_balance_entry_id,
)
// Rate resolves lazily: a company without opening fonder never touches
// the SLR table, so an unmapped year cannot break its bokslut.
const ateforing = proposeAteforing(existingFonder, {
schablonintaktRate: getSchablonintaktRate(fiscalYear),
schablonintaktRate: resolveSchablonintaktRate(fiscalYear, existingFonder),
})
proposals.push(...ateforing.proposals)
const ateforingTotal = ateforing.proposals.reduce((sum, p) => sum + p.amount, 0)
@@ -19,12 +19,42 @@ export const PFOND_MAX_HOLD_YEARS = 6
* calendar year in which the beskattningsår ends, floored at 0.5 %. Note:
* the rate is the SLR itself, NOT SLR + 1 procentenhet (that formula is
* negativ räntefördelning). Keyed by the closing calendar year.
*
* The table starts at closing year 2020. The "100 % of SLR" rule applies to
* beskattningsår that START 2019-01-01 or later (prop. 2017/18:245; before
* that the rate was 72 % of SLR), so a 2019 closing can still be a brutet
* räkenskapsår under the old factor: 2019 and earlier therefore stay
* unmapped (fail closed) rather than carry a value that is wrong for some
* companies. SLR values per Riksgälden's 30 November announcements.
*/
const SCHABLONINTAKT_RATE_BY_CLOSING_YEAR: Record<number, number> = {
2020: 0.005, // SLR 2019-11-30 = -0.09 %, floored to 0.5 %
2021: 0.005, // SLR 2020-11-30 = -0.10 %, floored to 0.5 %
2022: 0.005, // SLR 2021-11-30 = 0.23 %, floored to 0.5 %
2023: 0.0194, // SLR 2022-11-30 = 1.94 %
2024: 0.0262, // SLR 2023-11-30 = 2.62 %
2025: 0.0196, // SLR 2024-11-30 = 1.96 %
2026: 0.0255, // SLR 2025-11-30 = 2.55 %
}
/**
* Thrown when the closing year has no SLR in the table. Carries a registry
* code so errorResponse() maps it to a specific Swedish message instead of a
* generic "oväntat serverfel", and the fiscal year so the UI or an agent can
* name the year.
*/
export class SchablonintaktRateNotConfiguredError extends Error {
readonly code = 'SCHABLONINTAKT_RATE_NOT_CONFIGURED'
constructor(readonly fiscalYear: number) {
super(
`Schablonintäkt rate for fiscal year ${fiscalYear} is not configured. `
+ 'Add the SLR (30 Nov of the preceding year, floor 0.5 %) to '
+ 'SCHABLONINTAKT_RATE_BY_CLOSING_YEAR in periodiseringsfond-service.ts.',
)
this.name = 'SchablonintaktRateNotConfiguredError'
}
}
/**
* Fail closed for unmapped years: a statutory rate must never be guessed.
* The table is an annual maintenance item (Riksbanken publishes the SLR on
@@ -34,16 +64,32 @@ const SCHABLONINTAKT_RATE_BY_CLOSING_YEAR: Record<number, number> = {
*/
export function getSchablonintaktRate(fiscalYear: number): number {
const rate = SCHABLONINTAKT_RATE_BY_CLOSING_YEAR[fiscalYear]
if (rate === undefined) {
throw new Error(
`Schablonintäkt rate for fiscal year ${fiscalYear} is not configured. `
+ 'Add the SLR (30 Nov of the preceding year, floor 0.5 %) to '
+ 'SCHABLONINTAKT_RATE_BY_CLOSING_YEAR in periodiseringsfond-service.ts.',
)
}
if (rate === undefined) throw new SchablonintaktRateNotConfiguredError(fiscalYear)
return rate
}
/**
* Resolve the schablonintäkt rate for one bokslut run. The rate only matters
* when some 212X account carried a balance at beskattningsårets ingång
* (IL 30 kap 6a §: schablonintäkt = rate × opening fond balance), so a
* company without periodiseringsfonder never consults the table and never
* fails on an unmapped year: the bokslut wizard must not 500 for the common
* no-fond AB just because that year's SLR is missing (which is exactly what
* happened for every pre-2025 closing before the table was backfilled). With
* opening fonder the table is authoritative unless the caller supplied a
* validated override; a missing year still fails closed.
*/
export function resolveSchablonintaktRate(
fiscalYear: number,
existingFonder: ReadonlyArray<Pick<ExistingFond, 'opening_balance'>>,
override?: number,
): number {
if (override !== undefined) return override
const hasOpeningBalance = existingFonder.some((f) => f.opening_balance > 0)
if (!hasOpeningBalance) return 0
return getSchablonintaktRate(fiscalYear)
}
/**
* BAS account convention: account = '212' + (fiscalYear % 10). 2020 → '2120',
* 2025 → '2125'. The collision year 2019/2029 maps to '2129' per the BAS
+18
View File
@@ -355,6 +355,24 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
message_sv: 'Bokslutsåtgärder måste utföras innan perioden kan stängas.',
message_en: 'Year-end closing must be executed before the period can be closed.',
},
// Bokslutsdispositioner: the schablonintäkt on periodiseringsfonder
// (IL 30 kap 6a §) needs the SLR for the closing year, kept in a table in
// lib/bokslut/reserves/periodiseringsfond-service.ts that is extended each
// December. Only raised when the company actually holds fonder at the start
// of the year (no fonder: no rate needed). 500 on purpose: it is a
// server-side configuration gap, not a user error, and it must show up in
// runtime-error clustering so the annual update is not missed.
SCHABLONINTAKT_RATE_NOT_CONFIGURED: {
httpStatus: 500,
message_sv:
'Statslåneräntan för det här räkenskapsåret saknas i systemet, så schablonintäkten på periodiseringsfonderna kan inte beräknas ännu. Kontakta supporten så lägger vi in den.',
message_en:
'The statslåneränta (SLR) for this closing year is not configured, so the schablonintäkt on periodiseringsfonder cannot be calculated yet. Contact support to have it added.',
remediation: {
description:
'Wait for the SLR table update, or pass schablonintaktRate explicitly on periodiseringsfond_avsattning / periodiseringsfond_ateforing items when posting dispositions.',
},
},
TRANSACTION_ALREADY_CATEGORIZED: {
httpStatus: 409,
message_sv: