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 -35
View File
@@ -6,7 +6,7 @@ import { validateBody } from '@/lib/api/validate'
import { K3ComponentSchema } from '@/lib/api/schemas'
import { getAsset, updateAsset } 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,11 +19,8 @@ const ASSET_CATEGORIES: readonly AssetCategory[] = [
'other_tangible',
] as const
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
const DEPRECIATION_METHODS: readonly WritableDepreciationMethod[] = [
'linear',
'declining_balance_30',
'declining_balance_20',
'restvardesavskrivning_25',
] as const
const UpdateAssetSchema = z
@@ -42,9 +39,12 @@ const UpdateAssetSchema = z
salvage_value: z.number().nonnegative().optional(),
useful_life_months: z.number().int().positive().optional(),
depreciation_method: z
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
.enum(DEPRECIATION_METHODS as unknown as [
WritableDepreciationMethod,
...WritableDepreciationMethod[],
])
.optional(),
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(),
@@ -55,34 +55,6 @@ const UpdateAssetSchema = z
// in the PATCH handler below, which can read the existing row.
k3_components: z.array(K3ComponentSchema).nullable().optional(),
})
.superRefine((value, ctx) => {
// Enforce the method/target biconditional when EITHER field is supplied.
// We can't see the existing row from a zod refinement, so the
// application-level updateAsset() carries the cross-row check; here we
// only catch the obviously inconsistent combinations within a single
// PATCH body.
const hasMethod = value.depreciation_method !== undefined
const hasTarget = value.restvarde_target !== undefined
if (!hasMethod && !hasTarget) return
const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25'
const targetIsSet = value.restvarde_target !== null && value.restvarde_target !== undefined
if (hasMethod && isRestvarde && hasTarget && !targetIsSet) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
})
}
if (hasMethod && !isRestvarde && hasTarget && targetIsSet) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).',
})
}
})
export const GET = withRouteContext(
'assets.get',
+58 -1
View File
@@ -29,15 +29,19 @@ vi.mock('@/lib/auth/require-write', () => ({
}))
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
createAsset: vi.fn(),
listAssets: vi.fn(),
getAsset: vi.fn(),
updateAsset: vi.fn(),
}))
import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
import { createAsset, getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
import { GET, PATCH } from '../[id]/route'
import { POST } from '../route'
const mockGetAsset = vi.mocked(getAsset)
const mockUpdateAsset = vi.mocked(updateAsset)
const mockCreateAsset = vi.mocked(createAsset)
const routeParams = { params: Promise.resolve({ id: 'asset-1' }) }
beforeEach(() => {
@@ -71,7 +75,60 @@ describe('GET /api/assets/[id]', () => {
})
})
describe('POST /api/assets', () => {
it('rejects legacy per-asset tax depreciation methods with 400', async () => {
const response = await POST(createMockRequest('/api/assets', {
method: 'POST',
body: {
name: 'Maskin',
category: 'machinery',
acquisition_date: '2025-01-01',
acquisition_cost: 100_000,
useful_life_months: 60,
depreciation_method: 'declining_balance_30',
},
}))
expect(response.status).toBe(400)
})
it('creates a valid asset with ordinary linear depreciation', async () => {
mockCreateAsset.mockResolvedValue({ id: 'asset-new', depreciation_method: 'linear' } as never)
const response = await POST(createMockRequest('/api/assets', {
method: 'POST',
body: {
name: 'Maskin',
category: 'machinery',
acquisition_date: '2025-01-01',
acquisition_cost: 100_000,
useful_life_months: 60,
depreciation_method: 'linear',
},
}))
expect(response.status).toBe(200)
expect(mockCreateAsset).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({ depreciation_method: 'linear' }),
)
})
})
describe('PATCH /api/assets/[id]', () => {
it('rejects legacy per-asset tax depreciation methods with 400', async () => {
const req = createMockRequest('/api/assets/asset-1', {
method: 'PATCH',
body: { depreciation_method: 'declining_balance_30' },
})
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
expect(status).toBe(400)
expect(mockUpdateAsset).not.toHaveBeenCalled()
})
it('rejects an invalid body (non-positive acquisition_cost) with 400', async () => {
const req = createMockRequest('/api/assets/asset-1', {
method: 'PATCH',
+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