diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx
index bb94eb62..f9c2d61a 100644
--- a/app/(dashboard)/bookkeeping/year-end/page.tsx
+++ b/app/(dashboard)/bookkeeping/year-end/page.tsx
@@ -65,6 +65,7 @@ export default function YearEndPage() {
const [executing, setExecuting] = useState(false)
const [executeError, setExecuteError] = useState(null)
const [result, setResult] = useState(null)
+ const [navigationBlocked, setNavigationBlocked] = useState(false)
// ---- Load eligible periods ----
useEffect(() => {
@@ -231,6 +232,7 @@ export default function YearEndPage() {
annotation: `${p.period_start} till ${p.period_end}`,
}))}
value={selectedPeriodId}
+ disabled={navigationBlocked}
onChange={(value) => {
setSelectedPeriodId(value)
setStep('preflight')
@@ -296,13 +298,13 @@ export default function YearEndPage() {
type="button"
role="tab"
aria-selected={s === step}
- disabled={i >= currentStepIndex}
+ disabled={navigationBlocked || i >= currentStepIndex}
onClick={() => {
- if (i < currentStepIndex) setStep(s)
+ if (!navigationBlocked && i < currentStepIndex) setStep(s)
}}
className={cn(
'group flex shrink-0 items-center gap-2 text-left',
- i >= currentStepIndex && 'cursor-default',
+ (navigationBlocked || i >= currentStepIndex) && 'cursor-default',
)}
>
setStep('accruals')}
onContinue={goToPreview}
+ onNavigationBlockedChange={setNavigationBlocked}
/>
)}
diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts
index 76086d10..db8b82e0 100644
--- a/app/api/assets/[id]/route.ts
+++ b/app/api/assets/[id]/route.ts
@@ -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',
diff --git a/app/api/assets/__tests__/id.test.ts b/app/api/assets/__tests__/id.test.ts
index 71550bb9..73716506 100644
--- a/app/api/assets/__tests__/id.test.ts
+++ b/app/api/assets/__tests__/id.test.ts
@@ -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',
diff --git a/app/api/assets/route.ts b/app/api/assets/route.ts
index 3a89537e..4714c106 100644
--- a/app/api/assets/route.ts
+++ b/app/api/assets/route.ts
@@ -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
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/depreciation/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/depreciation/__tests__/route.test.ts
new file mode 100644
index 00000000..6742e778
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/depreciation/__tests__/route.test.ts
@@ -0,0 +1,259 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
+
+vi.mock('@/lib/bokslut/assets/depreciation-engine', () => ({
+ proposeAnnualPostings: vi.fn(),
+ commitAnnualPostings: vi.fn(),
+}))
+
+vi.mock('@/lib/bokslut/assets/tax-depreciation-service', async (importOriginal) => {
+ const actual = await importOriginal<
+ typeof import('@/lib/bokslut/assets/tax-depreciation-service')
+ >()
+ return {
+ ...actual,
+ loadTaxDepreciationView: vi.fn(),
+ previewTaxDepreciationElection: vi.fn(),
+ saveTaxDepreciationElection: vi.fn(),
+ }
+})
+
+const requireAuthMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+const requireWriteMock = vi.fn()
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+
+import {
+ commitAnnualPostings,
+ proposeAnnualPostings,
+} from '@/lib/bokslut/assets/depreciation-engine'
+import {
+ loadTaxDepreciationView,
+ previewTaxDepreciationElection,
+ saveTaxDepreciationElection,
+ TaxDepreciationPeriodLockedError,
+} from '@/lib/bokslut/assets/tax-depreciation-service'
+import { GET, POST, PUT } from '../route'
+
+const params = { params: Promise.resolve({ id: 'period-1' }) }
+const periodBuilder = {
+ select: vi.fn(),
+ eq: vi.fn(),
+ single: vi.fn(),
+}
+periodBuilder.select.mockReturnValue(periodBuilder)
+periodBuilder.eq.mockReturnValue(periodBuilder)
+const supabase = { from: vi.fn().mockReturnValue(periodBuilder) }
+const ordinary = {
+ fiscalPeriod: {
+ id: 'period-1',
+ name: '2025',
+ period_start: '2025-01-01',
+ period_end: '2025-12-31',
+ },
+ items: [],
+ totalAmount: 0,
+}
+const tax = {
+ status: 'ready' as const,
+ method: 'rakenskapsenlig' as const,
+ selectedRule: 'huvudregel_30' as const,
+ methodLocked: false,
+ openingTaxValue: 100_000,
+ openingSource: 'saved' as const,
+ periodMonths: 12,
+ eligibleAssetCount: 2,
+ excludedAssetCount: 0,
+ excludedCategories: [],
+ cohortHistoryComplete: true,
+ incompleteCohortCount: 0,
+ result: null,
+ snapshot: null,
+ isStale: false,
+}
+
+function get() {
+ return GET(createMockRequest('/api/bookkeeping/fiscal-periods/period-1/depreciation'), params)
+}
+
+function put(body: unknown) {
+ return PUT(
+ createMockRequest('/api/bookkeeping/fiscal-periods/period-1/depreciation', {
+ method: 'PUT',
+ body,
+ }),
+ params,
+ )
+}
+
+function post(body: unknown) {
+ return POST(
+ createMockRequest('/api/bookkeeping/fiscal-periods/period-1/depreciation', {
+ method: 'POST',
+ body,
+ }),
+ params,
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase,
+ error: null,
+ })
+ requireWriteMock.mockResolvedValue({ ok: true })
+ vi.mocked(proposeAnnualPostings).mockResolvedValue(ordinary)
+ vi.mocked(loadTaxDepreciationView).mockResolvedValue(tax)
+ vi.mocked(previewTaxDepreciationElection).mockResolvedValue(tax)
+ vi.mocked(saveTaxDepreciationElection).mockResolvedValue(tax)
+ vi.mocked(commitAnnualPostings).mockResolvedValue({ posted: [], skipped: [] })
+ periodBuilder.single.mockResolvedValue({
+ data: { is_closed: false, locked_at: null, closing_entry_id: null },
+ error: null,
+ })
+})
+
+describe('GET /api/bookkeeping/fiscal-periods/[id]/depreciation', () => {
+ it('returns 401 when unauthenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: {},
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ expect((await get()).status).toBe(401)
+ })
+
+ it('returns ordinary and tax depreciation for the active company', async () => {
+ const { status, body } = await parseJsonResponse<{ data: ProposalWithTax }>(
+ await get(),
+ )
+ expect(status).toBe(200)
+ expect(body.data).toEqual({ ...ordinary, tax })
+ expect(loadTaxDepreciationView).toHaveBeenCalledWith(supabase, 'company-1', 'period-1')
+ })
+
+ it('returns a read-only tax preview for validated query inputs', async () => {
+ const request = createMockRequest(
+ '/api/bookkeeping/fiscal-periods/period-1/depreciation?tax_method=rakenskapsenlig&tax_rule=huvudregel_30&opening_tax_value=100000',
+ )
+ expect((await GET(request, params)).status).toBe(200)
+ expect(previewTaxDepreciationElection).toHaveBeenCalledWith(
+ supabase,
+ 'company-1',
+ 'period-1',
+ {
+ method: 'rakenskapsenlig',
+ selectedRule: 'huvudregel_30',
+ openingTaxValue: 100_000,
+ },
+ )
+ })
+
+ it('returns 404 when the fiscal period is missing', async () => {
+ vi.mocked(loadTaxDepreciationView).mockRejectedValue(new Error('Fiscal period not found'))
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await get())
+ expect(status).toBe(404)
+ expect(body.error.code).toBe('PERIOD_NOT_FOUND')
+ })
+})
+
+describe('PUT /api/bookkeeping/fiscal-periods/[id]/depreciation', () => {
+ it('returns 401 when unauthenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: {},
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ expect((await put({
+ method: 'restvarde',
+ opening_tax_value: 100_000,
+ elected_deduction: 25_000,
+ })).status).toBe(401)
+ })
+
+ it('returns 400 for an incoherent method and annual rule', async () => {
+ expect((await put({
+ method: 'restvarde',
+ selected_rule: 'huvudregel_30',
+ elected_deduction: 25_000,
+ })).status).toBe(400)
+ expect(saveTaxDepreciationElection).not.toHaveBeenCalled()
+ })
+
+ it('returns 404 when the fiscal period is missing', async () => {
+ vi.mocked(saveTaxDepreciationElection).mockRejectedValue(new Error('Fiscal period not found'))
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
+ await put({
+ method: 'restvarde',
+ opening_tax_value: 100_000,
+ elected_deduction: 25_000,
+ }),
+ )
+ expect(status).toBe(404)
+ expect(body.error.code).toBe('PERIOD_NOT_FOUND')
+ })
+
+ it('returns PERIOD_LOCKED when the snapshot cannot be saved', async () => {
+ vi.mocked(saveTaxDepreciationElection).mockRejectedValue(
+ new TaxDepreciationPeriodLockedError('Fiscal period is locked'),
+ )
+ const { body } = await parseJsonResponse<{ error: { code: string } }>(
+ await put({
+ method: 'restvarde',
+ opening_tax_value: 100_000,
+ elected_deduction: 25_000,
+ }),
+ )
+ expect(body.error.code).toBe('PERIOD_LOCKED')
+ })
+
+ it('saves a validated annual election for the active company', async () => {
+ const { status } = await parseJsonResponse(
+ await put({
+ method: 'rakenskapsenlig',
+ selected_rule: 'kompletteringsregel_20',
+ opening_tax_value: 100_000,
+ elected_deduction: 20_000,
+ book_conformity_confirmed: true,
+ }),
+ )
+ expect(status).toBe(200)
+ expect(saveTaxDepreciationElection).toHaveBeenCalledWith(
+ supabase,
+ 'company-1',
+ 'user-1',
+ 'period-1',
+ {
+ method: 'rakenskapsenlig',
+ selectedRule: 'kompletteringsregel_20',
+ openingTaxValue: 100_000,
+ electedDeduction: 20_000,
+ bookConformityConfirmed: true,
+ },
+ )
+ })
+})
+
+type ProposalWithTax = typeof ordinary & { tax: typeof tax }
+
+describe('POST /api/bookkeeping/fiscal-periods/[id]/depreciation', () => {
+ it('continues to post ordinary depreciation only', async () => {
+ expect((await post({})).status).toBe(200)
+ expect(commitAnnualPostings).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'period-1', {
+ assetIds: undefined,
+ })
+ })
+})
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts
index 3869fd99..74a03c1e 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts
@@ -2,11 +2,18 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
-import { validateBody } from '@/lib/api/validate'
+import { validateBody, validateQuery } from '@/lib/api/validate'
import {
proposeAnnualPostings,
commitAnnualPostings,
} from '@/lib/bokslut/assets/depreciation-engine'
+import {
+ loadTaxDepreciationView,
+ previewTaxDepreciationElection,
+ saveTaxDepreciationElection,
+ TaxDepreciationPeriodLockedError,
+ TaxDepreciationValidationError,
+} from '@/lib/bokslut/assets/tax-depreciation-service'
const CommitSchema = z.object({
/** Optional whitelist: when supplied, only assets in this list are posted.
@@ -14,15 +21,99 @@ const CommitSchema = z.object({
asset_ids: z.array(z.string().uuid()).optional(),
})
+const TaxElectionSchema = z
+ .object({
+ method: z.enum(['rakenskapsenlig', 'restvarde']),
+ selected_rule: z.enum(['huvudregel_30', 'kompletteringsregel_20']).optional(),
+ opening_tax_value: z.number().nonnegative().optional(),
+ elected_deduction: z.number().nonnegative(),
+ book_conformity_confirmed: z.boolean().optional(),
+ })
+ .superRefine((value, ctx) => {
+ if (value.method === 'rakenskapsenlig' && !value.selected_rule) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['selected_rule'],
+ message: 'Välj 30-procentsregeln eller 20-procentsregeln.',
+ })
+ }
+ if (value.method === 'restvarde' && value.selected_rule) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['selected_rule'],
+ message: 'Restvärdeavskrivning har ingen kompletteringsregel.',
+ })
+ }
+ if (value.method === 'rakenskapsenlig' && value.book_conformity_confirmed !== true) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['book_conformity_confirmed'],
+ message: 'Bekräfta att avdraget motsvarar bokslutets totala avskrivning.',
+ })
+ }
+ })
+
+const TaxPreviewQuerySchema = z
+ .object({
+ tax_method: z.enum(['rakenskapsenlig', 'restvarde']).optional(),
+ tax_rule: z.enum(['huvudregel_30', 'kompletteringsregel_20']).optional(),
+ opening_tax_value: z.coerce.number().nonnegative().optional(),
+ })
+ .superRefine((value, ctx) => {
+ if (!value.tax_method && (value.tax_rule || value.opening_tax_value !== undefined)) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['tax_method'],
+ message: 'tax_method is required for a tax depreciation preview.',
+ })
+ }
+ if (value.tax_method === 'rakenskapsenlig' && !value.tax_rule) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['tax_rule'],
+ message: 'tax_rule is required for räkenskapsenlig depreciation.',
+ })
+ }
+ if (value.tax_method === 'restvarde' && value.tax_rule) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['tax_rule'],
+ message: 'tax_rule is not valid for restvärdeavskrivning.',
+ })
+ }
+ })
+
export const GET = withRouteContext(
'period.depreciation_preview',
- async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
+ const query = validateQuery(request, TaxPreviewQuerySchema, {
+ log,
+ operation: 'period.depreciation_preview',
+ })
+ if (!query.success) return query.response
try {
- const proposal = await proposeAnnualPostings(supabase, companyId, id)
- return NextResponse.json({ data: proposal })
+ const [ordinary, tax] = await Promise.all([
+ proposeAnnualPostings(supabase, companyId, id),
+ query.data.tax_method
+ ? previewTaxDepreciationElection(supabase, companyId, id, {
+ method: query.data.tax_method,
+ selectedRule: query.data.tax_rule,
+ openingTaxValue: query.data.opening_tax_value,
+ })
+ : loadTaxDepreciationView(supabase, companyId, id),
+ ])
+ return NextResponse.json({ data: { ...ordinary, tax } })
} catch (err) {
+ if (err instanceof TaxDepreciationValidationError) {
+ return errorResponseFromCode('VALIDATION_ERROR', log, {
+ requestId,
+ status: 400,
+ messageSv: 'Valet för skattemässig avskrivning är ogiltigt.',
+ messageEn: 'The tax depreciation election is invalid.',
+ })
+ }
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
@@ -32,6 +123,51 @@ export const GET = withRouteContext(
},
)
+export const PUT = withRouteContext(
+ 'period.tax_depreciation_save',
+ async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { user, supabase, companyId, log, requestId } = ctx
+ const validation = await validateBody(request, TaxElectionSchema)
+ if (!validation.success) return validation.response
+
+ try {
+ const tax = await saveTaxDepreciationElection(
+ supabase,
+ companyId,
+ user.id,
+ id,
+ {
+ method: validation.data.method,
+ selectedRule: validation.data.selected_rule,
+ openingTaxValue: validation.data.opening_tax_value,
+ electedDeduction: validation.data.elected_deduction,
+ bookConformityConfirmed: validation.data.book_conformity_confirmed,
+ },
+ )
+ return NextResponse.json({ data: tax })
+ } catch (err) {
+ if (err instanceof TaxDepreciationValidationError) {
+ return errorResponseFromCode('VALIDATION_ERROR', log, {
+ requestId,
+ status: 400,
+ messageSv: 'Valet för skattemässig avskrivning är ogiltigt.',
+ messageEn: 'The tax depreciation election is invalid.',
+ })
+ }
+ if (err instanceof TaxDepreciationPeriodLockedError) {
+ return errorResponseFromCode('PERIOD_LOCKED', log, { requestId })
+ }
+ const message = err instanceof Error ? err.message : ''
+ if (/not found/i.test(message)) {
+ return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
+ }
+ return errorResponse(err, log, { requestId })
+ }
+ },
+ { requireWrite: true },
+)
+
export const POST = withRouteContext(
'period.depreciation_commit',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
diff --git a/components/bookkeeping/assets/CreateAssetDialog.tsx b/components/bookkeeping/assets/CreateAssetDialog.tsx
index 2a81ca84..84d3ec4a 100644
--- a/components/bookkeeping/assets/CreateAssetDialog.tsx
+++ b/components/bookkeeping/assets/CreateAssetDialog.tsx
@@ -22,7 +22,7 @@ import { Loader2, Plus, X } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { formatCurrency } from '@/lib/utils'
-import type { AssetCategory, DepreciationMethod, K3Component } from '@/types'
+import type { AssetCategory, K3Component } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface CreateAssetDialogProps {
@@ -67,29 +67,6 @@ const CATEGORY_OPTIONS: { value: AssetCategory; label: string; defaultYears: num
{ value: 'other_tangible', label: 'Övrig materiell tillgång', defaultYears: 5 },
]
-const DEPRECIATION_METHOD_OPTIONS: { value: DepreciationMethod; label: string; hint: string }[] = [
- {
- value: 'linear',
- label: 'Linjär',
- hint: 'Planenlig raklinje över nyttjandeperioden (ÅRL 4 kap 4§).',
- },
- {
- value: 'declining_balance_30',
- label: 'Räkenskapsenlig 30 %',
- hint: 'Huvudregeln (IL 18 kap 13§): 30 % degressivt på avskrivningsunderlaget.',
- },
- {
- value: 'declining_balance_20',
- label: 'Räkenskapsenlig 20 %',
- hint: 'Kompletteringsregeln (IL 18 kap 17§): 20 % degressivt. Vanlig för byggnader.',
- },
- {
- value: 'restvardesavskrivning_25',
- label: 'Restvärdeavskrivning 25 %',
- hint: 'IL 18 kap 13§ st.3: 25 % degressivt ner till angivet restvärde.',
- },
-]
-
export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAssetDialogProps) {
const { toast } = useToast()
// useCompanyOptional so the dialog still works in tests / storyboards
@@ -104,8 +81,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
)
const [acquisitionCost, setAcquisitionCost] = useState('')
const [usefulLifeYears, setUsefulLifeYears] = useState('5')
- const [depreciationMethod, setDepreciationMethod] = useState('linear')
- const [restvardeTarget, setRestvardeTarget] = useState('')
// K3 component depreciation. `useComponents` toggles the advanced section;
// null when disabled, an array (possibly empty during editing) when enabled.
const [useComponents, setUseComponents] = useState(false)
@@ -119,10 +94,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
if (option) setUsefulLifeYears(option.defaultYears.toString())
}
- const isRestvarde = depreciationMethod === 'restvardesavskrivning_25'
- const methodHint =
- DEPRECIATION_METHOD_OPTIONS.find((o) => o.value === depreciationMethod)?.hint ?? ''
-
const totalComponentCost = useMemo(() => {
return componentRows.reduce((sum, row) => {
const v = parseFloat(row.cost)
@@ -161,19 +132,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
setError('Fyll i namn, anskaffningsvärde och avskrivningstid.')
return
}
- let restvardeTargetNumber: number | null = null
- if (isRestvarde) {
- const parsed = parseFloat(restvardeTarget)
- if (!Number.isFinite(parsed) || parsed < 0) {
- setError('Ange ett restvärde (0 kr eller högre).')
- return
- }
- if (parsed >= cost) {
- setError('Restvärdet måste vara lägre än anskaffningsvärdet.')
- return
- }
- restvardeTargetNumber = parsed
- }
// K3 components: only when both the framework permits (gate at API)
// and the user opted into the section. Empty array is invalid (the
// validator rejects it) so the dialog also flips back to "off" when
@@ -239,10 +197,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
acquisition_date: acquisitionDate,
acquisition_cost: cost,
useful_life_months: years * 12,
- depreciation_method: depreciationMethod,
- ...(restvardeTargetNumber !== null
- ? { restvarde_target: restvardeTargetNumber }
- : {}),
...(componentsPayload !== null ? { k3_components: componentsPayload } : {}),
}),
})
@@ -255,8 +209,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
// Reset form for next entry
setName('')
setAcquisitionCost('')
- setDepreciationMethod('linear')
- setRestvardeTarget('')
setUseComponents(false)
setComponentRows([])
onCreated()
@@ -340,44 +292,6 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
För skattemässig avskrivning kan annan livslängd gälla (IL 18-20 kap).
-
-
Avskrivningsmetod
-
setDepreciationMethod(v as DepreciationMethod)}
- >
-
-
-
-
- {DEPRECIATION_METHOD_OPTIONS.map((o) => (
-
- {o.label}
-
- ))}
-
-
-
{methodHint}
-
- {isRestvarde && (
-
-
Restvärde (kr)
-
setRestvardeTarget(e.target.value)}
- placeholder="t.ex. 5000"
- className="tabular-nums"
- />
-
- Avskrivningen stannar när bokfört värde når restvärdet. Restvärdet
- måste vara lägre än anskaffningsvärdet.
-
-
- )}
{isK3 && (
diff --git a/components/bookkeeping/assets/EditAssetDialog.tsx b/components/bookkeeping/assets/EditAssetDialog.tsx
index 6d29a35b..3921c69a 100644
--- a/components/bookkeeping/assets/EditAssetDialog.tsx
+++ b/components/bookkeeping/assets/EditAssetDialog.tsx
@@ -18,11 +18,11 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
-import { AlertTriangle, Loader2, Lock } from 'lucide-react'
+import { Loader2, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
-import type { Asset, AssetCategory, DepreciationMethod } from '@/types'
+import type { Asset, AssetCategory } from '@/types'
/** The list route annotates each asset with whether any depreciation has been
* posted against it. When true, the acquisition-basis fields are locked. */
@@ -35,8 +35,7 @@ interface EditAssetDialogProps {
onSaved: () => void
}
-// Same category labels and depreciation hints as CreateAssetDialog (kept in
-// sync deliberately: this register surface is Swedish-only, like creation).
+// Same category labels as CreateAssetDialog.
const CATEGORY_OPTIONS: { value: AssetCategory; label: string }[] = [
{ value: 'computer', label: 'Dator / IT-utrustning' },
{ value: 'equipment', label: 'Inventarier' },
@@ -48,29 +47,6 @@ const CATEGORY_OPTIONS: { value: AssetCategory; label: string }[] = [
{ value: 'other_tangible', label: 'Övrig materiell tillgång' },
]
-const DEPRECIATION_METHOD_OPTIONS: { value: DepreciationMethod; label: string; hint: string }[] = [
- {
- value: 'linear',
- label: 'Linjär',
- hint: 'Planenlig raklinje över nyttjandeperioden (ÅRL 4 kap 4§).',
- },
- {
- value: 'declining_balance_30',
- label: 'Räkenskapsenlig 30 %',
- hint: 'Huvudregeln (IL 18 kap 13§): 30 % degressivt på avskrivningsunderlaget.',
- },
- {
- value: 'declining_balance_20',
- label: 'Räkenskapsenlig 20 %',
- hint: 'Kompletteringsregeln (IL 18 kap 17§): 20 % degressivt. Vanlig för byggnader.',
- },
- {
- value: 'restvardesavskrivning_25',
- label: 'Restvärdeavskrivning 25 %',
- hint: 'IL 18 kap 13§ st.3: 25 % degressivt ner till angivet restvärde.',
- },
-]
-
export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAssetDialogProps) {
const { toast } = useToast()
const { canWrite } = useCanWrite()
@@ -86,19 +62,9 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
const [usefulLifeYears, setUsefulLifeYears] = useState(
String(Math.round(asset.useful_life_months / 12)),
)
- const [depreciationMethod, setDepreciationMethod] = useState(
- asset.depreciation_method,
- )
- const [restvardeTarget, setRestvardeTarget] = useState(
- asset.restvarde_target != null ? String(asset.restvarde_target) : '',
- )
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
- const isRestvarde = depreciationMethod === 'restvardesavskrivning_25'
- const methodHint =
- DEPRECIATION_METHOD_OPTIONS.find((o) => o.value === depreciationMethod)?.hint ?? ''
-
const handleSubmit = async () => {
setError(null)
const trimmedName = name.trim()
@@ -133,31 +99,6 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
const months = years * 12
if (months !== asset.useful_life_months) patch.useful_life_months = months
- if (depreciationMethod !== asset.depreciation_method) {
- patch.depreciation_method = depreciationMethod
- }
-
- if (isRestvarde) {
- const target = parseFloat(restvardeTarget)
- const cost = !basisLocked ? parseFloat(acquisitionCost) : Number(asset.acquisition_cost)
- if (!Number.isFinite(target) || target < 0) {
- setError('Ange ett restvärde (0 kr eller högre).')
- return
- }
- if (Number.isFinite(cost) && target >= cost) {
- setError('Restvärdet måste vara lägre än anskaffningsvärdet.')
- return
- }
- // Send the target when switching into restvärde or when it changed, so
- // the method/target biconditional always holds.
- if (
- depreciationMethod !== asset.depreciation_method ||
- target !== Number(asset.restvarde_target)
- ) {
- patch.restvarde_target = target
- }
- }
-
if (Object.keys(patch).length === 0) {
toast({ title: 'Inga ändringar', description: 'Inget att spara.' })
onOpenChange(false)
@@ -255,7 +196,7 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
Anskaffningsdatum, anskaffningsvärde och kategori är låsta eftersom avskrivningar
redan har bokförts. Återför avskrivningen (storno) eller använd avyttring för att
- ändra grunduppgifterna. Namn, avskrivningstid och metod kan fortfarande justeras.
+ ändra grunduppgifterna. Namn och avskrivningstid kan fortfarande justeras.
)}
@@ -274,58 +215,6 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
/>
-
-
Avskrivningsmetod
-
setDepreciationMethod(v as DepreciationMethod)}
- >
-
-
-
-
- {DEPRECIATION_METHOD_OPTIONS.map((o) => (
-
- {o.label}
-
- ))}
-
-
-
{methodHint}
-
-
- {basisLocked && depreciationMethod !== asset.depreciation_method && (
-
-
-
- Byte av avskrivningsmetod efter att avskrivning påbörjats. Enligt K2
- (BFNAR 2016:10 p. 10.26) ska vald metod tillämpas konsekvent: ändra
- bara vid särskilda skäl och lämna i så fall upplysning i bokslutet.
- Ändringen gäller framåt; redan bokförda avskrivningar påverkas inte.
-
-
- )}
-
- {isRestvarde && (
-
-
Restvärde (kr)
-
setRestvardeTarget(e.target.value)}
- placeholder="t.ex. 5000"
- className="tabular-nums"
- />
-
- Avskrivningen stannar när bokfört värde når restvärdet. Restvärdet måste vara lägre
- än anskaffningsvärdet.
-
-
- )}
-
{error && (
{error}
diff --git a/components/bookkeeping/year-end/DepreciationPanel.tsx b/components/bookkeeping/year-end/DepreciationPanel.tsx
index d90ce8e9..cd97278c 100644
--- a/components/bookkeeping/year-end/DepreciationPanel.tsx
+++ b/components/bookkeeping/year-end/DepreciationPanel.tsx
@@ -1,11 +1,21 @@
'use client'
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Loader2 } from 'lucide-react'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
import Link from 'next/link'
import {
Table,
@@ -35,19 +45,73 @@ interface Proposal {
totalAmount: number
}
+type TaxMethod = 'rakenskapsenlig' | 'restvarde'
+type TaxRule = 'huvudregel_30' | 'kompletteringsregel_20'
+
+interface TaxAlternative {
+ rule: TaxRule | 'restvarde_25'
+ rate: number | null
+ deduction: number
+ closingTaxValue: number
+}
+
+interface TaxResult {
+ openingTaxValue: number
+ additions: number
+ disposals: number
+ basis: number
+ maximumDeduction: number
+ deduction: number
+ closingTaxValue: number
+ excessDisposals: number
+ alternatives: TaxAlternative[]
+}
+
+interface TaxView {
+ status:
+ | 'needs_previous_period'
+ | 'needs_period_history'
+ | 'needs_method'
+ | 'needs_opening_value'
+ | 'needs_rule'
+ | 'ready'
+ method: TaxMethod | null
+ selectedRule: TaxRule | null
+ methodLocked: boolean
+ openingTaxValue: number | null
+ openingSource: 'saved' | 'previous_period' | 'previous_period_required' | 'manual_required'
+ periodMonths: number
+ eligibleAssetCount: number
+ excludedAssetCount: number
+ excludedCategories: string[]
+ cohortHistoryComplete: boolean
+ incompleteCohortCount: number
+ result: TaxResult | null
+ snapshot: { deduction: number } | null
+ isStale: boolean
+}
+
interface DepreciationPanelProps {
periodId: string
/** Called after a successful post: parent refetches dispositions because
* posted avskrivningar change the result which affects bolagsskatt etc. */
onPosted: () => void
+ onTaxDirtyChange?: (dirty: boolean) => void
}
-export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps) {
+export function DepreciationPanel({ periodId, onPosted, onTaxDirtyChange }: DepreciationPanelProps) {
const { toast } = useToast()
const [proposal, setProposal] = useState
(null)
+ const [tax, setTax] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [posting, setPosting] = useState(false)
+ const [taxDirty, setTaxDirty] = useState(false)
+
+ const handleTaxDirtyChange = useCallback((dirty: boolean) => {
+ setTaxDirty(dirty)
+ onTaxDirtyChange?.(dirty)
+ }, [onTaxDirtyChange])
const load = useCallback(async () => {
setLoading(true)
@@ -60,6 +124,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
return
}
setProposal(body.data as Proposal)
+ setTax(body.data.tax as TaxView)
} catch (err) {
setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
@@ -72,6 +137,10 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
}, [load])
const handlePost = useCallback(async () => {
+ if (taxDirty) {
+ setError('Spara eller återställ ändringarna i skattemässig avskrivning först.')
+ return
+ }
setPosting(true)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`, {
@@ -97,7 +166,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
} finally {
setPosting(false)
}
- }, [periodId, onPosted, load, toast])
+ }, [periodId, onPosted, load, toast, taxDirty])
if (loading) {
return (
@@ -120,6 +189,15 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
if (!proposal) return null
+ const taxPanel = tax ? (
+
+ ) : null
+
const allPosted =
proposal.items.length > 0 && proposal.items.every((i) => Boolean(i.existingJournalEntryId))
const anyPending =
@@ -127,22 +205,26 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
if (proposal.items.length === 0) {
return (
-
-
- Planenliga avskrivningar
-
-
- Inga aktiva anläggningstillgångar att skriva av.{' '}
-
- Lägg till tillgångar
- {' '}
- så räknar bokslutet ut avskrivningarna automatiskt.
-
-
+
+
+
+ Planenliga avskrivningar
+
+
+ Inga aktiva anläggningstillgångar att skriva av.{' '}
+
+ Lägg till tillgångar
+ {' '}
+ så räknar bokslutet ut avskrivningarna automatiskt.
+
+
+ {taxPanel}
+
)
}
return (
+
@@ -195,7 +277,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
{anyPending && (
-
+
{posting ? (
<>
Bokför…
@@ -208,5 +290,441 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
)}
+ {taxPanel}
+
+ )
+}
+
+function TaxDepreciationCard({
+ periodId,
+ view,
+ onSaved,
+ onDirtyChange,
+}: {
+ periodId: string
+ view: TaxView
+ onSaved: () => Promise
+ onDirtyChange?: (dirty: boolean) => void
+}) {
+ const { toast } = useToast()
+ const [method, setMethod] = useState(view.method ?? 'rakenskapsenlig')
+ const [rule, setRule] = useState(view.selectedRule ?? 'huvudregel_30')
+ const [openingValue, setOpeningValue] = useState(
+ view.openingTaxValue === null ? '' : String(view.openingTaxValue),
+ )
+ const [electedDeduction, setElectedDeduction] = useState(
+ view.snapshot ? String(view.snapshot.deduction) : '',
+ )
+ const [bookConformityConfirmed, setBookConformityConfirmed] = useState(false)
+ const [preview, setPreview] = useState(null)
+ const [dirty, setDirty] = useState(false)
+ const [previewing, setPreviewing] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [saveError, setSaveError] = useState(null)
+ const requestVersion = useRef(0)
+ const saveSequence = useRef(0)
+
+ useEffect(() => {
+ requestVersion.current += 1
+ setMethod(view.method ?? 'rakenskapsenlig')
+ setRule(view.selectedRule ?? 'huvudregel_30')
+ setOpeningValue(view.openingTaxValue === null ? '' : String(view.openingTaxValue))
+ setElectedDeduction(view.snapshot ? String(view.snapshot.deduction) : '')
+ setBookConformityConfirmed(false)
+ setPreview(null)
+ setDirty(false)
+ }, [view])
+
+ useEffect(() => {
+ onDirtyChange?.(dirty)
+ }, [dirty, onDirtyChange])
+
+ useEffect(() => () => onDirtyChange?.(false), [onDirtyChange])
+
+ const resetCalculatedDraft = () => {
+ setPreview(null)
+ setElectedDeduction('')
+ setBookConformityConfirmed(false)
+ setSaveError(null)
+ setDirty(true)
+ }
+
+ const restoreSavedValues = () => {
+ requestVersion.current += 1
+ setMethod(view.method ?? 'rakenskapsenlig')
+ setRule(view.selectedRule ?? 'huvudregel_30')
+ setOpeningValue(view.openingTaxValue === null ? '' : String(view.openingTaxValue))
+ setElectedDeduction(view.snapshot ? String(view.snapshot.deduction) : '')
+ setBookConformityConfirmed(false)
+ setPreview(null)
+ setPreviewing(false)
+ setSaving(false)
+ setSaveError(null)
+ setDirty(false)
+ }
+
+ const parseOpening = (): number | null => {
+ if (openingValue.trim() === '') {
+ setSaveError('Ange skattemässigt värde vid årets ingång.')
+ return null
+ }
+ const opening = Number(openingValue.replace(',', '.'))
+ if (!Number.isFinite(opening) || opening < 0) {
+ setSaveError('Ange ett ingående skattemässigt värde på 0 kr eller mer.')
+ return null
+ }
+ return opening
+ }
+
+ const loadPreview = async () => {
+ setSaveError(null)
+ const opening = parseOpening()
+ if (opening === null) return
+ setDirty(true)
+ setPreviewing(true)
+ const version = ++requestVersion.current
+ try {
+ const query = new URLSearchParams({
+ tax_method: method,
+ opening_tax_value: String(opening),
+ })
+ if (method === 'rakenskapsenlig') query.set('tax_rule', rule)
+ const response = await fetch(
+ `/api/bookkeeping/fiscal-periods/${periodId}/depreciation?${query.toString()}`,
+ )
+ const body = await response.json()
+ if (version !== requestVersion.current) return
+ if (!response.ok) {
+ setSaveError(getUserErrorMessage(body?.error ?? body) ?? 'Kunde inte beräkna förslaget')
+ return
+ }
+ const nextPreview = body.data.tax as TaxView
+ setPreview(nextPreview)
+ setElectedDeduction(
+ nextPreview.result ? String(nextPreview.result.maximumDeduction) : '',
+ )
+ setBookConformityConfirmed(false)
+ setDirty(true)
+ } catch (err) {
+ if (version !== requestVersion.current) return
+ setSaveError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
+ } finally {
+ if (version === requestVersion.current) setPreviewing(false)
+ }
+ }
+
+ const save = async () => {
+ setSaveError(null)
+ const opening = parseOpening()
+ if (opening === null) return
+ const calculation = preview ?? (dirty && view.result ? view : null)
+ if (!calculation?.result) {
+ setSaveError('Beräkna förslaget innan valet sparas.')
+ return
+ }
+ if (electedDeduction.trim() === '') {
+ setSaveError('Ange årets faktiska skattemässiga avdrag.')
+ return
+ }
+ const deduction = Number(electedDeduction.replace(',', '.'))
+ if (!Number.isFinite(deduction) || deduction < 0) {
+ setSaveError('Årets faktiska avdrag måste vara 0 kr eller mer.')
+ return
+ }
+ if (deduction > calculation.result.maximumDeduction) {
+ setSaveError('Avdraget får inte överstiga högsta avdrag enligt den valda regeln.')
+ return
+ }
+ if (method === 'rakenskapsenlig' && !bookConformityConfirmed) {
+ setSaveError('Bekräfta att avdraget motsvarar bokslutets totala avskrivning.')
+ return
+ }
+ setSaving(true)
+ const version = ++requestVersion.current
+ const saveId = ++saveSequence.current
+ try {
+ const response = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ method,
+ ...(method === 'rakenskapsenlig' ? { selected_rule: rule } : {}),
+ opening_tax_value: opening,
+ elected_deduction: deduction,
+ ...(method === 'rakenskapsenlig'
+ ? { book_conformity_confirmed: bookConformityConfirmed }
+ : {}),
+ }),
+ })
+ const body = await response.json()
+ if (version !== requestVersion.current) return
+ if (!response.ok) {
+ setSaveError(getUserErrorMessage(body?.error) ?? 'Kunde inte spara beräkningen')
+ return
+ }
+ toast({ title: 'Skattemässig avskrivning sparad' })
+ setDirty(false)
+ await onSaved()
+ } catch (err) {
+ if (version !== requestVersion.current) return
+ setSaveError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
+ } finally {
+ // A successful save triggers onSaved -> parent load -> new view prop,
+ // which bumps requestVersion before this finally runs. Gate on the
+ // save sequence instead so the card never stays stuck busy after its
+ // own save completes.
+ if (saveId === saveSequence.current) setSaving(false)
+ }
+ }
+
+ const calculation = dirty ? preview : view
+ const result = calculation?.result ?? null
+ const controlsBusy = previewing || saving
+ const formBlocked = view.openingSource === 'previous_period_required'
+
+ return (
+
+
+
+
+
Skattemässig avskrivning
+
+ Beräkna högsta avdrag för den gemensamma inventariepoolen enligt IL 18 kap.
+
+
+ Du sparar det faktiska avdraget efter avstämning. Eventuell överavskrivning
+ bokförs separat.
+
+
+ {dirty &&
Ej sparad }
+ {!dirty && view.snapshot && !view.isStale && (
+
Sparad
+ )}
+ {!dirty && view.isStale &&
Behöver sparas om }
+
+
+
+ {view.openingSource === 'previous_period_required' && (
+
+ Spara skattemässig avskrivning för närmast föregående räkenskapsår först. Ett osparat
+ mellanår får inte hoppas över.
+
+ )}
+ {view.status === 'needs_period_history' && (
+
+ Kompletteringsregeln kan inte beräknas utan fullständig räkenskapsårshistorik för
+ alla kvarvarande inventarier. Välj huvudregeln eller komplettera periodhistoriken.
+
+ )}
+
+
+
Metod
+
{
+ setMethod(value as TaxMethod)
+ resetCalculatedDraft()
+ }}
+ disabled={view.methodLocked || controlsBusy || formBlocked}
+ >
+
+
+ Räkenskapsenlig
+ Restvärdeavskrivning
+
+
+ {view.methodLocked && (
+
+ Metoden följer föregående sparade år. Ett byte kräver en separat övergångsbedömning.
+
+ )}
+
+ {method === 'rakenskapsenlig' && (
+
+ Årets regel
+ {
+ setRule(value as TaxRule)
+ resetCalculatedDraft()
+ }}
+ disabled={controlsBusy || formBlocked}
+ >
+
+
+ Huvudregeln 30 %
+ Kompletteringsregeln 20 %
+
+
+
+ )}
+
+
Skattemässigt värde vid ingången
+
{
+ setOpeningValue(event.target.value)
+ resetCalculatedDraft()
+ }}
+ disabled={
+ view.openingSource === 'previous_period'
+ || formBlocked
+ || controlsBusy
+ }
+ className="tabular-nums"
+ />
+ {view.openingSource === 'previous_period' && (
+
Hämtat från föregående sparade år.
+ )}
+
+
+
+ {dirty && !preview && (
+
+ Inställningarna är ändrade. Beräkna förslaget för att se rätt gränsbelopp innan du
+ sparar.
+
+ )}
+
+ {result && (
+ <>
+
+
+
+
+
+
+ {!dirty && view.snapshot ? (
+
+ ) : (
+
+ )}
+
+ {method === 'rakenskapsenlig' && (
+
+
+ Jämförelse
+
+ {result.alternatives.map((alternative) => (
+
+ {alternative.rule === 'huvudregel_30' ? '30-procentsregeln' : '20-procentsregeln'}
+
+ {formatCurrency(alternative.closingTaxValue)} kvar
+
+
+ ))}
+
+ )}
+ {dirty && (
+
+
Faktiskt avdrag efter avstämning
+
setElectedDeduction(event.target.value)}
+ disabled={controlsBusy}
+ className="max-w-xs tabular-nums"
+ />
+
+ Beloppet får vara lägre än gränsbeloppet men aldrig högre.
+
+
+ )}
+ {dirty && method === 'rakenskapsenlig' && (
+
+ setBookConformityConfirmed(checked === true)}
+ disabled={controlsBusy}
+ />
+
+ Jag har stämt av att det faktiska avdraget motsvarar bokslutets totala
+ avskrivning, inklusive eventuell bokförd överavskrivning.
+
+
+ )}
+ >
+ )}
+
+ {view.excludedAssetCount > 0 && (
+
+ {view.excludedAssetCount} tillgång{view.excludedAssetCount === 1 ? '' : 'ar'} i andra
+ kategorier ingår inte i inventariepoolen och måste bedömas separat.
+
+ )}
+ {result && result.excessDisposals > 0 && (
+
+ Avyttringsersättningen överstiger underlaget med {formatCurrency(result.excessDisposals)}.
+ Överskjutande belopp behöver hanteras i deklarationen.
+
+ )}
+ {saveError && {saveError}
}
+
+
+ {view.eligibleAssetCount} tillgång{view.eligibleAssetCount === 1 ? '' : 'ar'} i poolen,
+ {view.periodMonths} månader i räkenskapsåret.
+
+
+ {dirty && (
+
+ Återställ
+
+ )}
+
+ {previewing && }
+ Beräkna förslag
+
+
+ {saving && }
+ Spara faktiskt avdrag
+
+
+
+
+
+ )
+}
+
+function TaxValue({ label, value, strong = false }: { label: string; value: number; strong?: boolean }) {
+ return (
+
+
{label}
+
{formatCurrency(value)}
+
)
}
diff --git a/components/bookkeeping/year-end/DispositionsStep.tsx b/components/bookkeeping/year-end/DispositionsStep.tsx
index a854ca85..ca88bb15 100644
--- a/components/bookkeeping/year-end/DispositionsStep.tsx
+++ b/components/bookkeeping/year-end/DispositionsStep.tsx
@@ -25,6 +25,7 @@ interface DispositionsStepProps {
periodId: string
onBack: () => void
onContinue: () => void
+ onNavigationBlockedChange?: (blocked: boolean) => void
}
interface UiState {
@@ -47,7 +48,12 @@ interface TaxAdjustmentDraft {
* EF companies get an empty `proposals` array from the server, so this step
* renders a short pass-through note and lets the user continue.
*/
-export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsStepProps) {
+export function DispositionsStep({
+ periodId,
+ onBack,
+ onContinue,
+ onNavigationBlockedChange,
+}: DispositionsStepProps) {
const { toast } = useToast()
const [proposal, setProposal] = useState(null)
const [loading, setLoading] = useState(true)
@@ -58,6 +64,13 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
const [savingAdjustments, setSavingAdjustments] = useState(false)
const [adjustmentError, setAdjustmentError] = useState(null)
const [taxAdjustmentDraft, setTaxAdjustmentDraft] = useState(emptyTaxDraft)
+ const [taxDepreciationDirty, setTaxDepreciationDirty] = useState(false)
+
+ useEffect(() => {
+ onNavigationBlockedChange?.(taxDepreciationDirty)
+ }, [onNavigationBlockedChange, taxDepreciationDirty])
+
+ useEffect(() => () => onNavigationBlockedChange?.(false), [onNavigationBlockedChange])
// ---- Fetch proposals ----
const loadProposals = useCallback(async () => {
@@ -136,6 +149,10 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
// ---- POST accepted dispositions ----
const handleCommit = useCallback(async () => {
if (!proposal) return
+ if (taxDepreciationDirty) {
+ setPostError('Spara eller återställ ändringarna i skattemässig avskrivning innan du fortsätter.')
+ return
+ }
if (proposal.completedDispositions?.some((item) => item.status === 'needs_correction')) {
setPostError('Rätta den bokförda bolagsskatten och ladda om sidan innan du fortsätter.')
return
@@ -172,7 +189,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
} finally {
setPosting(false)
}
- }, [proposal, ui, periodId, onContinue, toast])
+ }, [proposal, ui, periodId, onContinue, toast, taxDepreciationDirty])
// ---- Render branches ----
if (loading) {
@@ -209,17 +226,41 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
const fiscalYear = parseInt(proposal.fiscalPeriod.period_end.slice(0, 4), 10)
return (
-
void loadProposals()} />
+ void loadProposals()}
+ onTaxDirtyChange={setTaxDepreciationDirty}
+ />
+ {taxDepreciationDirty && (
+
+ Spara eller återställ ändringarna i skattemässig avskrivning innan du lämnar steget.
+
+ )}
-
+
← Tillbaka
-
+
Nästa: Förhandsgranska →
@@ -229,7 +270,11 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
return (
-
void loadProposals()} />
+ void loadProposals()}
+ onTaxDirtyChange={setTaxDepreciationDirty}
+ />
Bokslutsdispositioner
@@ -332,11 +377,30 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
)}
+ {taxDepreciationDirty && (
+
+ Spara eller återställ ändringarna i skattemässig avskrivning innan du lämnar steget.
+
+ )}
+
-
+
← Tillbaka
-
+
{posting ? (
<>
Bokför…
diff --git a/lib/bokslut/__tests__/depreciation-engine.test.ts b/lib/bokslut/__tests__/depreciation-engine.test.ts
index 9e5f4df6..c0be6096 100644
--- a/lib/bokslut/__tests__/depreciation-engine.test.ts
+++ b/lib/bokslut/__tests__/depreciation-engine.test.ts
@@ -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)
- })
-})
diff --git a/lib/bokslut/__tests__/tax-depreciation-service.test.ts b/lib/bokslut/__tests__/tax-depreciation-service.test.ts
new file mode 100644
index 00000000..9e0b46bc
--- /dev/null
+++ b/lib/bokslut/__tests__/tax-depreciation-service.test.ts
@@ -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 {
+ 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',
+ })
+ })
+})
diff --git a/lib/bokslut/__tests__/tax-depreciation.test.ts b/lib/bokslut/__tests__/tax-depreciation.test.ts
new file mode 100644
index 00000000..26f46c8a
--- /dev/null
+++ b/lib/bokslut/__tests__/tax-depreciation.test.ts
@@ -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)
+ })
+})
diff --git a/lib/bokslut/arsredovisning/__tests__/asset-note-figures.test.ts b/lib/bokslut/arsredovisning/__tests__/asset-note-figures.test.ts
index 139f60dd..6bb9d7b1 100644
--- a/lib/bokslut/arsredovisning/__tests__/asset-note-figures.test.ts
+++ b/lib/bokslut/arsredovisning/__tests__/asset-note-figures.test.ts
@@ -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',
diff --git a/lib/bokslut/assets/__tests__/k3-components.test.ts b/lib/bokslut/assets/__tests__/k3-components.test.ts
index 75ea6a9f..dc341ec4 100644
--- a/lib/bokslut/assets/__tests__/k3-components.test.ts
+++ b/lib/bokslut/assets/__tests__/k3-components.test.ts
@@ -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 },
diff --git a/lib/bokslut/assets/asset-service.ts b/lib/bokslut/assets/asset-service.ts
index 6a6e1c2c..0d2e79c2 100644
--- a/lib/bokslut/assets/asset-service.ts
+++ b/lib/bokslut/assets/asset-service.ts
@@ -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 {
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 {
- // 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)
diff --git a/lib/bokslut/assets/depreciation-engine.ts b/lib/bokslut/assets/depreciation-engine.ts
index dd7ae530..23bfa6f9 100644
--- a/lib/bokslut/assets/depreciation-engine.ts
+++ b/lib/bokslut/assets/depreciation-engine.ts
@@ -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,
- 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(
diff --git a/lib/bokslut/assets/tax-depreciation-service.ts b/lib/bokslut/assets/tax-depreciation-service.ts
new file mode 100644
index 00000000..60ded808
--- /dev/null
+++ b/lib/bokslut/assets/tax-depreciation-service.ts
@@ -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 | null
+}
+
+export interface TaxDepreciationSnapshot {
+ method: TaxDepreciationMethod
+ selectedRule: TaxDepreciationRule | null
+ openingTaxValue: number
+ basis: number
+ deduction: number
+ closingTaxValue: number
+ calculation: Record | 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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(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()
+ 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
+}
diff --git a/lib/bokslut/assets/tax-depreciation.ts b/lib/bokslut/assets/tax-depreciation.ts
new file mode 100644
index 00000000..ca13dc6c
--- /dev/null
+++ b/lib/bokslut/assets/tax-depreciation.ts
@@ -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
+}
+
+/**
+ * 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
+}
diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json
index 3b1a43f1..9c2f537b 100644
--- a/scripts/checks/antipatterns-baseline.json
+++ b/scripts/checks/antipatterns-baseline.json
@@ -7,10 +7,10 @@
]
},
"naiveOreRound": {
- "count": 641
+ "count": 638
},
"handRolledInvariants": {
- "count": 114
+ "count": 115
},
"ledgerScanningReports": {
"count": 4,
diff --git a/supabase/migrations/20260803227000_tax_depreciation_elections.sql b/supabase/migrations/20260803227000_tax_depreciation_elections.sql
new file mode 100644
index 00000000..2358d422
--- /dev/null
+++ b/supabase/migrations/20260803227000_tax_depreciation_elections.sql
@@ -0,0 +1,173 @@
+-- Issue #324: separate ordinary asset depreciation from the pooled tax
+-- depreciation election under IL 18 kap.
+
+ALTER TABLE public.company_settings
+ ADD COLUMN tax_depreciation_method TEXT NULL;
+
+ALTER TABLE public.company_settings
+ ADD CONSTRAINT company_settings_tax_depreciation_method_check
+ CHECK (tax_depreciation_method IS NULL OR tax_depreciation_method IN (
+ 'rakenskapsenlig',
+ 'restvarde'
+ ));
+
+ALTER TABLE public.fiscal_periods
+ ADD COLUMN tax_depreciation_method TEXT NULL,
+ ADD COLUMN tax_depreciation_rule TEXT NULL,
+ ADD COLUMN tax_depreciation_opening_value NUMERIC(15, 2) NULL,
+ ADD COLUMN tax_depreciation_base NUMERIC(15, 2) NULL,
+ ADD COLUMN tax_depreciation_deduction NUMERIC(15, 2) NULL,
+ ADD COLUMN tax_depreciation_closing_value NUMERIC(15, 2) NULL,
+ ADD COLUMN tax_depreciation_calculation JSONB NULL;
+
+ALTER TABLE public.fiscal_periods
+ ADD CONSTRAINT fiscal_periods_tax_depreciation_snapshot_check
+ CHECK (
+ (
+ tax_depreciation_method IS NULL
+ AND tax_depreciation_rule IS NULL
+ AND tax_depreciation_opening_value IS NULL
+ AND tax_depreciation_base IS NULL
+ AND tax_depreciation_deduction IS NULL
+ AND tax_depreciation_closing_value IS NULL
+ AND tax_depreciation_calculation IS NULL
+ )
+ OR
+ (
+ tax_depreciation_method IN ('rakenskapsenlig', 'restvarde')
+ AND tax_depreciation_opening_value >= 0
+ AND tax_depreciation_base >= 0
+ AND tax_depreciation_deduction >= 0
+ AND tax_depreciation_closing_value >= 0
+ AND jsonb_typeof(tax_depreciation_calculation) = 'object'
+ AND (
+ (
+ tax_depreciation_method = 'rakenskapsenlig'
+ AND tax_depreciation_rule IN ('huvudregel_30', 'kompletteringsregel_20')
+ )
+ OR
+ (
+ tax_depreciation_method = 'restvarde'
+ AND tax_depreciation_rule IS NULL
+ )
+ )
+ )
+ );
+
+CREATE OR REPLACE FUNCTION public.guard_fiscal_period_tax_depreciation()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF (
+ NEW.tax_depreciation_method IS DISTINCT FROM OLD.tax_depreciation_method
+ OR NEW.tax_depreciation_rule IS DISTINCT FROM OLD.tax_depreciation_rule
+ OR NEW.tax_depreciation_opening_value IS DISTINCT FROM OLD.tax_depreciation_opening_value
+ OR NEW.tax_depreciation_base IS DISTINCT FROM OLD.tax_depreciation_base
+ OR NEW.tax_depreciation_deduction IS DISTINCT FROM OLD.tax_depreciation_deduction
+ OR NEW.tax_depreciation_closing_value IS DISTINCT FROM OLD.tax_depreciation_closing_value
+ OR NEW.tax_depreciation_calculation IS DISTINCT FROM OLD.tax_depreciation_calculation
+ ) AND (
+ OLD.is_closed = true
+ OR OLD.locked_at IS NOT NULL
+ OR OLD.closing_entry_id IS NOT NULL
+ ) THEN
+ RAISE EXCEPTION 'Fiscal period is locked for tax depreciation elections'
+ USING ERRCODE = 'check_violation';
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER guard_fiscal_period_tax_depreciation
+ BEFORE UPDATE OF
+ tax_depreciation_method,
+ tax_depreciation_rule,
+ tax_depreciation_opening_value,
+ tax_depreciation_base,
+ tax_depreciation_deduction,
+ tax_depreciation_closing_value,
+ tax_depreciation_calculation
+ ON public.fiscal_periods
+ FOR EACH ROW EXECUTE FUNCTION public.guard_fiscal_period_tax_depreciation();
+
+-- Stop the rollout if an active legacy tax-labelled asset has already driven
+-- a posted ordinary-depreciation voucher. That state needs an explicit storno
+-- and accountant review, not an automatic data rewrite.
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM public.assets a
+ JOIN public.depreciation_schedules ds ON ds.asset_id = a.id
+ WHERE a.disposed_at IS NULL
+ AND a.depreciation_method <> 'linear'
+ AND ds.journal_entry_id IS NOT NULL
+ ) THEN
+ RAISE EXCEPTION 'Cannot normalize active tax-labelled assets with posted depreciation schedules';
+ END IF;
+END;
+$$;
+
+-- Active rows without postings can safely return to ordinary linear book
+-- depreciation. Disposed rows retain their historical method values because
+-- their financial attributes are immutable.
+UPDATE public.assets
+SET depreciation_method = 'linear',
+ restvarde_target = NULL
+WHERE disposed_at IS NULL
+ AND depreciation_method <> 'linear';
+
+ALTER TABLE public.assets
+ DROP CONSTRAINT IF EXISTS assets_restvarde_target_method_match;
+
+CREATE OR REPLACE FUNCTION public.enforce_ordinary_asset_depreciation_method()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF TG_OP = 'INSERT' THEN
+ IF NEW.depreciation_method <> 'linear' THEN
+ RAISE EXCEPTION 'Asset depreciation_method must be linear; tax depreciation is elected per fiscal period'
+ USING ERRCODE = 'check_violation';
+ END IF;
+ IF NEW.restvarde_target IS NOT NULL THEN
+ RAISE EXCEPTION 'Asset restvarde_target is deprecated; tax rest value is calculated per fiscal period'
+ USING ERRCODE = 'check_violation';
+ END IF;
+ ELSE
+ -- Judge the post-write state: NEW.disposed_at also catches an UPDATE that
+ -- reverses a disposal on a grandfathered non-linear row, which would
+ -- otherwise reactivate it with a tax-method label.
+ IF NEW.depreciation_method <> 'linear' AND (
+ OLD.depreciation_method IS DISTINCT FROM NEW.depreciation_method
+ OR NEW.disposed_at IS NULL
+ ) THEN
+ RAISE EXCEPTION 'Asset depreciation_method must be linear; tax depreciation is elected per fiscal period'
+ USING ERRCODE = 'check_violation';
+ END IF;
+ IF NEW.restvarde_target IS NOT NULL AND (
+ OLD.restvarde_target IS DISTINCT FROM NEW.restvarde_target
+ OR NEW.disposed_at IS NULL
+ ) THEN
+ RAISE EXCEPTION 'Asset restvarde_target is deprecated; tax rest value is calculated per fiscal period'
+ USING ERRCODE = 'check_violation';
+ END IF;
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER enforce_ordinary_asset_depreciation_method
+ BEFORE INSERT OR UPDATE ON public.assets
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_ordinary_asset_depreciation_method();
+
+COMMENT ON COLUMN public.company_settings.tax_depreciation_method IS
+ 'Company-level IL 18 tax depreciation method. NULL until explicitly selected.';
+COMMENT ON COLUMN public.fiscal_periods.tax_depreciation_calculation IS
+ 'Versioned annual IL 18 calculation details supporting the saved tax depreciation election.';
+COMMENT ON COLUMN public.assets.restvarde_target IS
+ 'Deprecated legacy per-asset field. New tax depreciation is pooled per fiscal period.';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260803227100_harden_tax_depreciation_function_search_path.sql b/supabase/migrations/20260803227100_harden_tax_depreciation_function_search_path.sql
new file mode 100644
index 00000000..813182e6
--- /dev/null
+++ b/supabase/migrations/20260803227100_harden_tax_depreciation_function_search_path.sql
@@ -0,0 +1,5 @@
+ALTER FUNCTION public.guard_fiscal_period_tax_depreciation()
+ SET search_path = '';
+
+ALTER FUNCTION public.enforce_ordinary_asset_depreciation_method()
+ SET search_path = '';
diff --git a/supabase/migrations/20260803227200_enforce_tax_depreciation_snapshot_integrity.sql b/supabase/migrations/20260803227200_enforce_tax_depreciation_snapshot_integrity.sql
new file mode 100644
index 00000000..db06731a
--- /dev/null
+++ b/supabase/migrations/20260803227200_enforce_tax_depreciation_snapshot_integrity.sql
@@ -0,0 +1,136 @@
+-- Issue #324 follow-up: make annual tax-depreciation snapshots coherent and
+-- preserve their fiscal-period chain even for direct RLS-authorized writes.
+
+ALTER TABLE public.fiscal_periods
+ ADD CONSTRAINT fiscal_periods_tax_depreciation_arithmetic_check
+ CHECK (
+ tax_depreciation_method IS NULL
+ OR (
+ -- Completeness first: SQL NULL semantics would let a partially
+ -- populated snapshot slip past the arithmetic comparisons below
+ -- (NULL operands make the whole expression NULL, which passes CHECK).
+ tax_depreciation_opening_value IS NOT NULL
+ AND tax_depreciation_base IS NOT NULL
+ AND tax_depreciation_deduction IS NOT NULL
+ AND tax_depreciation_closing_value IS NOT NULL
+ AND tax_depreciation_calculation IS NOT NULL
+ AND (
+ tax_depreciation_method <> 'rakenskapsenlig'
+ OR tax_depreciation_rule IS NOT NULL
+ )
+ AND tax_depreciation_deduction <= tax_depreciation_base
+ AND tax_depreciation_closing_value
+ = tax_depreciation_base - tax_depreciation_deduction
+ AND (tax_depreciation_calculation ->> 'version')::integer >= 2
+ AND (tax_depreciation_calculation ->> 'elected_deduction')::numeric
+ = tax_depreciation_deduction
+ AND (tax_depreciation_calculation ->> 'maximum_deduction')::numeric
+ >= tax_depreciation_deduction
+ AND (
+ tax_depreciation_method <> 'rakenskapsenlig'
+ OR tax_depreciation_calculation ->> 'book_conformity_confirmed' = 'true'
+ )
+ )
+ );
+
+CREATE OR REPLACE FUNCTION public.guard_fiscal_period_tax_depreciation()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = ''
+AS $$
+DECLARE
+ previous_period public.fiscal_periods%ROWTYPE;
+ tax_snapshot_changed boolean;
+BEGIN
+ tax_snapshot_changed := (
+ NEW.tax_depreciation_method IS DISTINCT FROM OLD.tax_depreciation_method
+ OR NEW.tax_depreciation_rule IS DISTINCT FROM OLD.tax_depreciation_rule
+ OR NEW.tax_depreciation_opening_value IS DISTINCT FROM OLD.tax_depreciation_opening_value
+ OR NEW.tax_depreciation_base IS DISTINCT FROM OLD.tax_depreciation_base
+ OR NEW.tax_depreciation_deduction IS DISTINCT FROM OLD.tax_depreciation_deduction
+ OR NEW.tax_depreciation_closing_value IS DISTINCT FROM OLD.tax_depreciation_closing_value
+ OR NEW.tax_depreciation_calculation IS DISTINCT FROM OLD.tax_depreciation_calculation
+ );
+
+ IF NOT tax_snapshot_changed THEN
+ RETURN NEW;
+ END IF;
+
+ IF OLD.is_closed = true OR OLD.locked_at IS NOT NULL OR OLD.closing_entry_id IS NOT NULL THEN
+ RAISE EXCEPTION 'Fiscal period is locked for tax depreciation elections'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF EXISTS (
+ SELECT 1
+ FROM public.fiscal_periods successor
+ WHERE successor.company_id = NEW.company_id
+ AND successor.period_start > NEW.period_start
+ AND successor.tax_depreciation_method IS NOT NULL
+ ) THEN
+ RAISE EXCEPTION 'A later fiscal period already has a tax depreciation snapshot'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF NEW.tax_depreciation_method IS NULL THEN
+ RETURN NEW;
+ END IF;
+
+ IF NEW.previous_period_id IS NOT NULL THEN
+ SELECT *
+ INTO previous_period
+ FROM public.fiscal_periods candidate
+ WHERE candidate.id = NEW.previous_period_id
+ AND candidate.company_id = NEW.company_id;
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'Previous fiscal period is missing or belongs to another company'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF previous_period.period_end + 1 <> NEW.period_start THEN
+ RAISE EXCEPTION 'Previous fiscal period is not date-adjacent'
+ USING ERRCODE = 'check_violation';
+ END IF;
+ ELSE
+ SELECT *
+ INTO previous_period
+ FROM public.fiscal_periods candidate
+ WHERE candidate.company_id = NEW.company_id
+ AND candidate.period_end + 1 = NEW.period_start
+ ORDER BY candidate.period_end DESC
+ LIMIT 1;
+ END IF;
+
+ IF previous_period.id IS NOT NULL THEN
+ IF previous_period.tax_depreciation_method IS NULL
+ OR previous_period.tax_depreciation_closing_value IS NULL THEN
+ RAISE EXCEPTION 'Previous fiscal period has no tax depreciation snapshot'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF NEW.tax_depreciation_method <> previous_period.tax_depreciation_method THEN
+ RAISE EXCEPTION 'Tax depreciation method must match the previous fiscal period'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF NEW.tax_depreciation_opening_value
+ IS DISTINCT FROM previous_period.tax_depreciation_closing_value THEN
+ RAISE EXCEPTION 'Tax depreciation opening value must equal the previous closing value'
+ USING ERRCODE = 'check_violation';
+ END IF;
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+-- The annual snapshot chain is authoritative. Keeping a second company-level
+-- method introduced an admin/member RLS mismatch and a non-atomic second write.
+ALTER TABLE public.company_settings
+ DROP CONSTRAINT IF EXISTS company_settings_tax_depreciation_method_check;
+
+ALTER TABLE public.company_settings
+ DROP COLUMN IF EXISTS tax_depreciation_method;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/tests/pg/tax-depreciation-elections.pg.test.ts b/tests/pg/tax-depreciation-elections.pg.test.ts
new file mode 100644
index 00000000..5f2e1ca3
--- /dev/null
+++ b/tests/pg/tax-depreciation-elections.pg.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, it } from 'vitest'
+import { getPool } from './setup'
+import { insertFiscalPeriod, seedCompany } from './fixtures'
+
+interface SnapshotInput {
+ method: 'rakenskapsenlig' | 'restvarde'
+ rule: 'huvudregel_30' | 'kompletteringsregel_20' | null
+ opening: number
+ basis: number
+ deduction: number
+ closing: number
+}
+
+const SNAPSHOT: SnapshotInput = {
+ method: 'rakenskapsenlig',
+ rule: 'huvudregel_30',
+ opening: 100_000,
+ basis: 130_000,
+ deduction: 39_000,
+ closing: 91_000,
+}
+
+async function saveSnapshot(
+ periodId: string,
+ overrides: Partial = {},
+): Promise {
+ const snapshot = { ...SNAPSHOT, ...overrides }
+ await getPool().query(
+ `UPDATE public.fiscal_periods
+ SET tax_depreciation_method = $2,
+ tax_depreciation_rule = $3,
+ tax_depreciation_opening_value = $4,
+ tax_depreciation_base = $5,
+ tax_depreciation_deduction = $6,
+ tax_depreciation_closing_value = $7,
+ tax_depreciation_calculation = $8::jsonb
+ WHERE id = $1`,
+ [
+ periodId,
+ snapshot.method,
+ snapshot.rule,
+ snapshot.opening,
+ snapshot.basis,
+ snapshot.deduction,
+ snapshot.closing,
+ JSON.stringify({
+ version: 2,
+ elected_deduction: snapshot.deduction,
+ maximum_deduction: Math.max(snapshot.deduction, SNAPSHOT.deduction),
+ book_conformity_confirmed: snapshot.method === 'rakenskapsenlig' ? true : null,
+ }),
+ ],
+ )
+}
+
+describe('tax depreciation election constraints', () => {
+ it('accepts a complete räkenskapsenlig annual snapshot', async () => {
+ const owner = await seedCompany()
+ await expect(saveSnapshot(owner.fiscalPeriodId)).resolves.toBeUndefined()
+ })
+
+ it('rejects an incomplete or incoherent annual snapshot', async () => {
+ const owner = await seedCompany()
+ await expect(
+ getPool().query(
+ `UPDATE public.fiscal_periods
+ SET tax_depreciation_method = 'restvarde',
+ tax_depreciation_rule = 'huvudregel_30',
+ tax_depreciation_opening_value = 100000,
+ tax_depreciation_base = 100000,
+ tax_depreciation_deduction = 25000,
+ tax_depreciation_closing_value = 75000,
+ tax_depreciation_calculation = '{
+ "version": 2,
+ "elected_deduction": 25000,
+ "maximum_deduction": 25000,
+ "book_conformity_confirmed": null
+ }'::jsonb
+ WHERE id = $1`,
+ [owner.fiscalPeriodId],
+ ),
+ ).rejects.toThrow()
+ })
+
+ it('rejects deduction above the base or a closing value that does not reconcile', async () => {
+ const owner = await seedCompany()
+ await expect(saveSnapshot(owner.fiscalPeriodId, {
+ basis: 10_000,
+ deduction: 11_000,
+ closing: 0,
+ })).rejects.toThrow()
+ await expect(saveSnapshot(owner.fiscalPeriodId, {
+ basis: 100_000,
+ deduction: 30_000,
+ closing: 60_000,
+ })).rejects.toThrow()
+ })
+
+ it('enforces predecessor method and opening value for direct writes', async () => {
+ const owner = await seedCompany()
+ const previousPeriodId = await insertFiscalPeriod({
+ userId: owner.userId,
+ companyId: owner.companyId,
+ name: '2025',
+ periodStart: '2025-01-01',
+ periodEnd: '2025-12-31',
+ })
+ await getPool().query(
+ 'UPDATE public.fiscal_periods SET previous_period_id = $2 WHERE id = $1',
+ [owner.fiscalPeriodId, previousPeriodId],
+ )
+ await saveSnapshot(previousPeriodId)
+
+ await expect(saveSnapshot(owner.fiscalPeriodId, {
+ opening: 90_000,
+ basis: 100_000,
+ deduction: 30_000,
+ closing: 70_000,
+ })).rejects.toThrow(/opening value must equal/i)
+ await expect(saveSnapshot(owner.fiscalPeriodId, {
+ method: 'restvarde',
+ rule: null,
+ opening: SNAPSHOT.closing,
+ basis: 100_000,
+ deduction: 25_000,
+ closing: 75_000,
+ })).rejects.toThrow(/method must match/i)
+ })
+
+ it('blocks an earlier snapshot change after a successor snapshot is saved', async () => {
+ const owner = await seedCompany()
+ const previousPeriodId = await insertFiscalPeriod({
+ userId: owner.userId,
+ companyId: owner.companyId,
+ name: '2025',
+ periodStart: '2025-01-01',
+ periodEnd: '2025-12-31',
+ })
+ await getPool().query(
+ 'UPDATE public.fiscal_periods SET previous_period_id = $2 WHERE id = $1',
+ [owner.fiscalPeriodId, previousPeriodId],
+ )
+ await saveSnapshot(previousPeriodId)
+ await saveSnapshot(owner.fiscalPeriodId, {
+ opening: SNAPSHOT.closing,
+ basis: SNAPSHOT.closing,
+ deduction: 20_000,
+ closing: 71_000,
+ })
+
+ await expect(saveSnapshot(previousPeriodId, {
+ deduction: 38_000,
+ closing: 92_000,
+ })).rejects.toThrow(/later fiscal period already has/i)
+ })
+
+ it('blocks snapshot changes after the fiscal period is locked', async () => {
+ const owner = await seedCompany()
+ await saveSnapshot(owner.fiscalPeriodId)
+ await getPool().query(
+ 'UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1',
+ [owner.fiscalPeriodId],
+ )
+
+ await expect(
+ getPool().query(
+ `UPDATE public.fiscal_periods
+ SET tax_depreciation_deduction = 38000,
+ tax_depreciation_closing_value = 92000
+ WHERE id = $1`,
+ [owner.fiscalPeriodId],
+ ),
+ ).rejects.toThrow(/locked for tax depreciation elections/i)
+ })
+
+ it('rejects new per-asset tax depreciation methods', async () => {
+ const owner = await seedCompany()
+ await expect(
+ getPool().query(
+ `INSERT INTO public.assets
+ (user_id, company_id, name, category, acquisition_date,
+ acquisition_cost, salvage_value, useful_life_months,
+ depreciation_method, bas_asset_account,
+ bas_accumulated_account, bas_expense_account)
+ VALUES ($1, $2, 'Legacy tax method', 'equipment', CURRENT_DATE,
+ 100000, 0, 60, 'declining_balance_30', '1220', '1229', '7832')`,
+ [owner.userId, owner.companyId],
+ ),
+ ).rejects.toThrow(/must be linear/i)
+ })
+
+})
diff --git a/types/index.ts b/types/index.ts
index 04a1180e..0bfda2a7 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -1623,6 +1623,13 @@ export interface FiscalPeriod {
closing_entry_id: string | null
opening_balance_entry_id: string | null
previous_period_id: string | null
+ tax_depreciation_method?: 'rakenskapsenlig' | 'restvarde' | null
+ tax_depreciation_rule?: 'huvudregel_30' | 'kompletteringsregel_20' | null
+ tax_depreciation_opening_value?: number | null
+ tax_depreciation_base?: number | null
+ tax_depreciation_deduction?: number | null
+ tax_depreciation_closing_value?: number | null
+ tax_depreciation_calculation?: Record | null
created_at: string
updated_at: string
}
@@ -3334,12 +3341,15 @@ export type AssetCategory =
| 'computer'
| 'other_tangible'
+/** Read type includes historical per-asset tax-method values retained on
+ * disposed rows. New and active assets may only be written as linear. */
export type DepreciationMethod =
| 'linear'
| 'declining_balance_30'
| 'declining_balance_20'
| 'restvardesavskrivning_25'
+export type WritableDepreciationMethod = 'linear'
export type AssetDisposalType = 'sale' | 'scrap' | 'business_transfer'
export type AssetJamkningDirection = 'increase' | 'decrease' | 'none' | 'transferred'
@@ -3382,8 +3392,8 @@ export interface Asset {
bas_asset_account: string
bas_accumulated_account: string
bas_expense_account: string
- /** Book-value floor for restvärdeavskrivning (IL 18 kap 13§ st.3). Required
- * iff depreciation_method = 'restvardesavskrivning_25'; null otherwise. */
+ /** Deprecated legacy field. New tax depreciation is pooled per fiscal
+ * period and ordinary per-asset depreciation is linear. */
restvarde_target: number | null
disposed_at: string | null
disposed_proceeds: number | null