feat(bokslut): calculate and book overavskrivningar (2150/8850) (#1379)

Add an automatic excess-depreciation calculator for machinery and
equipment under IL 18 kap: 30-rule and 20-rule residuals (fiscal-period
aware for short and long years), ledger vs asset-register
reconciliation, fail-closed blocking states, and a signed proposal that
books via the dispositions flow (8853/2153). Releases of an over-target
reserve are mandatory and not overridable; increases are optional and
capped server-side.

Fixes #323

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-03 17:25:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 16fbcefbbc
commit 6782da3e9e
11 changed files with 1229 additions and 43 deletions
@@ -20,9 +20,14 @@ vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
vi.mock('@/lib/bokslut/reserves/overavskrivningar-calculator', () => ({
calculateOveravskrivningar: vi.fn(),
}))
import { buildDispositionsProposal } from '../dispositions-proposal-builder'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
interface ChainableMock {
from: ReturnType<typeof vi.fn>
@@ -138,6 +143,15 @@ function makeSupabase(opts: {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
status: 'not_applicable',
proposal: null,
warning: null,
currentReserve: 0,
currentPeriodChange: 0,
targetReserve: 0,
maximumSignedChange: 0,
})
// Zero result so the builder doesn't propose a new avsättning: keeps the
// 21xx balance stable at the trial-balance value, which makes the latent
// tax math testable in isolation.
@@ -211,6 +225,80 @@ describe('buildDispositionsProposal: K3 framework', () => {
expect(bolagsskatt?.amount).toBe(154_500)
})
it('includes excess depreciation in the periodiseringsfond and tax bases', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 1_000_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
status: 'ready',
proposal: {
kind: 'overavskrivningar',
label: 'Överavskrivningar',
description: 'Skillnad mellan bokförd och skattemässig avskrivning.',
amount: 100_000,
signedAmount: 100_000,
lines: [
{ account_number: '8853', debit_amount: 100_000, credit_amount: 0 },
{ account_number: '2153', debit_amount: 0, credit_amount: 100_000 },
],
warnings: [],
},
warning: null,
currentReserve: 0,
currentPeriodChange: 0,
targetReserve: 100_000,
maximumSignedChange: 100_000,
})
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k2' })
const result = await buildDispositionsProposal(
supabase as unknown as Parameters<typeof buildDispositionsProposal>[0],
'co',
'fp1',
)
const avsattning = result.proposals.find((p) => p.kind === 'periodiseringsfond_avsattning')
const bolagsskatt = result.proposals.find((p) => p.kind === 'bolagsskatt')
expect(avsattning?.amount).toBe(225_000)
expect(bolagsskatt?.amount).toBe(139_050)
})
it('includes a pending 2153 increase in the K3 latent tax proposal', async () => {
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
status: 'ready',
proposal: {
kind: 'overavskrivningar',
label: 'Överavskrivningar',
description: 'Skillnad mellan bokförd och skattemässig avskrivning.',
amount: 10_000,
signedAmount: 10_000,
lines: [
{ account_number: '8853', debit_amount: 10_000, credit_amount: 0 },
{ account_number: '2153', debit_amount: 0, credit_amount: 10_000 },
],
warnings: [],
},
warning: null,
currentReserve: 0,
currentPeriodChange: 0,
targetReserve: 10_000,
maximumSignedChange: 10_000,
})
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k3' })
const result = await buildDispositionsProposal(
supabase as unknown as Parameters<typeof buildDispositionsProposal>[0],
'co',
'fp1',
)
const latentTax = result.proposals.find((p) => p.kind === 'uppskjuten_skatt')
expect(latentTax?.amount).toBe(22_660)
expect(latentTax?.computation).toEqual(
expect.objectContaining({ untaxedReserves: 110_000, target2240: 22_660 }),
)
})
it('does NOT add an uppskjuten_skatt proposal for K2 aktiebolag', async () => {
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k2' })
const result = await buildDispositionsProposal(
@@ -0,0 +1,261 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Asset, TrialBalanceRow } from '@/types'
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
listAssets: vi.fn(),
}))
vi.mock('@/lib/bokslut/assets/depreciation-engine', () => ({
proposeAnnualPostings: vi.fn(),
}))
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
import { listAssets } from '@/lib/bokslut/assets/asset-service'
import { proposeAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { calculateOveravskrivningar } from '../reserves/overavskrivningar-calculator'
const PERIOD = {
id: 'period-2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
}
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'company-1',
name: 'Production equipment',
category: 'equipment',
acquisition_date: '2026-01-15',
acquisition_cost: 100_000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
restvarde_target: null,
disposed_at: null,
disposed_proceeds: null,
disposed_proceeds_vat: 0,
disposed_vat_treatment: null,
jamkning_amount: 0,
jamkning_remaining_months: null,
jamkning_total_months: null,
jamkning_original_input_vat: null,
k3_components: null,
notes: null,
created_at: '2026-01-15T00:00:00Z',
updated_at: '2026-01-15T00:00:00Z',
...overrides,
}
}
function row(
accountNumber: string,
values: Partial<TrialBalanceRow> = {},
): TrialBalanceRow {
return {
account_number: accountNumber,
account_name: accountNumber,
account_class: Number(accountNumber[0]),
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 0,
closing_debit: 0,
closing_credit: 0,
...values,
}
}
function makeSupabase(periods = [PERIOD]) {
const builder: Record<string, unknown> = {}
builder.select = vi.fn(() => builder)
builder.eq = vi.fn(() => builder)
builder.lte = vi.fn(() => builder)
builder.order = vi.fn(() => builder)
builder.limit = vi.fn(async () => ({ data: periods, error: null }))
return {
from: vi.fn(() => builder),
}
}
function mockTrialBalance(rows: TrialBalanceRow[]) {
vi.mocked(generateTrialBalance).mockResolvedValue({
rows,
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
}
function mockPostedDepreciation(asset: Asset) {
vi.mocked(proposeAnnualPostings).mockResolvedValue({
fiscalPeriod: { ...PERIOD, name: '2026' },
items: [
{
asset,
amount: 20_000,
netBookValueAfter: 80_000,
proRated: false,
existingScheduleId: 'schedule-1',
existingJournalEntryId: 'entry-1',
},
],
totalAmount: 20_000,
})
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('calculateOveravskrivningar', () => {
it('proposes the 8853/2153 bridge after planned depreciation is posted', async () => {
const asset = makeAsset()
vi.mocked(listAssets).mockResolvedValue([asset])
mockPostedDepreciation(asset)
mockTrialBalance([
row('1220', { period_debit: 100_000, closing_debit: 100_000 }),
row('1229', { period_credit: 20_000, closing_credit: 20_000 }),
])
const result = await calculateOveravskrivningar({
supabase: makeSupabase() as never,
companyId: 'company-1',
fiscalPeriod: PERIOD,
entityType: 'aktiebolag',
})
// 30-rule residual 70,000 beats 20-rule residual 80,000. Book value is
// 80,000, so 10,000 is bridged through the untaxed reserve.
expect(result.status).toBe('ready')
expect(result.selectedRule).toBe('30-regeln')
expect(result.targetReserve).toBe(10_000)
expect(result.proposal?.signedAmount).toBe(10_000)
expect(result.proposal?.lines.map((line) => line.account_number)).toEqual([
'8853',
'2153',
])
})
it('requires a release when the existing reserve exceeds the lawful target', async () => {
const asset = makeAsset({
acquisition_date: '2020-01-01',
acquisition_cost: 100_000,
})
vi.mocked(listAssets).mockResolvedValue([asset])
mockPostedDepreciation(asset)
mockTrialBalance([
row('1220', { opening_debit: 100_000, closing_debit: 100_000 }),
row('1229', { opening_credit: 60_000, closing_credit: 80_000 }),
row('2153', { opening_credit: 30_000, closing_credit: 30_000 }),
])
const result = await calculateOveravskrivningar({
supabase: makeSupabase() as never,
companyId: 'company-1',
fiscalPeriod: PERIOD,
entityType: 'aktiebolag',
})
expect(result.targetReserve).toBe(20_000)
expect(result.proposal?.signedAmount).toBe(-10_000)
expect(result.proposal?.required).toBe(true)
expect(result.proposal?.lines.map((line) => line.account_number)).toEqual([
'2153',
'8853',
])
})
it('fails closed when the asset register does not reconcile to 12xx', async () => {
const asset = makeAsset()
vi.mocked(listAssets).mockResolvedValue([asset])
mockTrialBalance([
row('1220', { closing_debit: 90_000 }),
row('1229', { closing_credit: 20_000 }),
])
const result = await calculateOveravskrivningar({
supabase: makeSupabase() as never,
companyId: 'company-1',
fiscalPeriod: PERIOD,
entityType: 'aktiebolag',
})
expect(result.status).toBe('blocked')
expect(result.warning).toContain('stämmer inte')
expect(result.proposal).toBeNull()
expect(proposeAnnualPostings).not.toHaveBeenCalled()
})
it('waits until current-period planned depreciation is posted', async () => {
const asset = makeAsset()
vi.mocked(listAssets).mockResolvedValue([asset])
vi.mocked(proposeAnnualPostings).mockResolvedValue({
fiscalPeriod: { ...PERIOD, name: '2026' },
items: [
{
asset,
amount: 20_000,
netBookValueAfter: 80_000,
proRated: false,
existingJournalEntryId: null,
},
],
totalAmount: 20_000,
})
mockTrialBalance([row('1220', { closing_debit: 100_000 })])
const result = await calculateOveravskrivningar({
supabase: makeSupabase() as never,
companyId: 'company-1',
fiscalPeriod: PERIOD,
entityType: 'aktiebolag',
})
expect(result.status).toBe('blocked')
expect(result.warning).toContain('planenliga avskrivningarna först')
})
it('does not propose a second optional increase after one was posted this period', async () => {
const asset = makeAsset()
vi.mocked(listAssets).mockResolvedValue([asset])
mockPostedDepreciation(asset)
mockTrialBalance([
row('1220', { period_debit: 100_000, closing_debit: 100_000 }),
row('1229', { period_credit: 20_000, closing_credit: 20_000 }),
row('2153', { period_credit: 5_000, closing_credit: 5_000 }),
])
const result = await calculateOveravskrivningar({
supabase: makeSupabase() as never,
companyId: 'company-1',
fiscalPeriod: PERIOD,
entityType: 'aktiebolag',
})
expect(result.maximumSignedChange).toBe(5_000)
expect(result.currentPeriodChange).toBe(5_000)
expect(result.proposal).toBeNull()
})
it('does not calculate or query assets for a sole trader', async () => {
const result = await calculateOveravskrivningar({
supabase: makeSupabase() as never,
companyId: 'company-1',
fiscalPeriod: PERIOD,
entityType: 'enskild_firma',
})
expect(result.status).toBe('not_applicable')
expect(listAssets).not.toHaveBeenCalled()
expect(generateTrialBalance).not.toHaveBeenCalled()
})
})
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
compute30Rule,
compute20Rule,
compute20RuleForFiscalPeriods,
pickLowerResidual,
proposeOveravskrivningar,
OVERAVSKRIVNING_30_RULE,
@@ -31,15 +32,48 @@ describe('compute20Rule', () => {
// current year, year-1, year-2, year-3, year-4
acquisitionCostByYearOffset: [100_000, 100_000, 100_000, 100_000, 100_000],
})
// residuals: 100×(5/5) + 100×(4/5) + 100×(3/5) + 100×(2/5) + 100×(1/5) = 100+80+60+40+20 = 300
expect(result.minimumResidual).toBe(300_000)
// The acquisition period gets the first 20% deduction. Residuals are
// therefore 80 + 60 + 40 + 20 + 0 = 200.
expect(result.minimumResidual).toBe(200_000)
})
it('skips cohorts where no acquisitions happened', () => {
const result = compute20Rule({
acquisitionCostByYearOffset: [50_000, 0, 0, 0, 0],
})
expect(result.minimumResidual).toBe(50_000)
expect(result.minimumResidual).toBe(40_000)
})
it('pro-rates shortened fiscal periods', () => {
const result = compute20RuleForFiscalPeriods({
acquisitionCostByPeriod: [100_000, 100_000],
fiscalPeriodMonths: [6, 12],
})
// Current cohort: 10% deducted. Prior cohort: 30% cumulative.
expect(result.minimumResidual).toBe(160_000)
})
})
describe('compute30Rule: fiscal period length', () => {
it('pro-rates the 30% rate for a six-month period', () => {
const result = compute30Rule({
openingBookValue: 100_000,
additions: 0,
disposals: 0,
fiscalPeriodMonths: 6,
})
expect(result.minimumResidual).toBe(85_000)
expect(result.maxAllowedAccumulated).toBe(15_000)
})
it('never produces a negative tax base when proceeds exceed the basis', () => {
const result = compute30Rule({
openingBookValue: 10_000,
additions: 0,
disposals: 20_000,
})
expect(result.base).toBe(0)
expect(result.minimumResidual).toBe(0)
})
})
@@ -69,6 +103,7 @@ describe('proposeOveravskrivningar', () => {
expect(result!.lines[0].debit_amount).toBe(25_000)
expect(result!.lines[1].account_number).toBe('2153')
expect(result!.lines[1].credit_amount).toBe(25_000)
expect(result!.signedAmount).toBe(25_000)
expect(result!.warnings).toHaveLength(0)
})
@@ -80,6 +115,8 @@ describe('proposeOveravskrivningar', () => {
expect(result!.lines[0].debit_amount).toBe(10_000)
expect(result!.lines[1].account_number).toBe('8853')
expect(result!.lines[1].credit_amount).toBe(10_000)
expect(result!.signedAmount).toBe(-10_000)
expect(result!.required).toBe(true)
expect(result!.warnings).toHaveLength(1)
})
@@ -87,11 +124,11 @@ describe('proposeOveravskrivningar', () => {
expect(proposeOveravskrivningar({ additionalAmount: 0 })).toBeNull()
})
it('rounds fractional input to whole krona', () => {
const result = proposeOveravskrivningar({ additionalAmount: 1234.7 })
expect(result!.amount).toBe(1_235)
expect(result!.lines[0].debit_amount).toBe(1_235)
expect(result!.lines[1].credit_amount).toBe(1_235)
it('rounds fractional input to öre', () => {
const result = proposeOveravskrivningar({ additionalAmount: 1234.567 })
expect(result!.amount).toBe(1_234.57)
expect(result!.lines[0].debit_amount).toBe(1_234.57)
expect(result!.lines[1].credit_amount).toBe(1_234.57)
})
it('uses building accounts 8852/2152 when category=building', () => {