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
@@ -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,
}
}