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>
160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import {
|
||
compute30Rule,
|
||
compute20Rule,
|
||
compute20RuleForFiscalPeriods,
|
||
pickLowerResidual,
|
||
proposeOveravskrivningar,
|
||
OVERAVSKRIVNING_30_RULE,
|
||
} from '../reserves/overavskrivningar-service'
|
||
|
||
describe('compute30Rule', () => {
|
||
it('returns minimum residual = 70% of base', () => {
|
||
const result = compute30Rule({
|
||
openingBookValue: 100_000,
|
||
additions: 50_000,
|
||
disposals: 20_000,
|
||
})
|
||
// base = 100 + 50 - 20 = 130_000, residual = 91_000, maxAllowed = 39_000
|
||
expect(result.base).toBe(130_000)
|
||
expect(result.minimumResidual).toBe(91_000)
|
||
expect(result.maxAllowedAccumulated).toBe(39_000)
|
||
})
|
||
|
||
it('uses 0.7 rate per constant', () => {
|
||
expect(OVERAVSKRIVNING_30_RULE).toBe(0.7)
|
||
})
|
||
})
|
||
|
||
describe('compute20Rule', () => {
|
||
it('residual = sum of cost × (5 − age) / 5 per cohort', () => {
|
||
const result = compute20Rule({
|
||
// current year, year-1, year-2, year-3, year-4
|
||
acquisitionCostByYearOffset: [100_000, 100_000, 100_000, 100_000, 100_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(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)
|
||
})
|
||
})
|
||
|
||
describe('pickLowerResidual', () => {
|
||
it('returns whichever rule yields the lower residual (= more deduction)', () => {
|
||
const r30 = { base: 100_000, minimumResidual: 70_000, maxAllowedAccumulated: 30_000 }
|
||
const r20 = { minimumResidual: 60_000 }
|
||
const pick = pickLowerResidual(r30, r20)
|
||
expect(pick.residual).toBe(60_000)
|
||
expect(pick.rule).toBe('20-regeln')
|
||
})
|
||
|
||
it('prefers 30-rule on tie (default behaviour)', () => {
|
||
const r30 = { base: 100_000, minimumResidual: 70_000, maxAllowedAccumulated: 30_000 }
|
||
const r20 = { minimumResidual: 70_000 }
|
||
const pick = pickLowerResidual(r30, r20)
|
||
expect(pick.rule).toBe('30-regeln')
|
||
})
|
||
})
|
||
|
||
describe('proposeOveravskrivningar', () => {
|
||
it('emits balanced 8853 / 2153 entry for positive amount', () => {
|
||
const result = proposeOveravskrivningar({ additionalAmount: 25_000 })
|
||
expect(result).not.toBeNull()
|
||
expect(result!.amount).toBe(25_000)
|
||
expect(result!.lines[0].account_number).toBe('8853')
|
||
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)
|
||
})
|
||
|
||
it('emits reversal entry for negative amount with warning', () => {
|
||
const result = proposeOveravskrivningar({ additionalAmount: -10_000 })
|
||
expect(result).not.toBeNull()
|
||
expect(result!.amount).toBe(10_000)
|
||
expect(result!.lines[0].account_number).toBe('2153')
|
||
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)
|
||
})
|
||
|
||
it('returns null for zero amount', () => {
|
||
expect(proposeOveravskrivningar({ additionalAmount: 0 })).toBeNull()
|
||
})
|
||
|
||
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', () => {
|
||
const result = proposeOveravskrivningar({
|
||
additionalAmount: 50_000,
|
||
category: 'building',
|
||
})
|
||
expect(result!.lines[0].account_number).toBe('8852')
|
||
expect(result!.lines[1].account_number).toBe('2152')
|
||
expect(result!.label).toContain('byggnader')
|
||
})
|
||
|
||
it('uses immaterial accounts 8851/2151 when category=immaterial', () => {
|
||
const result = proposeOveravskrivningar({
|
||
additionalAmount: 30_000,
|
||
category: 'immaterial',
|
||
})
|
||
expect(result!.lines[0].account_number).toBe('8851')
|
||
expect(result!.lines[1].account_number).toBe('2151')
|
||
expect(result!.label).toContain('immateriella')
|
||
})
|
||
|
||
it('defaults to maskiner & inventarier (8853/2153) when no category given', () => {
|
||
const result = proposeOveravskrivningar({ additionalAmount: 20_000 })
|
||
expect(result!.lines[0].account_number).toBe('8853')
|
||
expect(result!.lines[1].account_number).toBe('2153')
|
||
})
|
||
})
|