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>
This commit is contained in:
Mattsson
2026-08-04 11:43:58 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 00ae3540db
commit 5ca64bde30
25 changed files with 2863 additions and 657 deletions
@@ -152,180 +152,3 @@ describe('computeAnnualDepreciation', () => {
expect(result.amount).toBeLessThan(1_020)
})
})
// ============================================================
// Declining-balance methods (IL 18 kap 13§ huvudregel + kompletteringsregel)
// ============================================================
//
// Swedish practice: declining methods take the full annual amount regardless
// of acquisition month (K2 10.23: "Full annual amount regardless of partial
// year"). The engine therefore does NOT pro-rate by day-overlap for these
// methods. Disposal during the period still yields the full year because the
// disposal entry zeroes out the remaining book value separately.
describe('computeAnnualDepreciation: declining_balance_30 (räkenskapsenlig huvudregel)', () => {
it('year 1: 100 000 kr × 30 % = 30 000 kr (no prior accumulated)', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(30_000)
expect(result.proRated).toBe(false)
})
it('year 2: book value 70 000 × 30 % = 21 000', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR, 30_000)
expect(result.amount).toBe(21_000)
})
it('year 3: book value 49 000 × 30 % = 14 700', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR, 51_000)
expect(result.amount).toBe(14_700)
})
it('does NOT pro-rate for mid-year acquisition (full annual amount)', () => {
// Acquired July 1: linear would pro-rate to ~50 %. Declining methods
// take the full year amount per K2 10.23 and tax practice.
const asset = makeAsset({
acquisition_cost: 100_000,
acquisition_date: '2025-07-01',
depreciation_method: 'declining_balance_30',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(30_000)
expect(result.proRated).toBe(false)
})
it('returns 0 when book value already at zero (or below)', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
// Prior accumulated ≥ acquisition cost → book value 0.
const result = computeAnnualDepreciation(asset, FULL_YEAR, 100_000)
expect(result.amount).toBe(0)
})
it('returns 0 when asset disposed before period start', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
disposed_at: '2024-12-31',
disposed_proceeds: 50_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(0)
})
})
describe('computeAnnualDepreciation: declining_balance_20 (kompletteringsregel, byggnader)', () => {
it('year 1: 1 000 000 kr building × 20 % = 200 000', () => {
const asset = makeAsset({
category: 'building',
bas_asset_account: '1110',
bas_accumulated_account: '1119',
bas_expense_account: '7821',
acquisition_cost: 1_000_000,
depreciation_method: 'declining_balance_20',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(200_000)
expect(result.proRated).toBe(false)
})
it('year 2: book value 800 000 × 20 % = 160 000', () => {
const asset = makeAsset({
category: 'building',
acquisition_cost: 1_000_000,
depreciation_method: 'declining_balance_20',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR, 200_000)
expect(result.amount).toBe(160_000)
})
})
describe('computeAnnualDepreciation: restvardesavskrivning_25 (IL 18 kap 13§ st.3)', () => {
it('year 1: (100 000 − 20 000 restvärde) × 25 % = 20 000', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 20_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(20_000)
expect(result.proRated).toBe(false)
})
it('year 2: book value 80 000, depreciable (80 000 − 20 000) × 25 % = 15 000', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 20_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR, 20_000)
expect(result.amount).toBe(15_000)
})
it('floors at restvärde: book value already at floor returns 0', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 20_000,
})
// Prior accumulated brings book value to exactly the floor (20 000).
const result = computeAnnualDepreciation(asset, FULL_YEAR, 80_000)
expect(result.amount).toBe(0)
})
it('multi-year convergence: book value approaches restvärde but never goes below', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 20_000,
})
// Simulate 10 years of compounding to verify the floor.
let accumulated = 0
for (let year = 0; year < 10; year++) {
const { amount } = computeAnnualDepreciation(asset, FULL_YEAR, accumulated)
accumulated += amount
}
const finalBookValue = 100_000 - accumulated
expect(finalBookValue).toBeGreaterThanOrEqual(20_000)
// Should be tracking toward the floor: within a kr or two after 10 years.
expect(finalBookValue).toBeLessThan(26_000)
})
it('does NOT pro-rate for mid-year acquisition (full annual amount)', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
acquisition_date: '2025-07-01',
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 20_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(20_000)
expect(result.proRated).toBe(false)
})
it('treats restvarde_target=null as 0 (defensive: DB CHECK should prevent this state)', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: null,
})
// (100 000 − 0) × 25 % = 25 000. The DB CHECK forbids method=restvärde
// with null target, but the engine should still produce a deterministic
// answer rather than crash.
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(25_000)
})
})
@@ -0,0 +1,238 @@
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',
})
})
})
@@ -0,0 +1,172 @@
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)
})
})
@@ -105,28 +105,6 @@ describe('computeAssetNoteFigures', () => {
expect(f).toEqual({ ibAck: 146_000, aretsAvskrivning: 0, avgaendeAck: 0 })
})
it('applies declining_balance_30 on the posted book value', () => {
const asset = makeAsset({
acquisition_date: '2024-01-01',
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
const f = figuresFor(asset, [posted('asset-1', 'fp2024', 30_000)])
// Book value 70,000 * 30% = 21,000
expect(f).toEqual({ ibAck: 30_000, aretsAvskrivning: 21_000, avgaendeAck: 0 })
})
it('floors restvardesavskrivning_25 at the restvarde target', () => {
const asset = makeAsset({
acquisition_date: '2024-01-01',
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 50_000,
})
const f = figuresFor(asset, [posted('asset-1', 'fp2024', 50_000)])
expect(f).toEqual({ ibAck: 50_000, aretsAvskrivning: 0, avgaendeAck: 0 })
})
it('sums K3 component depreciation via the engine fallback', () => {
const asset = makeAsset({
acquisition_date: '2025-01-01',
@@ -330,8 +330,7 @@ describe('computeAnnualDepreciation: K3 dispatch', () => {
it('routes to component depreciation when k3_components is non-empty', () => {
const asset = makeAsset({
acquisition_cost: 1_000_000,
// method+life on the asset would compute different number: engine should ignore them
depreciation_method: 'declining_balance_30',
// Asset-level life would compute a different number: components win.
useful_life_months: 60,
k3_components: [
{ name: 'Tak', cost: 300_000, useful_life_months: 240 },
+10 -58
View File
@@ -10,6 +10,7 @@ import { assessJamkning, assessJamkningEligibility } from './jamkning'
import type {
Asset,
AssetCategory,
WritableDepreciationMethod,
AssetDisposalType,
DepreciationMethod,
FiscalPeriod,
@@ -53,15 +54,13 @@ export interface CreateAssetInput {
acquisition_cost: number
salvage_value?: number
useful_life_months: number
depreciation_method?: DepreciationMethod
/** Required when depreciation_method = 'restvardesavskrivning_25'. */
restvarde_target?: number | null
depreciation_method?: WritableDepreciationMethod
restvarde_target?: null
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
/** K3 component depreciation (BFNAR 2012:1 ch.17.4). When non-null, the
* engine sums per-component linear depreciation instead of applying
* `depreciation_method` to the asset as a whole. The API layer rejects
* engine sums per-component linear depreciation. The API layer rejects
* writes for K2 companies with K3_REQUIRED_FOR_COMPONENTS. */
k3_components?: K3Component[] | null
notes?: string
@@ -81,12 +80,6 @@ export async function createAsset(
input: CreateAssetInput,
): Promise<Asset> {
const defaults = DEFAULT_ACCOUNTS_BY_CATEGORY[input.category]
const method: DepreciationMethod = input.depreciation_method ?? 'linear'
// The DB CHECK constraint enforces the biconditional between method and
// restvarde_target. Pass null explicitly when not restvärde so a stale
// value never leaks through.
const restvarde =
method === 'restvardesavskrivning_25' ? input.restvarde_target ?? null : null
const row = {
user_id: userId,
company_id: companyId,
@@ -96,8 +89,8 @@ export async function createAsset(
acquisition_cost: input.acquisition_cost,
salvage_value: input.salvage_value ?? 0,
useful_life_months: input.useful_life_months,
depreciation_method: method,
restvarde_target: restvarde,
depreciation_method: 'linear' as const,
restvarde_target: null,
bas_asset_account: input.bas_asset_account ?? defaults.asset,
bas_accumulated_account: input.bas_accumulated_account ?? defaults.accumulated,
bas_expense_account: input.bas_expense_account ?? defaults.expense,
@@ -202,15 +195,13 @@ export interface UpdateAssetInput {
* legitimate *prospective* change and stays allowed after depreciation. */
salvage_value?: number
useful_life_months?: number
depreciation_method?: DepreciationMethod
/** Editable as long as method=restvärdeavskrivning. Set to null when
* switching back to a non-restvärde method (the DB CHECK enforces). */
restvarde_target?: number | null
depreciation_method?: WritableDepreciationMethod
restvarde_target?: null
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
/** K3 component breakdown. Pass null to clear an existing breakdown
* (engine then falls back to depreciation_method). The route handler
* (engine then falls back to ordinary linear depreciation). The route handler
* enforces accounting_framework='k3' + sum validation before delegating. */
k3_components?: K3Component[] | null
}
@@ -305,7 +296,7 @@ export async function updateAsset(
assetId: string,
inputParam: UpdateAssetInput,
): Promise<Asset> {
// Copy so we can adjust restvarde_target without mutating the caller's object.
// Copy so category-driven account defaults do not mutate the caller's object.
let input: UpdateAssetInput = { ...inputParam }
// Almost every meaningful patch needs the current row (range checks, the
@@ -315,7 +306,6 @@ export async function updateAsset(
input.acquisition_date !== undefined ||
input.acquisition_cost !== undefined ||
input.depreciation_method !== undefined ||
input.restvarde_target !== undefined ||
input.bas_asset_account !== undefined ||
input.bas_accumulated_account !== undefined ||
input.bas_expense_account !== undefined
@@ -415,44 +405,6 @@ export async function updateAsset(
}
}
// ── Method / restvärde-target biconditional ───────────────────────
// Required iff restvärdeavskrivning. Resolve final method+target+cost across
// the merged row (existing + patch) so we can null the target when switching
// away, require it when switching in, and re-check the floor when the cost
// itself is being corrected.
if (
input.depreciation_method !== undefined ||
input.restvarde_target !== undefined ||
input.acquisition_cost !== undefined
) {
if (!existing) throw new Error('Asset not found')
const finalMethod = input.depreciation_method ?? existing.depreciation_method
const finalTarget =
input.restvarde_target !== undefined ? input.restvarde_target : existing.restvarde_target
const finalCost =
input.acquisition_cost !== undefined ? input.acquisition_cost : Number(existing.acquisition_cost)
if (finalMethod === 'restvardesavskrivning_25' && (finalTarget === null || finalTarget === undefined)) {
throw new Error(
'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
)
}
if (finalMethod !== 'restvardesavskrivning_25' && finalTarget !== null && finalTarget !== undefined) {
// Auto-null the target when switching away from restvärde so the DB
// CHECK doesn't reject the update.
input = { ...input, restvarde_target: null }
}
if (
finalMethod === 'restvardesavskrivning_25' &&
finalTarget !== null &&
finalTarget !== undefined &&
Number(finalTarget) >= Number(finalCost)
) {
throw new Error(
'restvarde_target måste vara lägre än anskaffningsvärdet: annars finns inget kvar att skriva av.',
)
}
}
const { data, error } = await supabase
.from('assets')
.update(input)
+5 -71
View File
@@ -7,11 +7,6 @@ import type {
CreateJournalEntryLineInput,
} from '@/types'
/** Rate constants for the non-linear Swedish depreciation methods. */
const DECLINING_RATE_30 = 0.3
const DECLINING_RATE_20 = 0.2
const RESTVARDE_RATE_25 = 0.25
export interface AssetDepreciation {
asset: Asset
/** Planenlig avskrivning för denna period, avrundad till hela kronor. */
@@ -40,34 +35,14 @@ export interface DepreciationProposal {
/**
* Compute avskrivning för en enskild tillgång under en given fiscal period.
*
* Method dispatch:
* * 'linear' (planenlig raklinje): pro-rates by day-overlap of the
* period with the asset's active life and the disposal cutoff. Annual
* amount = (acquisition_cost − salvage_value) × 12 / useful_life_months.
* * 'declining_balance_30' (räkenskapsenlig huvudregel, IL 18 kap 13§): * 30% of the current book value. No annual pro-ration: K2 10.23 says
* "full annual amount regardless of partial year" when the asset is
* put into use, mirrored in Swedish tax practice for IL 18 kap. Disposal
* in-period still zeros out: we return 0 if disposed before the period
* and the full 30% if disposed during, because the disposal entry itself
* takes care of the asset's remaining book value.
* * 'declining_balance_20' (kompletteringsregel, IL 18 kap 17§): 20% of
* book value. Same proration semantics as 30%.
* * 'restvardesavskrivning_25' (IL 18 kap 13§ st.3): 25% of
* max(0, currentBookValue − restvarde_target). Floors at the target so
* the asset is never charged below restvärde. Same proration semantics
* as the other declining methods.
*
* For non-linear methods, currentBookValue =
* acquisition_cost − accumulated_depreciation_through_period_start.
*
* Callers that don't know prior accumulated depreciation pass 0 (e.g. tests
* for year-1 declining-balance). The orchestrator (`proposeAnnualPostings`)
* fetches it from posted depreciation_schedules.
* Ordinary depreciation is always linear at asset level and pro-rates by the
* active-life overlap. The pooled 30, 20 and 25 percent tax rules live in
* tax-depreciation.ts and never create ordinary per-asset postings.
*/
export function computeAnnualDepreciation(
asset: Asset,
fiscalPeriod: Pick<FiscalPeriod, 'period_start' | 'period_end'>,
priorAccumulated: number = 0,
_priorAccumulated: number = 0,
): { amount: number; proRated: boolean } {
if (asset.disposed_at && asset.disposed_at < fiscalPeriod.period_start) {
return { amount: 0, proRated: false }
@@ -84,48 +59,7 @@ export function computeAnnualDepreciation(
return { amount: result.amount, proRated: result.proRated }
}
const acquisitionCost = Number(asset.acquisition_cost)
const method = asset.depreciation_method
if (method === 'linear') {
return computeLinearAnnual(asset, fiscalPeriod)
}
// Declining-balance methods (huvudregel 30%, kompletteringsregel 20%,
// restvärde 25%) do NOT pro-rate annually: full-year amount applies
// regardless of acquisition month. Disposal during the period is handled
// by disposeAsset(); we still charge the full annual amount because the
// disposal entry zeroes out the residual.
const currentBookValue = acquisitionCost - priorAccumulated
// Already fully depreciated (linear-style accumulated overshoot) or
// negative: defensive guard.
if (currentBookValue <= 0.005) {
return { amount: 0, proRated: false }
}
let annualAmount = 0
if (method === 'declining_balance_30') {
annualAmount = currentBookValue * DECLINING_RATE_30
} else if (method === 'declining_balance_20') {
annualAmount = currentBookValue * DECLINING_RATE_20
} else if (method === 'restvardesavskrivning_25') {
const target = Number(asset.restvarde_target ?? 0)
const depreciable = currentBookValue - target
if (depreciable <= 0.005) {
// Already at or below restvärde: never deplete past the floor.
return { amount: 0, proRated: false }
}
annualAmount = depreciable * RESTVARDE_RATE_25
}
// Monetary rounding per CLAUDE.md guard-rail #9. Schedules store NUMERIC
// values, but the journal entry rounds to whole kronor downstream: match
// the linear branch which rounds to integer kronor for the entry amount.
return {
amount: Math.round(annualAmount),
proRated: false,
}
return computeLinearAnnual(asset, fiscalPeriod)
}
function computeLinearAnnual(
@@ -0,0 +1,610 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Asset, AssetCategory } from '@/types'
import { roundOre } from '@/lib/money'
import { listAssets } from './asset-service'
import {
TAX_DEPRECIATION_CATEGORIES,
computeTaxDepreciation,
fiscalPeriodMonths,
type TaxDepreciationCohort,
type TaxDepreciationMethod,
type TaxDepreciationResult,
type TaxDepreciationRule,
} from './tax-depreciation'
export interface TaxPeriodRow {
id: string
name: string
period_start: string
period_end: string
previous_period_id: string | null
is_closed: boolean
locked_at: string | null
closing_entry_id: string | null
tax_depreciation_method: TaxDepreciationMethod | null
tax_depreciation_rule: TaxDepreciationRule | null
tax_depreciation_opening_value: number | string | null
tax_depreciation_base: number | string | null
tax_depreciation_deduction: number | string | null
tax_depreciation_closing_value: number | string | null
tax_depreciation_calculation: Record<string, unknown> | null
}
export interface TaxDepreciationSnapshot {
method: TaxDepreciationMethod
selectedRule: TaxDepreciationRule | null
openingTaxValue: number
basis: number
deduction: number
closingTaxValue: number
calculation: Record<string, unknown> | null
}
export interface TaxDepreciationView {
status:
| 'needs_previous_period'
| 'needs_period_history'
| 'needs_method'
| 'needs_opening_value'
| 'needs_rule'
| 'ready'
method: TaxDepreciationMethod | null
selectedRule: TaxDepreciationRule | null
methodLocked: boolean
openingTaxValue: number | null
openingSource: 'saved' | 'previous_period' | 'previous_period_required' | 'manual_required'
periodMonths: number
eligibleAssetCount: number
excludedAssetCount: number
excludedCategories: AssetCategory[]
cohortHistoryComplete: boolean
incompleteCohortCount: number
result: TaxDepreciationResult | null
snapshot: TaxDepreciationSnapshot | null
isStale: boolean
}
export interface SaveTaxDepreciationInput {
method: TaxDepreciationMethod
selectedRule?: TaxDepreciationRule
openingTaxValue?: number
electedDeduction: number
bookConformityConfirmed?: boolean
}
export type PreviewTaxDepreciationInput = Omit<
SaveTaxDepreciationInput,
'electedDeduction' | 'bookConformityConfirmed'
>
export class TaxDepreciationValidationError extends Error {}
export class TaxDepreciationPeriodLockedError extends Error {}
// Literal select string at each call site: the no-phantom-columns guard can only
// verify columns it can resolve statically.
export async function loadTaxDepreciationView(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<TaxDepreciationView> {
const [periodResult, assets] = await Promise.all([
supabase
.from('fiscal_periods')
.select(
'id, name, period_start, period_end, previous_period_id, is_closed, locked_at, closing_entry_id, tax_depreciation_method, tax_depreciation_rule, tax_depreciation_opening_value, tax_depreciation_base, tax_depreciation_deduction, tax_depreciation_closing_value, tax_depreciation_calculation'
)
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
listAssets(supabase, companyId),
])
if (periodResult.error || !periodResult.data) throw new Error('Fiscal period not found')
const current = periodResult.data as TaxPeriodRow
const periodsResult = await supabase
.from('fiscal_periods')
.select(
'id, name, period_start, period_end, previous_period_id, is_closed, locked_at, closing_entry_id, tax_depreciation_method, tax_depreciation_rule, tax_depreciation_opening_value, tax_depreciation_base, tax_depreciation_deduction, tax_depreciation_closing_value, tax_depreciation_calculation'
)
.eq('company_id', companyId)
.lte('period_end', current.period_end)
.order('period_start', { ascending: true })
if (periodsResult.error) {
throw new Error(`Failed to load fiscal period history: ${periodsResult.error.message}`)
}
const periods = (periodsResult.data ?? []) as TaxPeriodRow[]
const currentIndex = periods.findIndex((period) => period.id === current.id)
const previousPeriod = findImmediatePreviousTaxPeriod(current, periods)
const previousSnapshot = previousPeriod ? snapshotFromPeriod(previousPeriod) : null
const openingState = resolveTaxDepreciationOpening(
snapshotFromPeriod(current),
previousSnapshot,
previousPeriod !== null,
)
const missingPreviousSnapshot = openingState.source === 'previous_period_required'
const previousMethod = previousSnapshot?.method ?? null
const snapshot = snapshotFromPeriod(current)
const method = snapshot?.method ?? previousMethod
const selectedRule = snapshot?.selectedRule ?? null
const methodLocked = previousSnapshot !== null
const openingTaxValue = openingState.value
const openingSource = openingState.source
const population = buildTaxDepreciationPopulation(assets, current, periods, currentIndex)
let result: TaxDepreciationResult | null = null
if (
method
&& openingTaxValue !== null
&& (method === 'restvarde' || selectedRule)
&& !(
method === 'rakenskapsenlig'
&& selectedRule === 'kompletteringsregel_20'
&& !population.cohortHistoryComplete
)
) {
const baseInput = {
method,
selectedRule: method === 'rakenskapsenlig' ? selectedRule ?? undefined : undefined,
openingTaxValue,
additions: population.additions,
disposals: population.disposals,
periodMonths: population.periodMonths,
cohorts: population.cohorts,
}
try {
result = computeTaxDepreciation({ ...baseInput, electedDeduction: snapshot?.deduction })
} catch (error) {
// A saved election can exceed the statutory maximum when the
// predecessor's closing value later changed. The view must surface
// that as a stale snapshot, not crash: recompute the statutory result
// and let the snapshot comparison flag the divergence.
if (!(error instanceof Error && /electedDeduction/.test(error.message))) throw error
result = computeTaxDepreciation(baseInput)
}
}
const status = missingPreviousSnapshot
? 'needs_previous_period'
: !method
? 'needs_method'
: openingTaxValue === null
? 'needs_opening_value'
: method === 'rakenskapsenlig'
&& selectedRule === 'kompletteringsregel_20'
&& !population.cohortHistoryComplete
? 'needs_period_history'
: method === 'rakenskapsenlig' && !selectedRule
? 'needs_rule'
: 'ready'
return {
status,
method,
selectedRule,
methodLocked,
openingTaxValue,
openingSource,
periodMonths: population.periodMonths,
eligibleAssetCount: population.eligibleAssetCount,
excludedAssetCount: population.excludedAssetCount,
excludedCategories: population.excludedCategories,
cohortHistoryComplete: population.cohortHistoryComplete,
incompleteCohortCount: population.incompleteCohortCount,
result,
snapshot,
isStale: snapshot !== null && (result === null || !taxDepreciationSnapshotMatches(snapshot, result)),
}
}
export async function saveTaxDepreciationElection(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
input: SaveTaxDepreciationInput,
): Promise<TaxDepreciationView> {
const currentView = await loadTaxDepreciationView(supabase, companyId, fiscalPeriodId)
if (currentView.openingSource === 'previous_period_required') {
throw new TaxDepreciationValidationError(
'Spara skattemässig avskrivning för närmast föregående räkenskapsår först.',
)
}
if (currentView.methodLocked && currentView.method && currentView.method !== input.method) {
throw new TaxDepreciationValidationError(
'Skattemässig avskrivningsmetod kan inte bytas efter ett sparat tidigare år utan en särskild övergångsbedömning.',
)
}
if (input.method === 'rakenskapsenlig' && !input.selectedRule) {
throw new TaxDepreciationValidationError('Välj 30-procentsregeln eller 20-procentsregeln.')
}
if (input.method === 'restvarde' && input.selectedRule) {
throw new TaxDepreciationValidationError(
'Restvärdeavskrivning har ingen kompletteringsregel.',
)
}
if (!Number.isFinite(input.electedDeduction) || input.electedDeduction < 0) {
throw new TaxDepreciationValidationError('Årets faktiska avdrag måste vara 0 kr eller mer.')
}
if (input.method === 'rakenskapsenlig' && input.bookConformityConfirmed !== true) {
throw new TaxDepreciationValidationError(
'Bekräfta att avdraget motsvarar bokslutets totala avskrivning.',
)
}
const openingTaxValue = currentView.openingSource === 'previous_period'
? currentView.openingTaxValue
: input.openingTaxValue ?? currentView.openingTaxValue
if (openingTaxValue === null || openingTaxValue === undefined) {
throw new TaxDepreciationValidationError(
'Ange skattemässigt värde vid årets ingång innan beräkningen sparas.',
)
}
if (currentView.openingSource === 'previous_period'
&& input.openingTaxValue !== undefined
&& roundOre(input.openingTaxValue) !== roundOre(openingTaxValue)) {
throw new TaxDepreciationValidationError(
'Ingående skattemässigt värde hämtas från föregående sparade år och kan inte skrivas över.',
)
}
let calculationView: TaxDepreciationView
try {
calculationView = await calculateWithElection(
supabase,
companyId,
fiscalPeriodId,
input.method,
input.selectedRule,
openingTaxValue,
input.electedDeduction,
)
} catch (error) {
if (error instanceof Error && /electedDeduction/.test(error.message)) {
throw new TaxDepreciationValidationError(
'Årets faktiska avdrag får inte överstiga högsta avdrag enligt den valda regeln.',
)
}
throw error
}
if (!calculationView.result) throw new Error('Tax depreciation calculation was not produced')
const result = calculationView.result
const calculation = {
version: 2,
saved_by: userId,
saved_at: new Date().toISOString(),
period_months: result.periodMonths,
additions: result.additions,
disposals: result.disposals,
excess_disposals: result.excessDisposals,
maximum_deduction: result.maximumDeduction,
elected_deduction: result.deduction,
book_conformity_confirmed:
input.method === 'rakenskapsenlig' ? input.bookConformityConfirmed === true : null,
alternatives: result.alternatives,
cohorts: result.cohorts,
eligible_asset_count: calculationView.eligibleAssetCount,
excluded_asset_count: calculationView.excludedAssetCount,
excluded_categories: calculationView.excludedCategories,
cohort_history_complete: calculationView.cohortHistoryComplete,
incomplete_cohort_count: calculationView.incompleteCohortCount,
}
const { data: savedPeriod, error: saveError } = await supabase
.from('fiscal_periods')
.update({
tax_depreciation_method: input.method,
tax_depreciation_rule: input.method === 'rakenskapsenlig' ? input.selectedRule : null,
tax_depreciation_opening_value: result.openingTaxValue,
tax_depreciation_base: result.basis,
tax_depreciation_deduction: result.deduction,
tax_depreciation_closing_value: result.closingTaxValue,
tax_depreciation_calculation: calculation,
})
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.eq('is_closed', false)
.is('locked_at', null)
.is('closing_entry_id', null)
.select('id')
.maybeSingle()
if (saveError) {
if (/locked for tax depreciation/i.test(saveError.message)) {
throw new TaxDepreciationPeriodLockedError('Fiscal period is locked')
}
if (
/later fiscal period|previous fiscal period|opening value must equal|method must match/i
.test(saveError.message)
) {
throw new TaxDepreciationValidationError(
'Skattemässig avskrivning kan inte ändras eftersom räkenskapsårskedjan skulle brytas.',
)
}
throw new Error(`Failed to save tax depreciation: ${saveError.message}`)
}
if (!savedPeriod) throw new TaxDepreciationPeriodLockedError('Fiscal period is locked')
return loadTaxDepreciationView(supabase, companyId, fiscalPeriodId)
}
export async function previewTaxDepreciationElection(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
input: PreviewTaxDepreciationInput,
): Promise<TaxDepreciationView> {
const currentView = await loadTaxDepreciationView(supabase, companyId, fiscalPeriodId)
if (currentView.openingSource === 'previous_period_required') {
throw new TaxDepreciationValidationError(
'Spara skattemässig avskrivning för närmast föregående räkenskapsår först.',
)
}
if (currentView.methodLocked && currentView.method && currentView.method !== input.method) {
throw new TaxDepreciationValidationError(
'Skattemässig avskrivningsmetod kan inte bytas efter ett sparat tidigare år utan en särskild övergångsbedömning.',
)
}
if (input.method === 'rakenskapsenlig' && !input.selectedRule) {
throw new TaxDepreciationValidationError('Välj 30-procentsregeln eller 20-procentsregeln.')
}
if (input.method === 'restvarde' && input.selectedRule) {
throw new TaxDepreciationValidationError(
'Restvärdeavskrivning har ingen kompletteringsregel.',
)
}
const openingTaxValue = currentView.openingSource === 'previous_period'
? currentView.openingTaxValue
: input.openingTaxValue ?? currentView.openingTaxValue
if (openingTaxValue === null || openingTaxValue === undefined) {
throw new TaxDepreciationValidationError(
'Ange skattemässigt värde vid årets ingång innan beräkningen förhandsgranskas.',
)
}
return calculateWithElection(
supabase,
companyId,
fiscalPeriodId,
input.method,
input.selectedRule,
openingTaxValue,
)
}
async function calculateWithElection(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
method: TaxDepreciationMethod,
selectedRule: TaxDepreciationRule | undefined,
openingTaxValue: number,
electedDeduction?: number,
): Promise<TaxDepreciationView> {
const view = await loadTaxDepreciationView(supabase, companyId, fiscalPeriodId)
const [periodResult, assets, periodsResult] = await Promise.all([
supabase
.from('fiscal_periods')
.select(
'id, name, period_start, period_end, previous_period_id, is_closed, locked_at, closing_entry_id, tax_depreciation_method, tax_depreciation_rule, tax_depreciation_opening_value, tax_depreciation_base, tax_depreciation_deduction, tax_depreciation_closing_value, tax_depreciation_calculation'
)
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
listAssets(supabase, companyId),
supabase
.from('fiscal_periods')
.select(
'id, name, period_start, period_end, previous_period_id, is_closed, locked_at, closing_entry_id, tax_depreciation_method, tax_depreciation_rule, tax_depreciation_opening_value, tax_depreciation_base, tax_depreciation_deduction, tax_depreciation_closing_value, tax_depreciation_calculation'
)
.eq('company_id', companyId)
.order('period_start', { ascending: true }),
])
if (periodResult.error || !periodResult.data) throw new Error('Fiscal period not found')
if (periodsResult.error) throw new Error(`Failed to load fiscal period history: ${periodsResult.error.message}`)
const current = periodResult.data as TaxPeriodRow
const periods = (periodsResult.data ?? []) as TaxPeriodRow[]
const population = buildTaxDepreciationPopulation(
assets,
current,
periods,
periods.findIndex((period) => period.id === current.id),
)
if (
method === 'rakenskapsenlig'
&& selectedRule === 'kompletteringsregel_20'
&& !population.cohortHistoryComplete
) {
throw new TaxDepreciationValidationError(
'Kompletteringsregeln kräver fullständig räkenskapsårshistorik för alla kvarvarande anskaffningskohorter.',
)
}
return {
...view,
status: 'ready',
method,
selectedRule: method === 'rakenskapsenlig' ? selectedRule ?? null : null,
openingTaxValue,
result: computeTaxDepreciation({
method,
selectedRule: method === 'rakenskapsenlig' ? selectedRule : undefined,
openingTaxValue,
additions: population.additions,
disposals: population.disposals,
periodMonths: population.periodMonths,
cohorts: population.cohorts,
electedDeduction,
}),
eligibleAssetCount: population.eligibleAssetCount,
excludedAssetCount: population.excludedAssetCount,
excludedCategories: population.excludedCategories,
cohortHistoryComplete: population.cohortHistoryComplete,
incompleteCohortCount: population.incompleteCohortCount,
}
}
export function findImmediatePreviousTaxPeriod(
current: TaxPeriodRow,
periods: TaxPeriodRow[],
): TaxPeriodRow | null {
if (current.previous_period_id) {
const linked = periods.find((period) => period.id === current.previous_period_id) ?? null
return linked && periodsAreAdjacent(linked, current) ? linked : null
}
const currentStart = new Date(`${current.period_start}T00:00:00Z`)
currentStart.setUTCDate(currentStart.getUTCDate() - 1)
const adjacentEnd = currentStart.toISOString().slice(0, 10)
return periods.find((period) => period.period_end === adjacentEnd) ?? null
}
export function resolveTaxDepreciationOpening(
currentSnapshot: TaxDepreciationSnapshot | null,
previousSnapshot: TaxDepreciationSnapshot | null,
hasPreviousPeriod: boolean,
): { value: number | null; source: TaxDepreciationView['openingSource'] } {
if (previousSnapshot) {
return { value: previousSnapshot.closingTaxValue, source: 'previous_period' }
}
if (hasPreviousPeriod) {
return { value: null, source: 'previous_period_required' }
}
if (currentSnapshot) {
return { value: currentSnapshot.openingTaxValue, source: 'saved' }
}
return { value: null, source: 'manual_required' }
}
export function buildTaxDepreciationPopulation(
assets: Asset[],
current: TaxPeriodRow,
periods: TaxPeriodRow[],
currentIndex: number,
): {
additions: number
disposals: number
cohorts: TaxDepreciationCohort[]
periodMonths: number
eligibleAssetCount: number
excludedAssetCount: number
excludedCategories: AssetCategory[]
cohortHistoryComplete: boolean
incompleteCohortCount: number
} {
const eligibleCategories = new Set<AssetCategory>(TAX_DEPRECIATION_CATEGORIES)
const acquiredByEnd = assets.filter((asset) => asset.acquisition_date <= current.period_end)
const heldAtEnd = acquiredByEnd.filter(
(asset) => !asset.disposed_at || asset.disposed_at > current.period_end,
)
const eligibleHeld = heldAtEnd.filter((asset) => eligibleCategories.has(asset.category))
const excluded = heldAtEnd.filter((asset) => !eligibleCategories.has(asset.category))
const additions = roundOre(
eligibleHeld
.filter((asset) => asset.acquisition_date >= current.period_start)
.reduce((sum, asset) => sum + Number(asset.acquisition_cost), 0),
)
const disposals = roundOre(
acquiredByEnd
.filter(
(asset) =>
eligibleCategories.has(asset.category)
&& asset.acquisition_date < current.period_start
&& asset.disposed_at !== null
&& asset.disposed_at >= current.period_start
&& asset.disposed_at <= current.period_end,
)
.reduce(
(sum, asset) =>
sum
+ Math.max(
0,
Number(asset.disposed_proceeds ?? 0) - Number(asset.disposed_proceeds_vat ?? 0),
),
0,
),
)
const cohortMap = new Map<string, TaxDepreciationCohort>()
let incompleteCohortCount = 0
for (const asset of eligibleHeld) {
const acquisitionIndex = periods.findIndex(
(period) =>
asset.acquisition_date >= period.period_start
&& asset.acquisition_date <= period.period_end,
)
const cohortPeriods = acquisitionIndex >= 0 && currentIndex >= acquisitionIndex
? periods.slice(acquisitionIndex, currentIndex + 1)
: []
const completeHistory = cohortPeriods.length > 0
&& cohortPeriods[cohortPeriods.length - 1]?.id === current.id
&& cohortPeriods.every(
(period, index) => index === 0 || periodsAreAdjacent(cohortPeriods[index - 1], period),
)
if (!completeHistory) {
incompleteCohortCount += 1
continue
}
const elapsedMonths = cohortPeriods.reduce(
(sum, period) => sum + fiscalPeriodMonths(period.period_start, period.period_end),
0,
)
const label = periods[acquisitionIndex].name
const key = `${label}:${elapsedMonths}`
const existing = cohortMap.get(key)
cohortMap.set(key, {
label,
elapsedMonths,
acquisitionCost: roundOre((existing?.acquisitionCost ?? 0) + Number(asset.acquisition_cost)),
})
}
return {
additions,
disposals,
cohorts: [...cohortMap.values()].sort((a, b) => a.elapsedMonths - b.elapsedMonths),
periodMonths: fiscalPeriodMonths(current.period_start, current.period_end),
eligibleAssetCount: eligibleHeld.length,
excludedAssetCount: excluded.length,
excludedCategories: [...new Set(excluded.map((asset) => asset.category))],
cohortHistoryComplete: incompleteCohortCount === 0,
incompleteCohortCount,
}
}
function periodsAreAdjacent(previous: TaxPeriodRow, current: TaxPeriodRow): boolean {
const nextDay = new Date(`${previous.period_end}T00:00:00Z`)
nextDay.setUTCDate(nextDay.getUTCDate() + 1)
return nextDay.toISOString().slice(0, 10) === current.period_start
}
function snapshotFromPeriod(period: TaxPeriodRow): TaxDepreciationSnapshot | null {
if (
!period.tax_depreciation_method
|| period.tax_depreciation_opening_value === null
|| period.tax_depreciation_base === null
|| period.tax_depreciation_deduction === null
|| period.tax_depreciation_closing_value === null
) return null
return {
method: period.tax_depreciation_method,
selectedRule: period.tax_depreciation_rule,
openingTaxValue: Number(period.tax_depreciation_opening_value),
basis: Number(period.tax_depreciation_base),
deduction: Number(period.tax_depreciation_deduction),
closingTaxValue: Number(period.tax_depreciation_closing_value),
calculation: period.tax_depreciation_calculation,
}
}
export function taxDepreciationSnapshotMatches(
snapshot: TaxDepreciationSnapshot,
result: TaxDepreciationResult,
): boolean {
return snapshot.method === result.method
&& snapshot.selectedRule === result.selectedRule
&& roundOre(snapshot.openingTaxValue) === result.openingTaxValue
&& roundOre(snapshot.basis) === result.basis
&& roundOre(snapshot.deduction) === result.deduction
&& roundOre(snapshot.closingTaxValue) === result.closingTaxValue
}
+219
View File
@@ -0,0 +1,219 @@
import type { AssetCategory } from '@/types'
import { roundOre } from '@/lib/money'
export type TaxDepreciationMethod = 'rakenskapsenlig' | 'restvarde'
export type TaxDepreciationRule = 'huvudregel_30' | 'kompletteringsregel_20'
export const TAX_DEPRECIATION_CATEGORIES: readonly AssetCategory[] = [
'machinery',
'equipment',
'vehicle',
'computer',
] as const
export interface TaxDepreciationCohort {
label: string
acquisitionCost: number
/**
* Sum of the fiscal-period lengths from the acquisition period through the
* current period. The 20 percent rule applies a full period's adjusted rate
* regardless of the acquisition date inside that period.
*/
elapsedMonths: number
}
export interface TaxDepreciationInput {
method: TaxDepreciationMethod
selectedRule?: TaxDepreciationRule
openingTaxValue: number
additions: number
disposals: number
periodMonths: number
cohorts: TaxDepreciationCohort[]
/** Actual deduction elected for the period. Omit when calculating the
* statutory maximum before the user has reconciled the election. */
electedDeduction?: number
}
export interface TaxDepreciationAlternative {
rule: TaxDepreciationRule | 'restvarde_25'
rate: number | null
deduction: number
closingTaxValue: number
}
export interface TaxDepreciationResult {
method: TaxDepreciationMethod
selectedRule: TaxDepreciationRule | null
openingTaxValue: number
additions: number
disposals: number
basis: number
periodMonths: number
maximumDeduction: number
deduction: number
closingTaxValue: number
excessDisposals: number
alternatives: TaxDepreciationAlternative[]
cohorts: Array<TaxDepreciationCohort & { remainingRate: number; closingValue: number }>
}
/**
* Calculate the annual tax value for the pooled machinery and inventory
* population under IL 18 kap. This is deliberately separate from ordinary
* per-asset depreciation: the 30, 20 and 25 percent rules are tax valuation
* rules for a pool, not book-depreciation methods on an individual asset.
*/
export function computeTaxDepreciation(input: TaxDepreciationInput): TaxDepreciationResult {
assertNonNegative('openingTaxValue', input.openingTaxValue)
assertNonNegative('additions', input.additions)
assertNonNegative('disposals', input.disposals)
if (!Number.isInteger(input.periodMonths) || input.periodMonths < 1 || input.periodMonths > 18) {
throw new Error('periodMonths must be an integer between 1 and 18')
}
const openingTaxValue = roundOre(input.openingTaxValue)
const additions = roundOre(input.additions)
const disposals = roundOre(input.disposals)
const grossBasis = roundOre(openingTaxValue + additions)
const basis = roundOre(Math.max(0, grossBasis - disposals))
const excessDisposals = roundOre(Math.max(0, disposals - grossBasis))
const cohorts = input.cohorts.map((cohort) => {
assertNonNegative('cohort acquisitionCost', cohort.acquisitionCost)
if (!Number.isInteger(cohort.elapsedMonths) || cohort.elapsedMonths < 1) {
throw new Error('cohort elapsedMonths must be a positive integer')
}
const remainingRate = clampRate(1 - 0.2 * (cohort.elapsedMonths / 12))
return {
...cohort,
acquisitionCost: roundOre(cohort.acquisitionCost),
remainingRate,
closingValue: roundOre(cohort.acquisitionCost * remainingRate),
}
})
if (input.method === 'restvarde') {
if (input.selectedRule !== undefined) {
throw new Error('selectedRule is only valid for rakenskapsenlig depreciation')
}
const rate = 0.25 * (input.periodMonths / 12)
const maximumDeduction = roundOre(Math.min(basis, basis * rate))
const deduction = resolveElectedDeduction(input.electedDeduction, maximumDeduction)
const closingTaxValue = roundOre(basis - deduction)
return {
method: input.method,
selectedRule: null,
openingTaxValue,
additions,
disposals,
basis,
periodMonths: input.periodMonths,
maximumDeduction,
deduction,
closingTaxValue,
excessDisposals,
alternatives: [{
rule: 'restvarde_25',
rate,
deduction: maximumDeduction,
closingTaxValue: roundOre(basis - maximumDeduction),
}],
cohorts,
}
}
if (!input.selectedRule) {
throw new Error('selectedRule is required for rakenskapsenlig depreciation')
}
// IL 18 kap. 17 §: the 20-rule's lowest permitted closing value is derived
// from acquisition-year cohorts. With a positive basis but no cohort at all,
// the reduce degenerates to a full write-off, which is not a computation the
// cohort evidence supports; refuse instead of silently deducting everything.
if (input.selectedRule === 'kompletteringsregel_20' && cohorts.length === 0 && basis > 0) {
throw new Error(
'kompletteringsregel_20 requires at least one acquisition cohort for a non-zero basis',
)
}
const mainRate = 0.3 * (input.periodMonths / 12)
const mainDeduction = roundOre(Math.min(basis, basis * mainRate))
const mainClosing = roundOre(basis - mainDeduction)
const complementaryMinimum = roundOre(
cohorts.reduce((sum, cohort) => sum + cohort.closingValue, 0),
)
const complementaryClosing = roundOre(Math.min(basis, complementaryMinimum))
const complementaryDeduction = roundOre(basis - complementaryClosing)
const alternatives: TaxDepreciationAlternative[] = [
{
rule: 'huvudregel_30',
rate: mainRate,
deduction: mainDeduction,
closingTaxValue: mainClosing,
},
{
rule: 'kompletteringsregel_20',
rate: null,
deduction: complementaryDeduction,
closingTaxValue: complementaryClosing,
},
]
const selected = alternatives.find((alternative) => alternative.rule === input.selectedRule)
if (!selected) throw new Error('Unsupported tax depreciation rule')
const deduction = resolveElectedDeduction(input.electedDeduction, selected.deduction)
return {
method: input.method,
selectedRule: input.selectedRule,
openingTaxValue,
additions,
disposals,
basis,
periodMonths: input.periodMonths,
maximumDeduction: selected.deduction,
deduction,
closingTaxValue: roundOre(basis - deduction),
excessDisposals,
alternatives,
cohorts,
}
}
export function fiscalPeriodMonths(periodStart: string, periodEnd: string): number {
const start = parseIsoDate(periodStart)
const end = parseIsoDate(periodEnd)
if (end < start) throw new Error('Fiscal period end must not precede its start')
return (
(end.getUTCFullYear() - start.getUTCFullYear()) * 12
+ end.getUTCMonth()
- start.getUTCMonth()
+ 1
)
}
function parseIsoDate(value: string): Date {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error(`Invalid ISO date: ${value}`)
const date = new Date(`${value}T00:00:00Z`)
if (Number.isNaN(date.getTime())) throw new Error(`Invalid ISO date: ${value}`)
return date
}
function assertNonNegative(label: string, value: number): void {
if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be non-negative`)
}
function clampRate(value: number): number {
return Math.max(0, Math.min(1, value))
}
function resolveElectedDeduction(value: number | undefined, maximumDeduction: number): number {
if (value === undefined) return maximumDeduction
assertNonNegative('electedDeduction', value)
const deduction = roundOre(value)
if (deduction > maximumDeduction) {
throw new Error('electedDeduction must not exceed the statutory maximum')
}
return deduction
}