* feat(bokslut): IL 18 kap pooled tax depreciation with method election Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25 as a company-level annual pool separate from per-asset book depreciation. Method election persisted with immutable snapshots and book-conformity confirmation for rakenskapsenlig. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): move tax depreciation migrations to coordinated versions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): keep tax depreciation view loadable when a saved election goes stale A predecessor's changed closing value can push a saved elected deduction above the new statutory maximum; the view now falls back to the statutory recomputation so the snapshot is flagged stale instead of crashing. Ratchet naive-ore-round baseline down by 3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): resolve tax-depreciation period selects statically The no-phantom-columns guard counts every select it cannot resolve toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo 4 over (364 > 360). Inline the literal column list at the four call sites so the guard verifies these columns instead of skipping them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): address review findings on tax depreciation election - DepreciationPanel: gate the saving flag on a dedicated save sequence so a successful save (which refreshes the view and bumps the request version) no longer leaves the card permanently busy - computeTaxDepreciation: refuse kompletteringsregel_20 with a positive basis and no acquisition cohorts instead of degenerating to a full write-off the cohort evidence does not support (IL 18 kap. 17 §) - migration 227000: judge the asset-method guards on NEW.disposed_at so reversing a disposal cannot reactivate a grandfathered non-linear row - migration 227200: require snapshot column completeness in the CHECK; SQL NULL semantics let partially populated snapshots pass the pure arithmetic comparisons - depreciation route: use the string issue code 'custom' like the rest of the codebase instead of the Zod 3 compat enum Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
173 lines
5.1 KiB
TypeScript
173 lines
5.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
computeTaxDepreciation,
|
|
fiscalPeriodMonths,
|
|
} from '../assets/tax-depreciation'
|
|
|
|
describe('computeTaxDepreciation', () => {
|
|
it('calculates the pooled 30 percent main rule after additions and disposals', () => {
|
|
const result = computeTaxDepreciation({
|
|
method: 'rakenskapsenlig',
|
|
selectedRule: 'huvudregel_30',
|
|
openingTaxValue: 100_000,
|
|
additions: 50_000,
|
|
disposals: 20_000,
|
|
periodMonths: 12,
|
|
cohorts: [],
|
|
})
|
|
|
|
expect(result.basis).toBe(130_000)
|
|
expect(result.maximumDeduction).toBe(39_000)
|
|
expect(result.deduction).toBe(39_000)
|
|
expect(result.closingTaxValue).toBe(91_000)
|
|
})
|
|
|
|
it('reduces only by net disposal proceeds and floors a negative basis at zero', () => {
|
|
const result = computeTaxDepreciation({
|
|
method: 'restvarde',
|
|
openingTaxValue: 10_000,
|
|
additions: 0,
|
|
disposals: 12_500,
|
|
periodMonths: 12,
|
|
cohorts: [],
|
|
})
|
|
|
|
expect(result.basis).toBe(0)
|
|
expect(result.excessDisposals).toBe(2_500)
|
|
expect(result.deduction).toBe(0)
|
|
})
|
|
|
|
it('uses 80, 60, 40, 20 and 0 percent closing cohorts for the 20 percent rule', () => {
|
|
const result = computeTaxDepreciation({
|
|
method: 'rakenskapsenlig',
|
|
selectedRule: 'kompletteringsregel_20',
|
|
openingTaxValue: 400_000,
|
|
additions: 100_000,
|
|
disposals: 0,
|
|
periodMonths: 12,
|
|
cohorts: [12, 24, 36, 48, 60].map((elapsedMonths, index) => ({
|
|
label: String(index),
|
|
acquisitionCost: 100_000,
|
|
elapsedMonths,
|
|
})),
|
|
})
|
|
|
|
expect(result.cohorts.map((cohort) => cohort.closingValue)).toEqual([
|
|
80_000,
|
|
60_000,
|
|
40_000,
|
|
20_000,
|
|
0,
|
|
])
|
|
expect(result.closingTaxValue).toBe(200_000)
|
|
expect(result.deduction).toBe(300_000)
|
|
})
|
|
|
|
it('lets the annual räkenskapsenlig election switch between 30 and 20 alternatives', () => {
|
|
const shared = {
|
|
method: 'rakenskapsenlig' as const,
|
|
openingTaxValue: 70_000,
|
|
additions: 100_000,
|
|
disposals: 0,
|
|
periodMonths: 12,
|
|
cohorts: [{ label: '2025', acquisitionCost: 100_000, elapsedMonths: 12 }],
|
|
}
|
|
const main = computeTaxDepreciation({ ...shared, selectedRule: 'huvudregel_30' })
|
|
const complement = computeTaxDepreciation({
|
|
...shared,
|
|
selectedRule: 'kompletteringsregel_20',
|
|
})
|
|
|
|
expect(main.closingTaxValue).toBe(119_000)
|
|
expect(complement.closingTaxValue).toBe(80_000)
|
|
expect(main.alternatives).toEqual(complement.alternatives)
|
|
})
|
|
|
|
it('stores an elected deduction below the statutory maximum without changing the maximum', () => {
|
|
const result = computeTaxDepreciation({
|
|
method: 'rakenskapsenlig',
|
|
selectedRule: 'huvudregel_30',
|
|
openingTaxValue: 100_000,
|
|
additions: 0,
|
|
disposals: 0,
|
|
periodMonths: 12,
|
|
cohorts: [],
|
|
electedDeduction: 20_000,
|
|
})
|
|
|
|
expect(result.maximumDeduction).toBe(30_000)
|
|
expect(result.deduction).toBe(20_000)
|
|
expect(result.closingTaxValue).toBe(80_000)
|
|
})
|
|
|
|
it('rejects an elected deduction above the statutory maximum', () => {
|
|
expect(() => computeTaxDepreciation({
|
|
method: 'restvarde',
|
|
openingTaxValue: 100_000,
|
|
additions: 0,
|
|
disposals: 0,
|
|
periodMonths: 12,
|
|
cohorts: [],
|
|
electedDeduction: 25_001,
|
|
})).toThrow(/must not exceed the statutory maximum/)
|
|
})
|
|
|
|
it('adjusts 30 and 25 percent proportionally for short and long fiscal periods', () => {
|
|
const short = computeTaxDepreciation({
|
|
method: 'rakenskapsenlig',
|
|
selectedRule: 'huvudregel_30',
|
|
openingTaxValue: 100_000,
|
|
additions: 0,
|
|
disposals: 0,
|
|
periodMonths: 6,
|
|
cohorts: [],
|
|
})
|
|
const long = computeTaxDepreciation({
|
|
method: 'restvarde',
|
|
openingTaxValue: 100_000,
|
|
additions: 0,
|
|
disposals: 0,
|
|
periodMonths: 18,
|
|
cohorts: [],
|
|
})
|
|
|
|
expect(short.deduction).toBe(15_000)
|
|
expect(long.deduction).toBe(37_500)
|
|
})
|
|
|
|
it('adjusts the 20 percent cohort rate for a short fiscal period', () => {
|
|
const result = computeTaxDepreciation({
|
|
method: 'rakenskapsenlig',
|
|
selectedRule: 'kompletteringsregel_20',
|
|
openingTaxValue: 0,
|
|
additions: 100_000,
|
|
disposals: 0,
|
|
periodMonths: 6,
|
|
cohorts: [{ label: 'short', acquisitionCost: 100_000, elapsedMonths: 6 }],
|
|
})
|
|
|
|
expect(result.closingTaxValue).toBe(90_000)
|
|
expect(result.deduction).toBe(10_000)
|
|
})
|
|
|
|
it('rejects a 20 percent rule selection for rest value depreciation', () => {
|
|
expect(() => computeTaxDepreciation({
|
|
method: 'restvarde',
|
|
selectedRule: 'kompletteringsregel_20',
|
|
openingTaxValue: 100_000,
|
|
additions: 0,
|
|
disposals: 0,
|
|
periodMonths: 12,
|
|
cohorts: [],
|
|
})).toThrow(/only valid for rakenskapsenlig/)
|
|
})
|
|
})
|
|
|
|
describe('fiscalPeriodMonths', () => {
|
|
it('counts calendar months inclusively for normal, short and long years', () => {
|
|
expect(fiscalPeriodMonths('2025-01-01', '2025-12-31')).toBe(12)
|
|
expect(fiscalPeriodMonths('2025-07-01', '2025-12-31')).toBe(6)
|
|
expect(fiscalPeriodMonths('2024-07-01', '2025-12-31')).toBe(18)
|
|
})
|
|
})
|