Files
accounted/lib/bokslut/reserves/overavskrivningar-service.ts
T
MattssonandClaude Fable 5 6782da3e9e 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>
2026-08-03 17:25:30 +02:00

218 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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
* 5 år (20 % avskrivning per år, raklinje). */
export const OVERAVSKRIVNING_20_RULE_YEARS = 5
export interface Compute30RuleInput {
/** 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 minus 4 till
* innevarande år. Index 0 = innevarande år. */
acquisitionCostByYearOffset: [number, number, number, number, number]
}
/**
* 30-regeln: skattemässigt lägsta restvärde = 70 % × (IB + årets anskaffningar − årets avyttringar).
* Maximalt skattemässigt avskrivningsbart belopp = avskrivningsunderlag − restvärde.
*/
export function compute30Rule(input: Compute30RuleInput): {
base: number
minimumResidual: number
maxAllowedAccumulated: number
} {
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: roundOre(base - minimumResidual),
}
}
/**
* 20-regeln: varje årsanskaffning får skrivas av med 20 % under 5 år. Lägsta
* 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.
*/
export function compute20Rule(input: Compute20RuleInput): {
minimumResidual: number
} {
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 < 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: roundOre(residual) }
}
/**
* Pick the rule that gives the lowest restvärde (highest allowed deduction)
* per IL 18 kap 13-17 §§: företaget får välja den fördelaktigaste regeln
* varje år.
*/
export function pickLowerResidual(
rule30: ReturnType<typeof compute30Rule>,
rule20: ReturnType<typeof compute20Rule>,
): { residual: number; rule: '30-regeln' | '20-regeln' } {
if (rule20.minimumResidual < rule30.minimumResidual) {
return { residual: rule20.minimumResidual, rule: '20-regeln' }
}
return { residual: rule30.minimumResidual, rule: '30-regeln' }
}
/**
* BAS account pairs for överavskrivningar by asset category. The 88xx
* "förändring" account always pairs with its matching 21xx "ackumulerade"
* account so the verifikation stays balanced and flows into the right INK2R
* field via the SRU mapping.
*
* - 8853 / 2153: maskiner & inventarier (IL 18 kap, dominant K2 case)
* - 8852 / 2152: byggnader (IL 19 kap, rare in SME)
* - 8851 / 2151: immateriella tillgångar (IL 16 kap, even rarer)
* - 8850 / 2150: samlingskonto för grupp
*/
export const OVERAVSKRIVNING_ACCOUNTS = {
machinery_equipment: { expense: '8853', accumulated: '2153' },
building: { expense: '8852', accumulated: '2152' },
immaterial: { expense: '8851', accumulated: '2151' },
group: { expense: '8850', accumulated: '2150' },
} as const
export type OveravskrivningCategory = keyof typeof OVERAVSKRIVNING_ACCOUNTS
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
* for buildings or immateriella tillgångar when relevant. */
category?: OveravskrivningCategory
/** Visa beräkningens bakgrund i UI:t. Helt fritt format. */
computation?: Record<string, unknown>
}
/**
* Propose an överavskrivningar entry. Caller computes the desired delta
* using compute30Rule / compute20Rule (or enters a manual amount) and the
* service emits the verifikation. Uses BAS 8853 / 2153 (maskiner & inventarier),
* which covers the vast majority of cases for small AB.
*
* For Phase 2 this is a thin wrapper. Phase 3 (anläggningsregister) will
* compute the delta automatically from per-asset planenlig vs skattemässig
* schedules and pre-fill `additionalAmount`.
*/
const CATEGORY_LABELS: Record<OveravskrivningCategory, string> = {
machinery_equipment: 'maskiner & inventarier',
building: 'byggnader',
immaterial: 'immateriella tillgångar',
group: 'samlingskonto',
}
export function proposeOveravskrivningar(input: OveravskrivningarInput): ProposedDisposition | null {
const amount = roundOre(input.additionalAmount)
if (amount === 0) return null
const category = input.category ?? 'machinery_equipment'
const accounts = OVERAVSKRIVNING_ACCOUNTS[category]
const categoryLabel = CATEGORY_LABELS[category]
if (amount > 0) {
return {
kind: 'overavskrivningar',
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,
debit_amount: amount,
credit_amount: 0,
line_description: 'Förändring av överavskrivningar',
},
{
account_number: accounts.accumulated,
debit_amount: 0,
credit_amount: amount,
line_description: 'Ackumulerade överavskrivningar',
},
],
warnings: [],
computation: { ...input.computation, additionalAmount: amount },
}
}
// Negative = upplösning av tidigare överavskrivning
const absAmount = Math.abs(amount)
return {
kind: 'overavskrivningar',
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,
debit_amount: absAmount,
credit_amount: 0,
line_description: 'Upplösning ackumulerade överavskrivningar',
},
{
account_number: accounts.expense,
debit_amount: 0,
credit_amount: absAmount,
line_description: 'Förändring av överavskrivningar',
},
],
warnings: ['Negativ förändring återför tidigare överavskrivning och ökar skattepliktigt resultat.'],
computation: { ...input.computation, additionalAmount: -absAmount },
required: true,
}
}