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:
co-authored by
Claude Fable 5
parent
16fbcefbbc
commit
6782da3e9e
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user