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
parent 16fbcefbbc
commit 6782da3e9e
11 changed files with 1229 additions and 43 deletions
+1
View File
@@ -747,3 +747,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-03] Shared format contracts centralised in lib/invariants/ (org number, BAS account number, ISO date, fiscal year), each with its rationale recorded next to the rule. Trigger: four Skatteverket/Bolagsverket-bound export paths (KU10, AGI, SRU redovisare, iXBRL preflight) each had their own idea of a valid organisationsnummer, so a company stored with a space or in 12-digit form could file AGI all year and fail at the arsredovisning deadline. normalizeOrgNumber moved from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both old paths re-export. The iXBRL check-digit verdict is warn, not error: we do not block a statutory filing on a Luhn assumption unverified against a primary source. KU10 12-digit passthrough pinned by test, not changed (open domain question). ROT/RUT brf_org_number left alone: different documented contract. Ratchet guard 8 holds the remaining 114 inline copies.
[2026-08-03] CI gained a pg-upgrade job: apply the merge-base schema, seed real rows, apply ONLY the PR migrations, assert the data survived. Rationale: pg-real applies all 548 migrations to an EMPTY database, so a NOT NULL / CHECK / unique index / backfill passes against zero rows and can still break prod. Proven locally against supabase/postgres:15.8.1.060 with three bad migrations: a CHECK violating an ore-level row and a NOT NULL on a populated column both exit 0 on empty and exit 3 on seeded. Base migrations are read from the merge-base git tree, not the working tree, so a PR that edits a shipped migration still surfaces here.
[2026-08-03] Issue #323 automatic excess depreciation is limited to reconciled IL 18 machinery and equipment with linear book depreciation and posts 8853/2153: buildings, intangible assets, and the 25 percent rest-value method follow separate rules, so calculation fails closed on an incomplete register or unposted planned depreciation.
@@ -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
@@ -255,6 +255,15 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
/>
)}
{proposal.warnings?.map((warning) => (
<Card key={warning}>
<CardContent className="p-4 text-sm text-warning-foreground flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>{warning}</span>
</CardContent>
</Card>
))}
{(proposal.completedDispositions ?? []).map((completed) => (
<Card key={`completed-${completed.kind}`}>
<CardHeader>
@@ -486,7 +495,7 @@ function ProposalCard({
lockedSkip: boolean
onChange: (next: { accept?: boolean; overrideAmount?: number }) => void
}) {
const overridable = isOverridable(proposal.kind)
const overridable = isOverridable(proposal)
const displayedAmount = overridable ? overrideAmount ?? proposal.amount : proposal.amount
return (
@@ -553,14 +562,20 @@ function ProposalCard({
)
}
function isOverridable(kind: DispositionKind): boolean {
function isOverridable(proposal: ProposedDisposition): boolean {
// Bolagsskatt and SLP are derived from posted entries: overriding the amount
// would silently break the journal posting (the calculator would still
// recompute server-side). p-fond avsättning and överavskrivningar take a
// desired amount as input, so editing is meaningful. p-fond återföring is
// composed of mandatory cohorts and isn't safely editable from a single
// amount field.
return kind === 'periodiseringsfond_avsattning' || kind === 'overavskrivningar'
return (
proposal.kind === 'periodiseringsfond_avsattning'
|| (
proposal.kind === 'overavskrivningar'
&& (proposal.signedAmount ?? proposal.amount) > 0
)
)
}
function proposalKey(p: ProposedDisposition, index = 0): string {
@@ -605,12 +620,16 @@ function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[]
if (account) ateforingReturns[account] = p.amount
break
}
case 'overavskrivningar':
case 'overavskrivningar': {
const signedDefault = p.signedAmount ?? p.amount
const selectedAmount = sel.overrideAmount ?? p.amount
items.push({
kind: 'overavskrivningar',
additionalAmount: sel.overrideAmount ?? p.amount,
additionalAmount:
signedDefault < 0 ? -Math.abs(selectedAmount) : selectedAmount,
})
break
}
case 'uppskjuten_skatt':
// K3 only: server recomputes the amount; client just signals intent.
items.push({ kind: 'uppskjuten_skatt' })
@@ -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', () => {
+31 -6
View File
@@ -21,6 +21,7 @@ import {
proposeAvsattning,
proposeAteforing,
} from './reserves/periodiseringsfond-service'
import { calculateOveravskrivningar } from './reserves/overavskrivningar-calculator'
import type { CompletedDisposition, DispositionsProposal, ProposedDisposition } from './types'
import type { AccountingFramework } from '@/types'
@@ -89,6 +90,7 @@ export async function buildDispositionsProposal(
const proposals: ProposedDisposition[] = []
const completedDispositions: CompletedDisposition[] = []
const warnings: string[] = []
// Dispositions already POSTED in this period (a partially completed
// bokslut run) are excluded from resultBeforeTax like all year_end
@@ -123,6 +125,31 @@ export async function buildDispositionsProposal(
proposals.push(...ateforing.proposals)
const ateforingTotal = ateforing.proposals.reduce((sum, p) => sum + p.amount, 0)
const overavskrivningar = await calculateOveravskrivningar({
supabase,
companyId,
fiscalPeriod: period,
entityType,
})
if (overavskrivningar.warning) warnings.push(overavskrivningar.warning)
if (overavskrivningar.proposal) proposals.push(overavskrivningar.proposal)
if (
!overavskrivningar.proposal
&& overavskrivningar.status === 'ready'
&& Math.abs(overavskrivningar.currentPeriodChange) >= 0.01
) {
completedDispositions.push({
kind: 'overavskrivningar',
label: 'Förändring av överavskrivningar',
amount: Math.abs(overavskrivningar.currentPeriodChange),
status: 'booked',
warnings: [],
})
}
const overavskrivningarResultEffect = -(
overavskrivningar.proposal?.signedAmount ?? 0
)
// SLP already posted in this period (resumed run): don't re-propose it
// (that would book it twice) and don't subtract it twice below (its
// effect is already inside postedEffect.total).
@@ -150,6 +177,7 @@ export async function buildDispositionsProposal(
// proposed återföringar and schablonintäkt, minus deductible SLP.
const taxableBeforeAvsattning =
normalizedResultBeforeTax + postedEffect.total + alreadyProvisioned + ateforingTotal
+ overavskrivningarResultEffect
+ ateforing.schablonintaktAmount - (slp?.amount ?? 0)
+ taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome
const avsattning = alreadyProvisioned > 0
@@ -182,6 +210,7 @@ export async function buildDispositionsProposal(
// diverges from what the sequential commit books and from ÅR/INK2.
const resultAfterDispositions =
normalizedResultBeforeTax + postedEffect.total + ateforingTotal
+ overavskrivningarResultEffect
- (avsattning?.amount ?? 0) - (slp?.amount ?? 0)
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
@@ -232,6 +261,7 @@ export async function buildDispositionsProposal(
proposals,
taxAdjustments,
completedDispositions,
warnings,
}
}
@@ -267,13 +297,8 @@ export async function buildLatentTaxProposal(params: {
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
// Pending 21xx postings from the proposals that will commit alongside
// latent tax. Avsättning adds to the reserves (credit 21xx), återföring
// removes (debit 21xx).
// latent tax. Credits add to reserves and debits remove them.
for (const p of proposalsBeforeLatentTax) {
if (
p.kind !== 'periodiseringsfond_avsattning'
&& p.kind !== 'periodiseringsfond_ateforing'
) continue
for (const line of p.lines) {
if (!line.account_number.startsWith('21')) continue
untaxedReserves += (line.credit_amount ?? 0) - (line.debit_amount ?? 0)
@@ -0,0 +1,413 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
import { proposeAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { roundOre } from '@/lib/money'
import type { Asset, AssetCategory } from '@/types'
import type { ProposedDisposition } from '../types'
import {
compute20RuleForFiscalPeriods,
compute30Rule,
pickLowerResidual,
proposeOveravskrivningar,
} from './overavskrivningar-service'
const MACHINERY_RESERVE_ACCOUNT = '2153'
const RECONCILIATION_TOLERANCE = 0.01
const FULL_DEPRECIATION_MONTHS = 60
const ELIGIBLE_CATEGORIES = new Set<AssetCategory>([
'machinery',
'equipment',
'vehicle',
'computer',
'other_tangible',
])
interface FiscalPeriodInput {
id: string
period_start: string
period_end: string
}
interface FiscalPeriodCohort extends FiscalPeriodInput {
months: number
}
export type OveravskrivningarCalculationStatus = 'ready' | 'not_applicable' | 'blocked'
export interface OveravskrivningarCalculation {
status: OveravskrivningarCalculationStatus
warning?: string
proposal: ProposedDisposition | null
currentReserve: number
currentPeriodChange: number
targetReserve: number
maximumSignedChange: number
selectedRule?: '30-regeln' | '20-regeln'
}
export interface CalculateOveravskrivningarInput {
supabase: SupabaseClient
companyId: string
fiscalPeriod: FiscalPeriodInput
entityType?: string
}
/**
* Calculate the maximum lawful closing reserve for machinery and inventory.
*
* The ledger is authoritative for book value and the existing 2153 reserve.
* The asset register supplies acquisition cohorts and disposal proceeds. The
* two sources must reconcile before a proposal is allowed.
*/
export async function calculateOveravskrivningar(
input: CalculateOveravskrivningarInput,
): Promise<OveravskrivningarCalculation> {
const { supabase, companyId, fiscalPeriod } = input
const entityType = input.entityType ?? (await loadEntityType(supabase, companyId))
if (entityType !== 'aktiebolag') return notApplicable()
const [trialBalance, assets, fiscalPeriods] = await Promise.all([
generateTrialBalance(supabase, companyId, fiscalPeriod.id, {
closingEntry: 'include',
}),
listAssets(supabase, companyId),
loadFiscalPeriodCohorts(supabase, companyId, fiscalPeriod),
])
const reserveRow = trialBalance.rows.find(
(row) => row.account_number === MACHINERY_RESERVE_ACCOUNT,
)
const openingReserve = roundMoney(
(reserveRow?.opening_credit ?? 0) - (reserveRow?.opening_debit ?? 0),
)
const currentReserve = roundMoney(
(reserveRow?.closing_credit ?? 0) - (reserveRow?.closing_debit ?? 0),
)
const currentPeriodChange = roundMoney(
(reserveRow?.period_credit ?? 0) - (reserveRow?.period_debit ?? 0),
)
const relevantAssets = assets.filter(
(asset) =>
isEligibleAsset(asset)
&& asset.acquisition_date <= fiscalPeriod.period_end
&& (!asset.disposed_at || asset.disposed_at >= fiscalPeriod.period_start),
)
const activeAtEnd = relevantAssets.filter(
(asset) => !asset.disposed_at || asset.disposed_at > fiscalPeriod.period_end,
)
const ledgerGrossValue = roundMoney(
trialBalance.rows
.filter((row) => isEligibleAcquisitionAccount(row.account_number))
.reduce(
(sum, row) => sum + row.closing_debit - row.closing_credit,
0,
),
)
const registerGrossValue = roundMoney(
activeAtEnd.reduce((sum, asset) => sum + Number(asset.acquisition_cost), 0),
)
if (Math.abs(ledgerGrossValue - registerGrossValue) > RECONCILIATION_TOLERANCE) {
return blocked(
currentReserve,
currentPeriodChange,
'Anläggningsregistret stämmer inte mot bokförda anskaffningsvärden i 12xx. Stäm av registret innan överavskrivningar beräknas.',
)
}
if (relevantAssets.length === 0) {
if (currentReserve <= 0) return notApplicable()
return readyCalculation({
currentReserve,
currentPeriodChange,
targetReserve: 0,
selectedRule: '20-regeln',
computation: {
openingReserve,
closingBookValue: 0,
taxResidual: 0,
reason: 'no_remaining_assets',
},
})
}
if (relevantAssets.some((asset) => asset.depreciation_method !== 'linear')) {
return blocked(
currentReserve,
currentPeriodChange,
'Automatisk överavskrivning kräver planenlig linjär avskrivning för hela 12xx-gruppen. Tillgångar med 30 %, 20 % eller restvärdeavskrivning måste hanteras enligt samma valda skattemetod.',
)
}
const annualPostings = await proposeAnnualPostings(supabase, companyId, fiscalPeriod.id)
const pendingEligibleDepreciation = annualPostings.items.some(
(item) => isEligibleAsset(item.asset) && !item.existingJournalEntryId,
)
if (pendingEligibleDepreciation) {
return blocked(
currentReserve,
currentPeriodChange,
'Bokför de planenliga avskrivningarna först. Därefter kan överavskrivningen beräknas på rätt bokfört restvärde.',
)
}
const acquisitionAccounts = new Set(relevantAssets.map((asset) => asset.bas_asset_account))
const accumulatedAccounts = new Set(
relevantAssets.map((asset) => asset.bas_accumulated_account),
)
const assetRows = trialBalance.rows.filter(
(row) =>
acquisitionAccounts.has(row.account_number)
|| accumulatedAccounts.has(row.account_number),
)
const openingBookValue = roundMoney(
assetRows.reduce(
(sum, row) => sum + row.opening_debit - row.opening_credit,
0,
),
)
const closingBookValue = roundMoney(
assetRows.reduce(
(sum, row) => sum + row.closing_debit - row.closing_credit,
0,
),
)
if (openingBookValue < -RECONCILIATION_TOLERANCE || closingBookValue < -RECONCILIATION_TOLERANCE) {
return blocked(
currentReserve,
currentPeriodChange,
'Bokfört restvärde för maskiner och inventarier är negativt. Rätta bokföringen innan överavskrivningar beräknas.',
)
}
const additions = roundMoney(
relevantAssets
.filter(
(asset) =>
asset.acquisition_date >= fiscalPeriod.period_start
&& asset.acquisition_date <= fiscalPeriod.period_end,
)
.reduce((sum, asset) => sum + Number(asset.acquisition_cost), 0),
)
const disposals = roundMoney(
relevantAssets
.filter(
(asset) =>
Boolean(asset.disposed_at)
&& asset.disposed_at! >= fiscalPeriod.period_start
&& asset.disposed_at! <= fiscalPeriod.period_end,
)
.reduce(
(sum, asset) =>
sum
+ Math.max(
0,
Number(asset.disposed_proceeds ?? 0) - Number(asset.disposed_proceeds_vat ?? 0),
),
0,
),
)
const openingTaxValue = Math.max(0, roundMoney(openingBookValue - openingReserve))
const rule30 = compute30Rule({
openingBookValue: openingTaxValue,
additions,
disposals,
fiscalPeriodMonths: countFiscalMonths(
fiscalPeriod.period_start,
fiscalPeriod.period_end,
),
})
const acquisitionCostByCohort = fiscalPeriods.map(() => 0)
for (const asset of activeAtEnd) {
const cohortIndex = fiscalPeriods.findIndex(
(period) =>
asset.acquisition_date >= period.period_start
&& asset.acquisition_date <= period.period_end,
)
if (cohortIndex >= 0) {
acquisitionCostByCohort[cohortIndex] += Number(asset.acquisition_cost)
continue
}
if (monthsBetween(asset.acquisition_date, fiscalPeriod.period_end) < FULL_DEPRECIATION_MONTHS) {
return blocked(
currentReserve,
currentPeriodChange,
'Tidigare räkenskapsperioder saknas för en tillgång som ännu omfattas av 20-regeln. Komplettera periodhistoriken innan överavskrivningar beräknas.',
)
}
}
const rule20 = compute20RuleForFiscalPeriods({
acquisitionCostByPeriod: acquisitionCostByCohort,
fiscalPeriodMonths: fiscalPeriods.map((period) => period.months),
})
const selected = pickLowerResidual(rule30, rule20)
const targetReserve = Math.max(
0,
Math.min(closingBookValue, roundMoney(closingBookValue - selected.residual)),
)
return readyCalculation({
currentReserve,
currentPeriodChange,
targetReserve,
selectedRule: selected.rule,
computation: {
openingBookValue,
openingReserve,
openingTaxValue,
additions,
disposals,
closingBookValue,
rule30Residual: rule30.minimumResidual,
rule20Residual: rule20.minimumResidual,
taxResidual: selected.residual,
selectedRule: selected.rule,
targetReserve,
},
})
}
function readyCalculation(input: {
currentReserve: number
currentPeriodChange: number
targetReserve: number
selectedRule: '30-regeln' | '20-regeln'
computation: Record<string, unknown>
}): OveravskrivningarCalculation {
const maximumSignedChange = roundMoney(input.targetReserve - input.currentReserve)
// A positive current-period posting records the user's optional deduction
// choice. Do not propose another increase on reload. A reserve above the
// lawful target is different: its release remains mandatory.
const proposalChange =
maximumSignedChange > 0 && Math.abs(input.currentPeriodChange) > RECONCILIATION_TOLERANCE
? 0
: maximumSignedChange
const proposal = proposeOveravskrivningar({
additionalAmount: proposalChange,
category: 'machinery_equipment',
computation: input.computation,
})
return {
status: 'ready',
proposal,
currentReserve: input.currentReserve,
currentPeriodChange: input.currentPeriodChange,
targetReserve: input.targetReserve,
maximumSignedChange,
selectedRule: input.selectedRule,
}
}
function blocked(
currentReserve: number,
currentPeriodChange: number,
warning: string,
): OveravskrivningarCalculation {
return {
status: 'blocked',
warning,
proposal: null,
currentReserve,
currentPeriodChange,
targetReserve: currentReserve,
maximumSignedChange: 0,
}
}
function notApplicable(): OveravskrivningarCalculation {
return {
status: 'not_applicable',
proposal: null,
currentReserve: 0,
currentPeriodChange: 0,
targetReserve: 0,
maximumSignedChange: 0,
}
}
async function loadEntityType(supabase: SupabaseClient, companyId: string): Promise<string> {
const { data, error } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Failed to load company entity type: ${error.message}`)
return data?.entity_type ?? 'aktiebolag'
}
async function loadFiscalPeriodCohorts(
supabase: SupabaseClient,
companyId: string,
current: FiscalPeriodInput,
): Promise<FiscalPeriodCohort[]> {
const { data, error } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
.lte('period_end', current.period_end)
.order('period_end', { ascending: false })
.limit(5)
if (error) throw new Error(`Failed to load fiscal period history: ${error.message}`)
const periods = ((data ?? []) as FiscalPeriodInput[]).filter(
(period, index, rows) => rows.findIndex((candidate) => candidate.id === period.id) === index,
)
if (!periods.some((period) => period.id === current.id)) periods.unshift(current)
periods.sort((a, b) => b.period_end.localeCompare(a.period_end))
return periods.slice(0, 5).map((period) => ({
...period,
months: countFiscalMonths(period.period_start, period.period_end),
}))
}
function isEligibleAsset(asset: Asset): boolean {
return (
ELIGIBLE_CATEGORIES.has(asset.category)
&& isEligibleAcquisitionAccount(asset.bas_asset_account)
)
}
function isEligibleAcquisitionAccount(account: string): boolean {
if (!/^12\d{2}$/.test(account)) return false
const numeric = Number(account)
if (numeric >= 1280 && numeric <= 1289) return false
if (account === '1291') return false
return !account.endsWith('8') && !account.endsWith('9')
}
export function countFiscalMonths(periodStart: string, periodEnd: string): number {
const start = new Date(`${periodStart}T00:00:00Z`)
const end = new Date(`${periodEnd}T00:00:00Z`)
return Math.max(
1,
(end.getUTCFullYear() - start.getUTCFullYear()) * 12
+ end.getUTCMonth()
- start.getUTCMonth()
+ 1,
)
}
function monthsBetween(startDate: string, endDate: string): number {
const start = new Date(`${startDate}T00:00:00Z`)
const end = new Date(`${endDate}T00:00:00Z`)
return (
(end.getUTCFullYear() - start.getUTCFullYear()) * 12
+ end.getUTCMonth()
- start.getUTCMonth()
)
}
function roundMoney(value: number): number {
return roundOre(value)
}
@@ -1,7 +1,8 @@
import type { ProposedDisposition } from '../types'
import { roundOre } from '@/lib/money'
/** 30-rule (huvudregel, IL 18 kap 13 §): restvärde minst 70 % av (ingående
* bokfört värde + årets anskaffningar årets försäljningar och utrangeringar). */
/** 30-rule (huvudregel, IL 18 kap 13 §): restvärde minst 70 % av ingående
* bokfört värde plus årets anskaffningar minus årets försäljningar. */
export const OVERAVSKRIVNING_30_RULE = 0.7
/** 20-rule (kompletteringsregel, IL 18 kap 17 §): restvärde minst 0 % efter
@@ -9,16 +10,18 @@ export const OVERAVSKRIVNING_30_RULE = 0.7
export const OVERAVSKRIVNING_20_RULE_YEARS = 5
export interface Compute30RuleInput {
/** IB bokfört värde maskiner & inventarier (12xx netto). */
/** IB skattemässigt värde maskiner & inventarier. */
openingBookValue: number
/** Årets anskaffningar (debet på anskaffningskonto, t.ex. 1220). */
additions: number
/** Försäljningsvärde och utrangering av tillgångar (kredit på anskaffningskonto). */
disposals: number
/** Räkenskapsperiodens längd. 30 % proportioneras för perioder som inte är 12 månader. */
fiscalPeriodMonths?: number
}
export interface Compute20RuleInput {
/** Anskaffningskostnad per anskaffningsår, från (innevarande år 4) till
/** Anskaffningskostnad per anskaffningsår, från innevarande år minus 4 till
* innevarande år. Index 0 = innevarande år. */
acquisitionCostByYearOffset: [number, number, number, number, number]
}
@@ -32,19 +35,21 @@ export function compute30Rule(input: Compute30RuleInput): {
minimumResidual: number
maxAllowedAccumulated: number
} {
const base = input.openingBookValue + input.additions - input.disposals
const minimumResidual = Math.round(base * OVERAVSKRIVNING_30_RULE * 100) / 100
const base = Math.max(0, input.openingBookValue + input.additions - input.disposals)
const periodMonths = input.fiscalPeriodMonths ?? 12
const depreciationRate = Math.min(1, 0.3 * (periodMonths / 12))
const minimumResidual = roundOre(base * (1 - depreciationRate))
return {
base,
minimumResidual,
maxAllowedAccumulated: Math.round((base - minimumResidual) * 100) / 100,
maxAllowedAccumulated: roundOre(base - minimumResidual),
}
}
/**
* 20-regeln: varje årsanskaffning får skrivas av med 20 % under 5 år. Lägsta
* skattemässigt restvärde är summan av 20 % × ((5 offset) / 5) × anskaffningar
* från år (innevarande offset).
* skattemässigt restvärde är anskaffningskostnaden minus 20 % per tolvmånadersperiod,
* inklusive anskaffningsperioden.
*
* Returns the allowed depreciation if 20-rule is used as the sole basis,
* computed against ALL still-active 20-rule cohorts.
@@ -52,15 +57,37 @@ export function compute30Rule(input: Compute30RuleInput): {
export function compute20Rule(input: Compute20RuleInput): {
minimumResidual: number
} {
// Residual per cohort = anskaffningskostnad × (5 ageInYears) / 5.
// ageInYears 0 = current year (residual 100 %), 4 = oldest still-live (20 %).
return compute20RuleForFiscalPeriods({
acquisitionCostByPeriod: input.acquisitionCostByYearOffset,
fiscalPeriodMonths: [12, 12, 12, 12, 12],
})
}
export interface Compute20RuleForFiscalPeriodsInput {
/** Anskaffningskostnad per period. Index 0 är innevarande period. */
acquisitionCostByPeriod: number[]
/** Periodlängd per motsvarande period, nyaste först. */
fiscalPeriodMonths: number[]
}
/**
* Kompletteringsregeln writes off 20 % per twelve fiscal months, including
* the acquisition period. Supplying actual period lengths handles shortened
* and extended fiscal years without shifting the acquisition cohort by a year.
*/
export function compute20RuleForFiscalPeriods(
input: Compute20RuleForFiscalPeriodsInput,
): { minimumResidual: number } {
let residual = 0
for (let offset = 0; offset < OVERAVSKRIVNING_20_RULE_YEARS; offset++) {
const cost = input.acquisitionCostByYearOffset[offset] ?? 0
const remainingFraction = (OVERAVSKRIVNING_20_RULE_YEARS - offset) / OVERAVSKRIVNING_20_RULE_YEARS
residual += cost * remainingFraction
for (let offset = 0; offset < input.acquisitionCostByPeriod.length; offset++) {
const cost = input.acquisitionCostByPeriod[offset] ?? 0
const elapsedMonths = input.fiscalPeriodMonths
.slice(0, offset + 1)
.reduce((sum, months) => sum + months, 0)
const depreciatedFraction = Math.min(1, elapsedMonths / 60)
residual += cost * (1 - depreciatedFraction)
}
return { minimumResidual: Math.round(residual * 100) / 100 }
return { minimumResidual: roundOre(residual) }
}
/**
@@ -102,7 +129,8 @@ export interface OveravskrivningarInput {
/** Föreslagen ökning av ackumulerade överavskrivningar. Positivt belopp
* ökar ackumulerade-kontot (debet 88xx), negativt minskar (kredit 88xx). */
additionalAmount: number
/** Account pair to use. Defaults to maskiner & inventarier (8853 / 2153): * the only category where överavskrivningar is common in K2 SME. Override
/** Account pair to use. Defaults to maskiner & inventarier (8853 / 2153),
* the only category where överavskrivningar is common in K2 SME. Override
* for buildings or immateriella tillgångar when relevant. */
category?: OveravskrivningCategory
/** Visa beräkningens bakgrund i UI:t. Helt fritt format. */
@@ -127,7 +155,7 @@ const CATEGORY_LABELS: Record<OveravskrivningCategory, string> = {
}
export function proposeOveravskrivningar(input: OveravskrivningarInput): ProposedDisposition | null {
const amount = Math.round(input.additionalAmount)
const amount = roundOre(input.additionalAmount)
if (amount === 0) return null
const category = input.category ?? 'machinery_equipment'
@@ -140,6 +168,7 @@ export function proposeOveravskrivningar(input: OveravskrivningarInput): Propose
label: `Ökning av överavskrivningar (${categoryLabel})`,
description: `Debet ${accounts.expense}, kredit ${accounts.accumulated}. Bokför skattemässig avskrivning utöver planenlig.`,
amount,
signedAmount: amount,
lines: [
{
account_number: accounts.expense,
@@ -155,7 +184,7 @@ export function proposeOveravskrivningar(input: OveravskrivningarInput): Propose
},
],
warnings: [],
computation: input.computation,
computation: { ...input.computation, additionalAmount: amount },
}
}
@@ -166,6 +195,7 @@ export function proposeOveravskrivningar(input: OveravskrivningarInput): Propose
label: `Upplösning av överavskrivningar (${categoryLabel})`,
description: `Debet ${accounts.accumulated}, kredit ${accounts.expense}. Återför tidigare gjord överavskrivning.`,
amount: absAmount,
signedAmount: -absAmount,
lines: [
{
account_number: accounts.accumulated,
@@ -181,6 +211,7 @@ export function proposeOveravskrivningar(input: OveravskrivningarInput): Propose
},
],
warnings: ['Negativ förändring återför tidigare överavskrivning och ökar skattepliktigt resultat.'],
computation: input.computation,
computation: { ...input.computation, additionalAmount: -absAmount },
required: true,
}
}
+5
View File
@@ -21,6 +21,9 @@ export interface ProposedDisposition {
description: string
/** SEK amount displayed in the card header. Always a positive number. */
amount: number
/** Signed posting amount when direction matters while `amount` remains a
* positive display value. Used by over-depreciation releases. */
signedAmount?: number
/** Final voucher lines if the user accepts. Already balanced. */
lines: CreateJournalEntryLineInput[]
/** Soft warnings the UI surfaces beside the card (e.g. forced p-fond reversal,
@@ -81,4 +84,6 @@ export interface DispositionsProposal {
proposals: ProposedDisposition[]
taxAdjustments?: TaxAdjustmentSnapshot
completedDispositions?: CompletedDisposition[]
/** Non-blocking calculation warnings surfaced in the statutory wizard. */
warnings?: string[]
}