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
@@ -35,6 +35,10 @@ vi.mock('@/lib/bokslut/reserves/periodiseringsfond-service', async (importOrigin
return { ...actual, listExistingPeriodiseringsfonder: vi.fn() }
})
vi.mock('@/lib/bokslut/reserves/overavskrivningar-calculator', () => ({
calculateOveravskrivningar: vi.fn(),
}))
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
}))
@@ -66,6 +70,7 @@ import {
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { listExistingPeriodiseringsfonder } 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'
@@ -133,6 +138,15 @@ beforeEach(() => {
})
vi.mocked(getBookedBolagsskatt).mockResolvedValue(0)
vi.mocked(listExistingPeriodiseringsfonder).mockResolvedValue([])
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
status: 'not_applicable',
proposal: null,
warning: null,
currentReserve: 0,
currentPeriodChange: 0,
targetReserve: 0,
maximumSignedChange: 0,
})
vi.mocked(calculateBolagsskatt).mockResolvedValue({
kind: 'bolagsskatt',
label: 'Bolagsskatt 20,6 %',
@@ -343,6 +357,247 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () =
expect(createJournalEntry).not.toHaveBeenCalled()
})
it('posts a validated excess depreciation increase through the bookkeeping engine', async () => {
const supabase = periodClient({
id: 'period-1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
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: [],
computation: {
openingBookValue: 80_000,
closingBookValue: 70_000,
openingTaxValue: 80_000,
closingTaxValue: 60_000,
taxDepreciation: 20_000,
bookedDepreciation: 10_000,
maxAdditionalDepreciation: 10_000,
targetReserve: 10_000,
currentReserve: 0,
method: '30-rule',
},
},
warning: null,
currentReserve: 0,
currentPeriodChange: 0,
targetReserve: 10_000,
maximumSignedChange: 10_000,
})
const { status, body } = await parseJsonResponse<{
data: { created: Array<{ kind: string }> }
}>(
await post({
items: [{ kind: 'overavskrivningar', additionalAmount: 8_000 }],
}),
)
expect(status).toBe(200)
expect(body.data.created).toHaveLength(1)
expect(createJournalEntry).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({
fiscal_period_id: 'period-1',
entry_date: '2025-12-31',
source_type: 'year_end',
lines: [
{
account_number: '8853',
debit_amount: 8_000,
credit_amount: 0,
line_description: 'Förändring av överavskrivningar',
},
{
account_number: '2153',
debit_amount: 0,
credit_amount: 8_000,
line_description: 'Ackumulerade överavskrivningar',
},
],
}),
)
})
it('returns 409 when a stale excess depreciation amount exceeds the current maximum', async () => {
const supabase = periodClient({
id: 'period-1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
status: 'ready',
proposal: {
kind: 'overavskrivningar',
label: 'Överavskrivningar',
description: 'Skillnad mellan bokförd och skattemässig avskrivning.',
amount: 5_000,
signedAmount: 5_000,
lines: [
{ account_number: '8853', debit_amount: 5_000, credit_amount: 0 },
{ account_number: '2153', debit_amount: 0, credit_amount: 5_000 },
],
warnings: [],
},
warning: null,
currentReserve: 0,
currentPeriodChange: 0,
targetReserve: 5_000,
maximumSignedChange: 5_000,
})
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post({
items: [{ kind: 'overavskrivningar', additionalAmount: 6_000 }],
}),
)
expect(status).toBe(409)
expect(body.error.code).toBe('CONFLICT')
expect(createJournalEntry).not.toHaveBeenCalled()
})
it('posts a required excess depreciation release with reversed lines', async () => {
const supabase = periodClient({
id: 'period-1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
status: 'ready',
proposal: {
kind: 'overavskrivningar',
label: 'Återföring av överavskrivningar',
description: 'Den skattemässiga reserven måste minskas.',
amount: 10_000,
signedAmount: -10_000,
lines: [
{
account_number: '2153',
debit_amount: 10_000,
credit_amount: 0,
line_description: 'Upplösning ackumulerade överavskrivningar',
},
{
account_number: '8853',
debit_amount: 0,
credit_amount: 10_000,
line_description: 'Förändring av överavskrivningar',
},
],
warnings: [],
required: true,
},
warning: null,
currentReserve: 20_000,
currentPeriodChange: 0,
targetReserve: 10_000,
maximumSignedChange: -10_000,
})
const { status } = await parseJsonResponse(
await post({
items: [{ kind: 'overavskrivningar', additionalAmount: -10_000 }],
}),
)
expect(status).toBe(200)
expect(createJournalEntry).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({
lines: [
{
account_number: '2153',
debit_amount: 10_000,
credit_amount: 0,
line_description: 'Upplösning ackumulerade överavskrivningar',
},
{
account_number: '8853',
debit_amount: 0,
credit_amount: 10_000,
line_description: 'Förändring av överavskrivningar',
},
],
}),
)
})
it('does not post a duplicate excess depreciation decision', async () => {
const supabase = periodClient({
id: 'period-1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
const { status, body } = await parseJsonResponse<{
data: { created: Array<{ kind: string }> }
}>(
await post({
items: [{ kind: 'overavskrivningar', additionalAmount: 8_000 }],
}),
)
expect(status).toBe(200)
expect(body.data.created).toEqual([])
expect(createJournalEntry).not.toHaveBeenCalled()
})
it('returns 409 instead of posting over a different booked tax amount', async () => {
const supabase = periodClient({
id: 'period-1',
@@ -23,7 +23,9 @@ import {
proposeAteforing,
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-service'
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { roundOre } from '@/lib/money'
import {
buildDispositionsProposal,
buildLatentTaxProposal,
@@ -243,6 +245,13 @@ export const POST = withRouteContext(
return NextResponse.json({ data: { created } })
} catch (err) {
if (err instanceof OveravskrivningarConflictError) {
return errorResponseFromCode('CONFLICT', opLog, {
requestId,
messageSv: err.message,
messageEn: err.messageEn,
})
}
if (err instanceof TaxProvisionConflictError) {
return errorResponseFromCode('CONFLICT', opLog, {
requestId,
@@ -295,6 +304,16 @@ class TaxProvisionConflictError extends Error {
}
}
class OveravskrivningarConflictError extends Error {
constructor(
message: string,
readonly messageEn: string,
) {
super(message)
this.name = 'OveravskrivningarConflictError'
}
}
async function computeProposal(
item: PostItem,
supabase: Parameters<typeof calculateBolagsskatt>[0],
@@ -442,11 +461,43 @@ async function computeProposal(
if (result.proposals.length === 0) return null
return mergeAteforingProposals(result.proposals)
}
case 'overavskrivningar':
return proposeOveravskrivningar({
additionalAmount: item.additionalAmount,
category: item.category,
case 'overavskrivningar': {
if (item.category && item.category !== 'machinery_equipment') {
throw new OveravskrivningarConflictError(
'Den automatiska beräkningen omfattar endast maskiner och inventarier enligt IL 18 kap.',
'The automatic calculation only covers machinery and equipment under Chapter 18 of the Income Tax Act.',
)
}
const calculation = await calculateOveravskrivningar({
supabase,
companyId,
fiscalPeriod: period,
})
if (calculation.status === 'blocked') {
throw new OveravskrivningarConflictError(
calculation.warning ?? 'Överavskrivningen kan inte beräknas säkert.',
'The excess depreciation cannot be calculated safely.',
)
}
if (!calculation.proposal) return null
const requested = roundOre(item.additionalAmount)
const maximum = calculation.proposal.signedAmount ?? calculation.proposal.amount
const invalidIncrease = maximum > 0 && (requested <= 0 || requested > maximum)
const invalidRelease = maximum < 0 && requested !== maximum
if (invalidIncrease || invalidRelease) {
throw new OveravskrivningarConflictError(
'Beloppet är inte längre giltigt. Ladda om bokslutet och använd den aktuella beräkningen.',
'The amount is no longer valid. Reload the year-end flow and use the current calculation.',
)
}
return proposeOveravskrivningar({
additionalAmount: requested,
category: 'machinery_equipment',
computation: calculation.proposal.computation,
})
}
case 'uppskjuten_skatt':
// Server-only: recompute from current TB (which already reflects any
// 21xx postings that committed earlier in this batch). The client