Files
accounted/lib/bokslut/__tests__/tax-depreciation-service.test.ts
T
MattssonandClaude Fable 5 5ca64bde30 feat(bokslut): IL 18 kap pooled tax depreciation with method election (#1393)
* 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>
2026-08-04 11:43:58 +02:00

239 lines
7.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { Asset } from '@/types'
import {
buildTaxDepreciationPopulation,
findImmediatePreviousTaxPeriod,
resolveTaxDepreciationOpening,
taxDepreciationSnapshotMatches,
type TaxDepreciationSnapshot,
} from '../assets/tax-depreciation-service'
import { computeTaxDepreciation } from '../assets/tax-depreciation'
function asset(overrides: Partial<Asset>): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'company-1',
name: 'Asset',
category: 'equipment',
acquisition_date: '2024-01-01',
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: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
}
}
function period(
id: string,
name: string,
periodStart: string,
periodEnd: string,
) {
return {
id,
name,
period_start: periodStart,
period_end: periodEnd,
previous_period_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
tax_depreciation_method: null,
tax_depreciation_rule: null,
tax_depreciation_opening_value: null,
tax_depreciation_base: null,
tax_depreciation_deduction: null,
tax_depreciation_closing_value: null,
tax_depreciation_calculation: null,
}
}
describe('buildTaxDepreciationPopulation', () => {
it('builds a company pool with net prior-year disposals and fiscal-year cohorts', () => {
const periods = [
period('period-2024', '2024', '2024-01-01', '2024-12-31'),
period('period-2025', '2025', '2025-01-01', '2025-12-31'),
]
const population = buildTaxDepreciationPopulation(
[
asset({ id: 'prior-held' }),
asset({
id: 'current-held',
category: 'computer',
acquisition_date: '2025-11-01',
acquisition_cost: 50_000,
}),
asset({
id: 'prior-sold',
category: 'vehicle',
disposed_at: '2025-06-30',
disposed_proceeds: 125_000,
disposed_proceeds_vat: 25_000,
}),
asset({
id: 'current-sold',
acquisition_date: '2025-02-01',
acquisition_cost: 30_000,
disposed_at: '2025-10-01',
disposed_proceeds: 40_000,
}),
asset({
id: 'building',
category: 'building',
acquisition_cost: 200_000,
}),
],
periods[1],
periods,
1,
)
expect(population.additions).toBe(50_000)
expect(population.disposals).toBe(100_000)
expect(population.periodMonths).toBe(12)
expect(population.eligibleAssetCount).toBe(2)
expect(population.excludedAssetCount).toBe(1)
expect(population.excludedCategories).toEqual(['building'])
expect(population.cohortHistoryComplete).toBe(true)
expect(population.incompleteCohortCount).toBe(0)
expect(population.cohorts).toEqual([
{ label: '2025', acquisitionCost: 50_000, elapsedMonths: 12 },
{ label: '2024', acquisitionCost: 100_000, elapsedMonths: 24 },
])
})
})
describe('findImmediatePreviousTaxPeriod', () => {
it('does not skip an unsaved immediate period to reuse an older saved snapshot', () => {
const saved2024 = {
...period('period-2024', '2024', '2024-01-01', '2024-12-31'),
tax_depreciation_method: 'rakenskapsenlig' as const,
tax_depreciation_rule: 'huvudregel_30' as const,
tax_depreciation_opening_value: 100_000,
tax_depreciation_base: 100_000,
tax_depreciation_deduction: 30_000,
tax_depreciation_closing_value: 70_000,
}
const unsaved2025 = period('period-2025', '2025', '2025-01-01', '2025-12-31')
const current2026 = {
...period('period-2026', '2026', '2026-01-01', '2026-12-31'),
previous_period_id: 'period-2025',
}
expect(findImmediatePreviousTaxPeriod(current2026, [saved2024, unsaved2025, current2026]))
.toBe(unsaved2025)
})
it('uses only a date-adjacent fallback when the explicit chain is missing', () => {
const distant = period('period-2024', '2024', '2024-01-01', '2024-12-31')
const adjacent = period('period-2025', '2025', '2025-01-01', '2025-12-31')
const current = period('period-2026', '2026', '2026-01-01', '2026-12-31')
expect(findImmediatePreviousTaxPeriod(current, [distant, adjacent, current])).toBe(adjacent)
expect(findImmediatePreviousTaxPeriod(current, [distant, current])).toBeNull()
})
it('rejects a non-adjacent explicit predecessor link', () => {
const distant = period('period-2024', '2024', '2024-01-01', '2024-12-31')
const current = {
...period('period-2026', '2026', '2026-01-01', '2026-12-31'),
previous_period_id: distant.id,
}
expect(findImmediatePreviousTaxPeriod(current, [distant, current])).toBeNull()
})
})
describe('complementary-rule cohort history', () => {
it('does not invent elapsed months when an acquisition period is missing', () => {
const current = period('period-2026', '2026', '2026-01-01', '2026-12-31')
const population = buildTaxDepreciationPopulation(
[asset({ acquisition_date: '2024-06-01' })],
current,
[current],
0,
)
expect(population.cohorts).toEqual([])
expect(population.cohortHistoryComplete).toBe(false)
expect(population.incompleteCohortCount).toBe(1)
})
})
describe('tax depreciation opening continuity', () => {
const currentSnapshot: TaxDepreciationSnapshot = {
method: 'rakenskapsenlig',
selectedRule: 'huvudregel_30',
openingTaxValue: 70_000,
basis: 70_000,
deduction: 20_000,
closingTaxValue: 50_000,
calculation: null,
}
it('keeps the immediate previous closing authoritative after the current year is saved', () => {
const previousSnapshot = {
...currentSnapshot,
openingTaxValue: 100_000,
basis: 100_000,
deduction: 30_000,
closingTaxValue: 70_000,
}
expect(resolveTaxDepreciationOpening(currentSnapshot, previousSnapshot, true)).toEqual({
value: 70_000,
source: 'previous_period',
})
})
it('marks a saved successor stale when the previous closing value changes', () => {
const changedPrevious = { ...currentSnapshot, closingTaxValue: 65_000 }
const opening = resolveTaxDepreciationOpening(currentSnapshot, changedPrevious, true)
expect(opening.value).toBe(65_000)
const staleInput = {
method: 'rakenskapsenlig' as const,
selectedRule: 'huvudregel_30' as const,
openingTaxValue: opening.value ?? 0,
additions: 0,
disposals: 0,
periodMonths: 12,
cohorts: [],
}
// The saved 20 000 kr election now exceeds the statutory maximum
// (30% of 65 000 = 19 500), so recomputing with it must reject...
expect(() =>
computeTaxDepreciation({ ...staleInput, electedDeduction: currentSnapshot.deduction }),
).toThrow(/electedDeduction/)
// ...and the statutory recomputation no longer matches the snapshot,
// which is what flags it as stale in the view.
const recomputed = computeTaxDepreciation(staleInput)
expect(taxDepreciationSnapshotMatches(currentSnapshot, recomputed)).toBe(false)
})
it('blocks a saved current year when its immediate predecessor has no snapshot', () => {
expect(resolveTaxDepreciationOpening(currentSnapshot, null, true)).toEqual({
value: null,
source: 'previous_period_required',
})
})
})