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
+7 -54
View File
@@ -6,7 +6,7 @@ import { validateBody } from '@/lib/api/validate'
import { K3ComponentSchema } from '@/lib/api/schemas'
import { createAsset, listAssets } from '@/lib/bokslut/assets/asset-service'
import { validateComponents } from '@/lib/bokslut/assets/k3-components'
import type { AssetCategory, DepreciationMethod } from '@/types'
import type { AssetCategory, WritableDepreciationMethod } from '@/types'
const ASSET_CATEGORIES: readonly AssetCategory[] = [
'immaterial',
@@ -19,14 +19,8 @@ const ASSET_CATEGORIES: readonly AssetCategory[] = [
'other_tangible',
] as const
// All four depreciation methods are now implemented by the engine. The DB
// CHECK constraint mirrors this list (see
// 20260526120100_restvardeavskrivning.sql).
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
const DEPRECIATION_METHODS: readonly WritableDepreciationMethod[] = [
'linear',
'declining_balance_30',
'declining_balance_20',
'restvardesavskrivning_25',
] as const
const CreateAssetSchema = z
@@ -40,13 +34,12 @@ const CreateAssetSchema = z
salvage_value: z.number().nonnegative().optional(),
useful_life_months: z.number().int().positive(),
depreciation_method: z
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
.enum(DEPRECIATION_METHODS as unknown as [
WritableDepreciationMethod,
...WritableDepreciationMethod[],
])
.optional(),
// Restvärde-target floor for restvärdeavskrivning. Required iff
// depreciation_method = 'restvardesavskrivning_25'. The DB CHECK enforces
// the same biconditional; we mirror it in the API for an early, Swedish
// error message rather than a Postgres check_violation surfacing.
restvarde_target: z.number().nonnegative().nullable().optional(),
restvarde_target: z.null().optional(),
bas_asset_account: z.string().regex(/^\d{4}$/).optional(),
bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(),
bas_expense_account: z.string().regex(/^\d{4}$/).optional(),
@@ -63,7 +56,6 @@ const CreateAssetSchema = z
// outside the legitimate range for the asset category so the chart stays
// BAS-aligned and INK2R mappings continue to work.
validateBasOverrides(value, ctx)
validateRestvardeTarget(value, ctx)
validateK3Components(value, ctx)
})
@@ -88,45 +80,6 @@ function validateK3Components(
}
}
function validateRestvardeTarget(
value: {
depreciation_method?: DepreciationMethod
restvarde_target?: number | null
acquisition_cost?: number
},
ctx: z.RefinementCtx,
): void {
const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25'
const hasTarget = value.restvarde_target !== undefined && value.restvarde_target !== null
if (isRestvarde && !hasTarget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
})
}
if (!isRestvarde && hasTarget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).',
})
}
if (
isRestvarde &&
hasTarget &&
value.acquisition_cost !== undefined &&
(value.restvarde_target as number) >= value.acquisition_cost
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message:
'restvarde_target måste vara lägre än anskaffningsvärdet: annars finns inget kvar att skriva av.',
})
}
}
function validateBasOverrides(
value: {
category: AssetCategory