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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
f11a78ec50
commit
0de766c6a4
+51
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user