feat(bokslut): IL 18 kap pooled tax depreciation with method election (#1393)

* feat(bokslut): IL 18 kap pooled tax depreciation with method election

Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25
as a company-level annual pool separate from per-asset book depreciation.
Method election persisted with immutable snapshots and book-conformity
confirmation for rakenskapsenlig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(db): move tax depreciation migrations to coordinated versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bokslut): keep tax depreciation view loadable when a saved election goes stale

A predecessor's changed closing value can push a saved elected deduction
above the new statutory maximum; the view now falls back to the statutory
recomputation so the snapshot is flagged stale instead of crashing.
Ratchet naive-ore-round baseline down by 3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bokslut): resolve tax-depreciation period selects statically

The no-phantom-columns guard counts every select it cannot resolve
toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo
4 over (364 > 360). Inline the literal column list at the four call
sites so the guard verifies these columns instead of skipping them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bokslut): address review findings on tax depreciation election

- DepreciationPanel: gate the saving flag on a dedicated save sequence
  so a successful save (which refreshes the view and bumps the request
  version) no longer leaves the card permanently busy
- computeTaxDepreciation: refuse kompletteringsregel_20 with a positive
  basis and no acquisition cohorts instead of degenerating to a full
  write-off the cohort evidence does not support (IL 18 kap. 17 §)
- migration 227000: judge the asset-method guards on NEW.disposed_at so
  reversing a disposal cannot reactivate a grandfathered non-linear row
- migration 227200: require snapshot column completeness in the CHECK;
  SQL NULL semantics let partially populated snapshots pass the pure
  arithmetic comparisons
- depreciation route: use the string issue code 'custom' like the rest
  of the codebase instead of the Zod 3 compat enum

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-04 11:43:58 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 00ae3540db
commit 5ca64bde30
25 changed files with 2863 additions and 657 deletions
@@ -65,6 +65,7 @@ export default function YearEndPage() {
const [executing, setExecuting] = useState(false)
const [executeError, setExecuteError] = useState<string | null>(null)
const [result, setResult] = useState<YearEndResult | null>(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',
)}
>
<span
@@ -357,6 +359,7 @@ export default function YearEndPage() {
periodId={selectedPeriodId}
onBack={() => setStep('accruals')}
onContinue={goToPreview}
onNavigationBlockedChange={setNavigationBlocked}
/>
)}
+7 -35
View File
@@ -6,7 +6,7 @@ import { validateBody } from '@/lib/api/validate'
import { K3ComponentSchema } from '@/lib/api/schemas'
import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
import { validateComponents } from '@/lib/bokslut/assets/k3-components'
import type { AssetCategory, DepreciationMethod } from '@/types'
import type { AssetCategory, WritableDepreciationMethod } from '@/types'
const ASSET_CATEGORIES: readonly AssetCategory[] = [
'immaterial',
@@ -19,11 +19,8 @@ const ASSET_CATEGORIES: readonly AssetCategory[] = [
'other_tangible',
] as const
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
const DEPRECIATION_METHODS: readonly WritableDepreciationMethod[] = [
'linear',
'declining_balance_30',
'declining_balance_20',
'restvardesavskrivning_25',
] as const
const UpdateAssetSchema = z
@@ -42,9 +39,12 @@ const UpdateAssetSchema = z
salvage_value: z.number().nonnegative().optional(),
useful_life_months: z.number().int().positive().optional(),
depreciation_method: z
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
.enum(DEPRECIATION_METHODS as unknown as [
WritableDepreciationMethod,
...WritableDepreciationMethod[],
])
.optional(),
restvarde_target: z.number().nonnegative().nullable().optional(),
restvarde_target: z.null().optional(),
bas_asset_account: z.string().regex(/^\d{4}$/).optional(),
bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(),
bas_expense_account: z.string().regex(/^\d{4}$/).optional(),
@@ -55,34 +55,6 @@ const UpdateAssetSchema = z
// in the PATCH handler below, which can read the existing row.
k3_components: z.array(K3ComponentSchema).nullable().optional(),
})
.superRefine((value, ctx) => {
// Enforce the method/target biconditional when EITHER field is supplied.
// We can't see the existing row from a zod refinement, so the
// application-level updateAsset() carries the cross-row check; here we
// only catch the obviously inconsistent combinations within a single
// PATCH body.
const hasMethod = value.depreciation_method !== undefined
const hasTarget = value.restvarde_target !== undefined
if (!hasMethod && !hasTarget) return
const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25'
const targetIsSet = value.restvarde_target !== null && value.restvarde_target !== undefined
if (hasMethod && isRestvarde && hasTarget && !targetIsSet) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
})
}
if (hasMethod && !isRestvarde && hasTarget && targetIsSet) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).',
})
}
})
export const GET = withRouteContext(
'assets.get',
+58 -1
View File
@@ -29,15 +29,19 @@ vi.mock('@/lib/auth/require-write', () => ({
}))
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
createAsset: vi.fn(),
listAssets: vi.fn(),
getAsset: vi.fn(),
updateAsset: vi.fn(),
}))
import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
import { createAsset, getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
import { GET, PATCH } from '../[id]/route'
import { POST } from '../route'
const mockGetAsset = vi.mocked(getAsset)
const mockUpdateAsset = vi.mocked(updateAsset)
const mockCreateAsset = vi.mocked(createAsset)
const routeParams = { params: Promise.resolve({ id: 'asset-1' }) }
beforeEach(() => {
@@ -71,7 +75,60 @@ describe('GET /api/assets/[id]', () => {
})
})
describe('POST /api/assets', () => {
it('rejects legacy per-asset tax depreciation methods with 400', async () => {
const response = await POST(createMockRequest('/api/assets', {
method: 'POST',
body: {
name: 'Maskin',
category: 'machinery',
acquisition_date: '2025-01-01',
acquisition_cost: 100_000,
useful_life_months: 60,
depreciation_method: 'declining_balance_30',
},
}))
expect(response.status).toBe(400)
})
it('creates a valid asset with ordinary linear depreciation', async () => {
mockCreateAsset.mockResolvedValue({ id: 'asset-new', depreciation_method: 'linear' } as never)
const response = await POST(createMockRequest('/api/assets', {
method: 'POST',
body: {
name: 'Maskin',
category: 'machinery',
acquisition_date: '2025-01-01',
acquisition_cost: 100_000,
useful_life_months: 60,
depreciation_method: 'linear',
},
}))
expect(response.status).toBe(200)
expect(mockCreateAsset).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({ depreciation_method: 'linear' }),
)
})
})
describe('PATCH /api/assets/[id]', () => {
it('rejects legacy per-asset tax depreciation methods with 400', async () => {
const req = createMockRequest('/api/assets/asset-1', {
method: 'PATCH',
body: { depreciation_method: 'declining_balance_30' },
})
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
expect(status).toBe(400)
expect(mockUpdateAsset).not.toHaveBeenCalled()
})
it('rejects an invalid body (non-positive acquisition_cost) with 400', async () => {
const req = createMockRequest('/api/assets/asset-1', {
method: 'PATCH',
+7 -54
View File
@@ -6,7 +6,7 @@ import { validateBody } from '@/lib/api/validate'
import { K3ComponentSchema } from '@/lib/api/schemas'
import { createAsset, listAssets } from '@/lib/bokslut/assets/asset-service'
import { validateComponents } from '@/lib/bokslut/assets/k3-components'
import type { AssetCategory, DepreciationMethod } from '@/types'
import type { AssetCategory, WritableDepreciationMethod } from '@/types'
const ASSET_CATEGORIES: readonly AssetCategory[] = [
'immaterial',
@@ -19,14 +19,8 @@ const ASSET_CATEGORIES: readonly AssetCategory[] = [
'other_tangible',
] as const
// All four depreciation methods are now implemented by the engine. The DB
// CHECK constraint mirrors this list (see
// 20260526120100_restvardeavskrivning.sql).
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
const DEPRECIATION_METHODS: readonly WritableDepreciationMethod[] = [
'linear',
'declining_balance_30',
'declining_balance_20',
'restvardesavskrivning_25',
] as const
const CreateAssetSchema = z
@@ -40,13 +34,12 @@ const CreateAssetSchema = z
salvage_value: z.number().nonnegative().optional(),
useful_life_months: z.number().int().positive(),
depreciation_method: z
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
.enum(DEPRECIATION_METHODS as unknown as [
WritableDepreciationMethod,
...WritableDepreciationMethod[],
])
.optional(),
// Restvärde-target floor for restvärdeavskrivning. Required iff
// depreciation_method = 'restvardesavskrivning_25'. The DB CHECK enforces
// the same biconditional; we mirror it in the API for an early, Swedish
// error message rather than a Postgres check_violation surfacing.
restvarde_target: z.number().nonnegative().nullable().optional(),
restvarde_target: z.null().optional(),
bas_asset_account: z.string().regex(/^\d{4}$/).optional(),
bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(),
bas_expense_account: z.string().regex(/^\d{4}$/).optional(),
@@ -63,7 +56,6 @@ const CreateAssetSchema = z
// outside the legitimate range for the asset category so the chart stays
// BAS-aligned and INK2R mappings continue to work.
validateBasOverrides(value, ctx)
validateRestvardeTarget(value, ctx)
validateK3Components(value, ctx)
})
@@ -88,45 +80,6 @@ function validateK3Components(
}
}
function validateRestvardeTarget(
value: {
depreciation_method?: DepreciationMethod
restvarde_target?: number | null
acquisition_cost?: number
},
ctx: z.RefinementCtx,
): void {
const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25'
const hasTarget = value.restvarde_target !== undefined && value.restvarde_target !== null
if (isRestvarde && !hasTarget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
})
}
if (!isRestvarde && hasTarget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).',
})
}
if (
isRestvarde &&
hasTarget &&
value.acquisition_cost !== undefined &&
(value.restvarde_target as number) >= value.acquisition_cost
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message:
'restvarde_target måste vara lägre än anskaffningsvärdet: annars finns inget kvar att skriva av.',
})
}
}
function validateBasOverrides(
value: {
category: AssetCategory
@@ -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,
})
})
})
@@ -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 }> }) => {
@@ -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<DepreciationMethod>('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).
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="asset-method">Avskrivningsmetod</Label>
<Select
value={depreciationMethod}
onValueChange={(v) => setDepreciationMethod(v as DepreciationMethod)}
>
<SelectTrigger id="asset-method">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPRECIATION_METHOD_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{methodHint}</p>
</div>
{isRestvarde && (
<div className="space-y-1.5">
<Label htmlFor="asset-restvarde">Restvärde (kr)</Label>
<Input
id="asset-restvarde"
type="number"
min="0"
step="1"
value={restvardeTarget}
onChange={(e) => setRestvardeTarget(e.target.value)}
placeholder="t.ex. 5000"
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">
Avskrivningen stannar när bokfört värde når restvärdet. Restvärdet
måste vara lägre än anskaffningsvärdet.
</p>
</div>
)}
{isK3 && (
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-4">
<div className="flex items-start justify-between gap-4">
@@ -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<DepreciationMethod>(
asset.depreciation_method,
)
const [restvardeTarget, setRestvardeTarget] = useState(
asset.restvarde_target != null ? String(asset.restvarde_target) : '',
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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
<span>
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.
</span>
</div>
)}
@@ -274,58 +215,6 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-asset-method">Avskrivningsmetod</Label>
<Select
value={depreciationMethod}
onValueChange={(v) => setDepreciationMethod(v as DepreciationMethod)}
>
<SelectTrigger id="edit-asset-method">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPRECIATION_METHOD_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{methodHint}</p>
</div>
{basisLocked && depreciationMethod !== asset.depreciation_method && (
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
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.
</span>
</div>
)}
{isRestvarde && (
<div className="space-y-1.5">
<Label htmlFor="edit-asset-restvarde">Restvärde (kr)</Label>
<Input
id="edit-asset-restvarde"
type="number"
min="0"
step="1"
value={restvardeTarget}
onChange={(e) => setRestvardeTarget(e.target.value)}
placeholder="t.ex. 5000"
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">
Avskrivningen stannar när bokfört värde når restvärdet. Restvärdet måste vara lägre
än anskaffningsvärdet.
</p>
</div>
)}
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
@@ -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<Proposal | null>(null)
const [tax, setTax] = useState<TaxView | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 ? (
<TaxDepreciationCard
periodId={periodId}
view={tax}
onSaved={load}
onDirtyChange={handleTaxDirtyChange}
/>
) : 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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Planenliga avskrivningar</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Inga aktiva anläggningstillgångar att skriva av.{' '}
<Link href="/assets" className="text-primary hover:underline">
Lägg till tillgångar
</Link>{' '}
så räknar bokslutet ut avskrivningarna automatiskt.
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Planenliga avskrivningar</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Inga aktiva anläggningstillgångar att skriva av.{' '}
<Link href="/assets" className="text-primary hover:underline">
Lägg till tillgångar
</Link>{' '}
så räknar bokslutet ut avskrivningarna automatiskt.
</CardContent>
</Card>
{taxPanel}
</div>
)
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
@@ -195,7 +277,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
{anyPending && (
<div className="flex justify-end">
<Button onClick={handlePost} disabled={posting}>
<Button onClick={handlePost} disabled={posting || taxDirty}>
{posting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför…
@@ -208,5 +290,441 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
)}
</CardContent>
</Card>
{taxPanel}
</div>
)
}
function TaxDepreciationCard({
periodId,
view,
onSaved,
onDirtyChange,
}: {
periodId: string
view: TaxView
onSaved: () => Promise<void>
onDirtyChange?: (dirty: boolean) => void
}) {
const { toast } = useToast()
const [method, setMethod] = useState<TaxMethod>(view.method ?? 'rakenskapsenlig')
const [rule, setRule] = useState<TaxRule>(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<TaxView | null>(null)
const [dirty, setDirty] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(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 (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">Skattemässig avskrivning</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
Beräkna högsta avdrag för den gemensamma inventariepoolen enligt IL 18 kap.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Du sparar det faktiska avdraget efter avstämning. Eventuell överavskrivning
bokförs separat.
</p>
</div>
{dirty && <Badge variant="warning">Ej sparad</Badge>}
{!dirty && view.snapshot && !view.isStale && (
<span className="text-xs text-muted-foreground">Sparad</span>
)}
{!dirty && view.isStale && <Badge variant="warning">Behöver sparas om</Badge>}
</div>
</CardHeader>
<CardContent className="space-y-4">
{view.openingSource === 'previous_period_required' && (
<p className="text-sm text-destructive" role="alert">
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.
</p>
)}
{view.status === 'needs_period_history' && (
<p className="text-sm text-destructive" role="alert">
Kompletteringsregeln kan inte beräknas utan fullständig räkenskapsårshistorik för
alla kvarvarande inventarier. Välj huvudregeln eller komplettera periodhistoriken.
</p>
)}
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="tax-depreciation-method">Metod</Label>
<Select
value={method}
onValueChange={(value) => {
setMethod(value as TaxMethod)
resetCalculatedDraft()
}}
disabled={view.methodLocked || controlsBusy || formBlocked}
>
<SelectTrigger id="tax-depreciation-method"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="rakenskapsenlig">Räkenskapsenlig</SelectItem>
<SelectItem value="restvarde">Restvärdeavskrivning</SelectItem>
</SelectContent>
</Select>
{view.methodLocked && (
<p className="text-xs text-muted-foreground">
Metoden följer föregående sparade år. Ett byte kräver en separat övergångsbedömning.
</p>
)}
</div>
{method === 'rakenskapsenlig' && (
<div className="space-y-2">
<Label htmlFor="tax-depreciation-rule">Årets regel</Label>
<Select
value={rule}
onValueChange={(value) => {
setRule(value as TaxRule)
resetCalculatedDraft()
}}
disabled={controlsBusy || formBlocked}
>
<SelectTrigger id="tax-depreciation-rule"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="huvudregel_30">Huvudregeln 30 %</SelectItem>
<SelectItem value="kompletteringsregel_20">Kompletteringsregeln 20 %</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="tax-opening-value">Skattemässigt värde vid ingången</Label>
<Input
id="tax-opening-value"
type="number"
min="0"
step="0.01"
value={openingValue}
onChange={(event) => {
setOpeningValue(event.target.value)
resetCalculatedDraft()
}}
disabled={
view.openingSource === 'previous_period'
|| formBlocked
|| controlsBusy
}
className="tabular-nums"
/>
{view.openingSource === 'previous_period' && (
<p className="text-xs text-muted-foreground">Hämtat från föregående sparade år.</p>
)}
</div>
</div>
{dirty && !preview && (
<p className="rounded-md border border-border bg-muted/20 p-3 text-sm text-muted-foreground">
Inställningarna är ändrade. Beräkna förslaget för att se rätt gränsbelopp innan du
sparar.
</p>
)}
{result && (
<>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border border-border bg-muted/20 p-4 text-sm sm:grid-cols-3">
<TaxValue label="Ingående värde" value={result.openingTaxValue} />
<TaxValue label="Årets anskaffningar" value={result.additions} />
<TaxValue label="Årets avyttringar" value={-result.disposals} />
<TaxValue label="Avskrivningsunderlag" value={result.basis} />
<TaxValue label="Högsta avdrag" value={-result.maximumDeduction} />
{!dirty && view.snapshot ? (
<TaxValue label="Faktiskt sparat avdrag" value={-result.deduction} strong />
) : (
<TaxValue label="Lägsta skattemässigt värde" value={result.closingTaxValue} strong />
)}
</div>
{method === 'rakenskapsenlig' && (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Jämförelse
</p>
{result.alternatives.map((alternative) => (
<div key={alternative.rule} className="flex justify-between text-sm">
<span>{alternative.rule === 'huvudregel_30' ? '30-procentsregeln' : '20-procentsregeln'}</span>
<span className="tabular-nums">
{formatCurrency(alternative.closingTaxValue)} kvar
</span>
</div>
))}
</div>
)}
{dirty && (
<div className="space-y-2">
<Label htmlFor="tax-elected-deduction">Faktiskt avdrag efter avstämning</Label>
<Input
id="tax-elected-deduction"
type="number"
min="0"
max={result.maximumDeduction}
step="0.01"
value={electedDeduction}
onChange={(event) => setElectedDeduction(event.target.value)}
disabled={controlsBusy}
className="max-w-xs tabular-nums"
/>
<p className="text-xs text-muted-foreground">
Beloppet får vara lägre än gränsbeloppet men aldrig högre.
</p>
</div>
)}
{dirty && method === 'rakenskapsenlig' && (
<div className="flex items-start gap-2 rounded-md border border-border p-3">
<Checkbox
id="tax-book-conformity"
checked={bookConformityConfirmed}
onCheckedChange={(checked) => setBookConformityConfirmed(checked === true)}
disabled={controlsBusy}
/>
<Label htmlFor="tax-book-conformity" className="cursor-pointer text-sm leading-5">
Jag har stämt av att det faktiska avdraget motsvarar bokslutets totala
avskrivning, inklusive eventuell bokförd överavskrivning.
</Label>
</div>
)}
</>
)}
{view.excludedAssetCount > 0 && (
<p className="text-xs text-muted-foreground">
{view.excludedAssetCount} tillgång{view.excludedAssetCount === 1 ? '' : 'ar'} i andra
kategorier ingår inte i inventariepoolen och måste bedömas separat.
</p>
)}
{result && result.excessDisposals > 0 && (
<p className="text-xs text-destructive">
Avyttringsersättningen överstiger underlaget med {formatCurrency(result.excessDisposals)}.
Överskjutande belopp behöver hanteras i deklarationen.
</p>
)}
{saveError && <p className="text-sm text-destructive" role="alert">{saveError}</p>}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{view.eligibleAssetCount} tillgång{view.eligibleAssetCount === 1 ? '' : 'ar'} i poolen,
{view.periodMonths} månader i räkenskapsåret.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
{dirty && (
<Button
type="button"
variant="ghost"
onClick={restoreSavedValues}
disabled={controlsBusy}
className="w-full sm:w-auto"
>
Återställ
</Button>
)}
<Button
type="button"
variant="outline"
onClick={loadPreview}
disabled={
previewing
|| saving
|| formBlocked
}
className="w-full sm:w-auto"
>
{previewing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Beräkna förslag
</Button>
<Button
type="button"
onClick={save}
disabled={
saving
|| !dirty
|| !result
|| formBlocked
}
className="w-full sm:w-auto"
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara faktiskt avdrag
</Button>
</div>
</div>
</CardContent>
</Card>
)
}
function TaxValue({ label, value, strong = false }: { label: string; value: number; strong?: boolean }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className={`tabular-nums ${strong ? 'font-medium' : ''}`}>{formatCurrency(value)}</p>
</div>
)
}
@@ -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<DispositionsProposal | null>(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<string | null>(null)
const [taxAdjustmentDraft, setTaxAdjustmentDraft] = useState<TaxAdjustmentDraft>(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 (
<div className="space-y-6">
<DepreciationPanel periodId={periodId} onPosted={() => void loadProposals()} />
<DepreciationPanel
periodId={periodId}
onPosted={() => void loadProposals()}
onTaxDirtyChange={setTaxDepreciationDirty}
/>
<EfDeclarationSection
fiscalPeriodId={periodId}
bookedSurplus={proposal.netResultBefore}
fiscalYear={fiscalYear}
/>
{taxDepreciationDirty && (
<p className="text-sm text-warning-foreground" role="status">
Spara eller återställ ändringarna i skattemässig avskrivning innan du lämnar steget.
</p>
)}
<div className="flex justify-between">
<Button variant="outline" size="sm" onClick={onBack}>
<Button
variant="outline"
size="sm"
onClick={onBack}
disabled={taxDepreciationDirty}
>
← Tillbaka
</Button>
<Button variant="outline" size="sm" onClick={onContinue}>
<Button
variant="outline"
size="sm"
onClick={onContinue}
disabled={taxDepreciationDirty}
title={
taxDepreciationDirty
? 'Spara ändringarna i skattemässig avskrivning innan du fortsätter.'
: undefined
}
>
Nästa: Förhandsgranska →
</Button>
</div>
@@ -229,7 +270,11 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
return (
<div className="space-y-6">
<DepreciationPanel periodId={periodId} onPosted={() => void loadProposals()} />
<DepreciationPanel
periodId={periodId}
onPosted={() => void loadProposals()}
onTaxDirtyChange={setTaxDepreciationDirty}
/>
<Card>
<CardHeader>
<CardTitle className="text-base">Bokslutsdispositioner</CardTitle>
@@ -332,11 +377,30 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
</Card>
)}
{taxDepreciationDirty && (
<p className="text-sm text-warning-foreground" role="status">
Spara eller återställ ändringarna i skattemässig avskrivning innan du lämnar steget.
</p>
)}
<div className="flex justify-between">
<Button variant="outline" size="sm" onClick={onBack} disabled={posting}>
<Button
variant="outline"
size="sm"
onClick={onBack}
disabled={posting || taxDepreciationDirty}
>
← Tillbaka
</Button>
<Button onClick={handleCommit} disabled={posting || hasCorrectionRequired}>
<Button
onClick={handleCommit}
disabled={posting || hasCorrectionRequired || taxDepreciationDirty}
title={
taxDepreciationDirty
? 'Spara ändringarna i skattemässig avskrivning innan du fortsätter.'
: undefined
}
>
{posting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför…
@@ -152,180 +152,3 @@ describe('computeAnnualDepreciation', () => {
expect(result.amount).toBeLessThan(1_020)
})
})
// ============================================================
// Declining-balance methods (IL 18 kap 13§ huvudregel + kompletteringsregel)
// ============================================================
//
// Swedish practice: declining methods take the full annual amount regardless
// of acquisition month (K2 10.23: "Full annual amount regardless of partial
// year"). The engine therefore does NOT pro-rate by day-overlap for these
// methods. Disposal during the period still yields the full year because the
// disposal entry zeroes out the remaining book value separately.
describe('computeAnnualDepreciation: declining_balance_30 (räkenskapsenlig huvudregel)', () => {
it('year 1: 100 000 kr × 30 % = 30 000 kr (no prior accumulated)', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(30_000)
expect(result.proRated).toBe(false)
})
it('year 2: book value 70 000 × 30 % = 21 000', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR, 30_000)
expect(result.amount).toBe(21_000)
})
it('year 3: book value 49 000 × 30 % = 14 700', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR, 51_000)
expect(result.amount).toBe(14_700)
})
it('does NOT pro-rate for mid-year acquisition (full annual amount)', () => {
// Acquired July 1: linear would pro-rate to ~50 %. Declining methods
// take the full year amount per K2 10.23 and tax practice.
const asset = makeAsset({
acquisition_cost: 100_000,
acquisition_date: '2025-07-01',
depreciation_method: 'declining_balance_30',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(30_000)
expect(result.proRated).toBe(false)
})
it('returns 0 when book value already at zero (or below)', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
// Prior accumulated ≥ acquisition cost → book value 0.
const result = computeAnnualDepreciation(asset, FULL_YEAR, 100_000)
expect(result.amount).toBe(0)
})
it('returns 0 when asset disposed before period start', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
disposed_at: '2024-12-31',
disposed_proceeds: 50_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(0)
})
})
describe('computeAnnualDepreciation: declining_balance_20 (kompletteringsregel, byggnader)', () => {
it('year 1: 1 000 000 kr building × 20 % = 200 000', () => {
const asset = makeAsset({
category: 'building',
bas_asset_account: '1110',
bas_accumulated_account: '1119',
bas_expense_account: '7821',
acquisition_cost: 1_000_000,
depreciation_method: 'declining_balance_20',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(200_000)
expect(result.proRated).toBe(false)
})
it('year 2: book value 800 000 × 20 % = 160 000', () => {
const asset = makeAsset({
category: 'building',
acquisition_cost: 1_000_000,
depreciation_method: 'declining_balance_20',
})
const result = computeAnnualDepreciation(asset, FULL_YEAR, 200_000)
expect(result.amount).toBe(160_000)
})
})
describe('computeAnnualDepreciation: restvardesavskrivning_25 (IL 18 kap 13§ st.3)', () => {
it('year 1: (100 000 − 20 000 restvärde) × 25 % = 20 000', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 20_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(20_000)
expect(result.proRated).toBe(false)
})
it('year 2: book value 80 000, depreciable (80 000 − 20 000) × 25 % = 15 000', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 20_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR, 20_000)
expect(result.amount).toBe(15_000)
})
it('floors at restvärde: book value already at floor returns 0', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 20_000,
})
// Prior accumulated brings book value to exactly the floor (20 000).
const result = computeAnnualDepreciation(asset, FULL_YEAR, 80_000)
expect(result.amount).toBe(0)
})
it('multi-year convergence: book value approaches restvärde but never goes below', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 20_000,
})
// Simulate 10 years of compounding to verify the floor.
let accumulated = 0
for (let year = 0; year < 10; year++) {
const { amount } = computeAnnualDepreciation(asset, FULL_YEAR, accumulated)
accumulated += amount
}
const finalBookValue = 100_000 - accumulated
expect(finalBookValue).toBeGreaterThanOrEqual(20_000)
// Should be tracking toward the floor: within a kr or two after 10 years.
expect(finalBookValue).toBeLessThan(26_000)
})
it('does NOT pro-rate for mid-year acquisition (full annual amount)', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
acquisition_date: '2025-07-01',
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 20_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(20_000)
expect(result.proRated).toBe(false)
})
it('treats restvarde_target=null as 0 (defensive: DB CHECK should prevent this state)', () => {
const asset = makeAsset({
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: null,
})
// (100 000 − 0) × 25 % = 25 000. The DB CHECK forbids method=restvärde
// with null target, but the engine should still produce a deterministic
// answer rather than crash.
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(25_000)
})
})
@@ -0,0 +1,238 @@
import { describe, expect, it } from 'vitest'
import type { Asset } from '@/types'
import {
buildTaxDepreciationPopulation,
findImmediatePreviousTaxPeriod,
resolveTaxDepreciationOpening,
taxDepreciationSnapshotMatches,
type TaxDepreciationSnapshot,
} from '../assets/tax-depreciation-service'
import { computeTaxDepreciation } from '../assets/tax-depreciation'
function asset(overrides: Partial<Asset>): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'company-1',
name: 'Asset',
category: 'equipment',
acquisition_date: '2024-01-01',
acquisition_cost: 100_000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
restvarde_target: null,
disposed_at: null,
disposed_proceeds: null,
disposed_proceeds_vat: 0,
disposed_vat_treatment: null,
jamkning_amount: 0,
jamkning_remaining_months: null,
jamkning_total_months: null,
jamkning_original_input_vat: null,
k3_components: null,
notes: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
}
}
function period(
id: string,
name: string,
periodStart: string,
periodEnd: string,
) {
return {
id,
name,
period_start: periodStart,
period_end: periodEnd,
previous_period_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
tax_depreciation_method: null,
tax_depreciation_rule: null,
tax_depreciation_opening_value: null,
tax_depreciation_base: null,
tax_depreciation_deduction: null,
tax_depreciation_closing_value: null,
tax_depreciation_calculation: null,
}
}
describe('buildTaxDepreciationPopulation', () => {
it('builds a company pool with net prior-year disposals and fiscal-year cohorts', () => {
const periods = [
period('period-2024', '2024', '2024-01-01', '2024-12-31'),
period('period-2025', '2025', '2025-01-01', '2025-12-31'),
]
const population = buildTaxDepreciationPopulation(
[
asset({ id: 'prior-held' }),
asset({
id: 'current-held',
category: 'computer',
acquisition_date: '2025-11-01',
acquisition_cost: 50_000,
}),
asset({
id: 'prior-sold',
category: 'vehicle',
disposed_at: '2025-06-30',
disposed_proceeds: 125_000,
disposed_proceeds_vat: 25_000,
}),
asset({
id: 'current-sold',
acquisition_date: '2025-02-01',
acquisition_cost: 30_000,
disposed_at: '2025-10-01',
disposed_proceeds: 40_000,
}),
asset({
id: 'building',
category: 'building',
acquisition_cost: 200_000,
}),
],
periods[1],
periods,
1,
)
expect(population.additions).toBe(50_000)
expect(population.disposals).toBe(100_000)
expect(population.periodMonths).toBe(12)
expect(population.eligibleAssetCount).toBe(2)
expect(population.excludedAssetCount).toBe(1)
expect(population.excludedCategories).toEqual(['building'])
expect(population.cohortHistoryComplete).toBe(true)
expect(population.incompleteCohortCount).toBe(0)
expect(population.cohorts).toEqual([
{ label: '2025', acquisitionCost: 50_000, elapsedMonths: 12 },
{ label: '2024', acquisitionCost: 100_000, elapsedMonths: 24 },
])
})
})
describe('findImmediatePreviousTaxPeriod', () => {
it('does not skip an unsaved immediate period to reuse an older saved snapshot', () => {
const saved2024 = {
...period('period-2024', '2024', '2024-01-01', '2024-12-31'),
tax_depreciation_method: 'rakenskapsenlig' as const,
tax_depreciation_rule: 'huvudregel_30' as const,
tax_depreciation_opening_value: 100_000,
tax_depreciation_base: 100_000,
tax_depreciation_deduction: 30_000,
tax_depreciation_closing_value: 70_000,
}
const unsaved2025 = period('period-2025', '2025', '2025-01-01', '2025-12-31')
const current2026 = {
...period('period-2026', '2026', '2026-01-01', '2026-12-31'),
previous_period_id: 'period-2025',
}
expect(findImmediatePreviousTaxPeriod(current2026, [saved2024, unsaved2025, current2026]))
.toBe(unsaved2025)
})
it('uses only a date-adjacent fallback when the explicit chain is missing', () => {
const distant = period('period-2024', '2024', '2024-01-01', '2024-12-31')
const adjacent = period('period-2025', '2025', '2025-01-01', '2025-12-31')
const current = period('period-2026', '2026', '2026-01-01', '2026-12-31')
expect(findImmediatePreviousTaxPeriod(current, [distant, adjacent, current])).toBe(adjacent)
expect(findImmediatePreviousTaxPeriod(current, [distant, current])).toBeNull()
})
it('rejects a non-adjacent explicit predecessor link', () => {
const distant = period('period-2024', '2024', '2024-01-01', '2024-12-31')
const current = {
...period('period-2026', '2026', '2026-01-01', '2026-12-31'),
previous_period_id: distant.id,
}
expect(findImmediatePreviousTaxPeriod(current, [distant, current])).toBeNull()
})
})
describe('complementary-rule cohort history', () => {
it('does not invent elapsed months when an acquisition period is missing', () => {
const current = period('period-2026', '2026', '2026-01-01', '2026-12-31')
const population = buildTaxDepreciationPopulation(
[asset({ acquisition_date: '2024-06-01' })],
current,
[current],
0,
)
expect(population.cohorts).toEqual([])
expect(population.cohortHistoryComplete).toBe(false)
expect(population.incompleteCohortCount).toBe(1)
})
})
describe('tax depreciation opening continuity', () => {
const currentSnapshot: TaxDepreciationSnapshot = {
method: 'rakenskapsenlig',
selectedRule: 'huvudregel_30',
openingTaxValue: 70_000,
basis: 70_000,
deduction: 20_000,
closingTaxValue: 50_000,
calculation: null,
}
it('keeps the immediate previous closing authoritative after the current year is saved', () => {
const previousSnapshot = {
...currentSnapshot,
openingTaxValue: 100_000,
basis: 100_000,
deduction: 30_000,
closingTaxValue: 70_000,
}
expect(resolveTaxDepreciationOpening(currentSnapshot, previousSnapshot, true)).toEqual({
value: 70_000,
source: 'previous_period',
})
})
it('marks a saved successor stale when the previous closing value changes', () => {
const changedPrevious = { ...currentSnapshot, closingTaxValue: 65_000 }
const opening = resolveTaxDepreciationOpening(currentSnapshot, changedPrevious, true)
expect(opening.value).toBe(65_000)
const staleInput = {
method: 'rakenskapsenlig' as const,
selectedRule: 'huvudregel_30' as const,
openingTaxValue: opening.value ?? 0,
additions: 0,
disposals: 0,
periodMonths: 12,
cohorts: [],
}
// The saved 20 000 kr election now exceeds the statutory maximum
// (30% of 65 000 = 19 500), so recomputing with it must reject...
expect(() =>
computeTaxDepreciation({ ...staleInput, electedDeduction: currentSnapshot.deduction }),
).toThrow(/electedDeduction/)
// ...and the statutory recomputation no longer matches the snapshot,
// which is what flags it as stale in the view.
const recomputed = computeTaxDepreciation(staleInput)
expect(taxDepreciationSnapshotMatches(currentSnapshot, recomputed)).toBe(false)
})
it('blocks a saved current year when its immediate predecessor has no snapshot', () => {
expect(resolveTaxDepreciationOpening(currentSnapshot, null, true)).toEqual({
value: null,
source: 'previous_period_required',
})
})
})
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import {
computeTaxDepreciation,
fiscalPeriodMonths,
} from '../assets/tax-depreciation'
describe('computeTaxDepreciation', () => {
it('calculates the pooled 30 percent main rule after additions and disposals', () => {
const result = computeTaxDepreciation({
method: 'rakenskapsenlig',
selectedRule: 'huvudregel_30',
openingTaxValue: 100_000,
additions: 50_000,
disposals: 20_000,
periodMonths: 12,
cohorts: [],
})
expect(result.basis).toBe(130_000)
expect(result.maximumDeduction).toBe(39_000)
expect(result.deduction).toBe(39_000)
expect(result.closingTaxValue).toBe(91_000)
})
it('reduces only by net disposal proceeds and floors a negative basis at zero', () => {
const result = computeTaxDepreciation({
method: 'restvarde',
openingTaxValue: 10_000,
additions: 0,
disposals: 12_500,
periodMonths: 12,
cohorts: [],
})
expect(result.basis).toBe(0)
expect(result.excessDisposals).toBe(2_500)
expect(result.deduction).toBe(0)
})
it('uses 80, 60, 40, 20 and 0 percent closing cohorts for the 20 percent rule', () => {
const result = computeTaxDepreciation({
method: 'rakenskapsenlig',
selectedRule: 'kompletteringsregel_20',
openingTaxValue: 400_000,
additions: 100_000,
disposals: 0,
periodMonths: 12,
cohorts: [12, 24, 36, 48, 60].map((elapsedMonths, index) => ({
label: String(index),
acquisitionCost: 100_000,
elapsedMonths,
})),
})
expect(result.cohorts.map((cohort) => cohort.closingValue)).toEqual([
80_000,
60_000,
40_000,
20_000,
0,
])
expect(result.closingTaxValue).toBe(200_000)
expect(result.deduction).toBe(300_000)
})
it('lets the annual räkenskapsenlig election switch between 30 and 20 alternatives', () => {
const shared = {
method: 'rakenskapsenlig' as const,
openingTaxValue: 70_000,
additions: 100_000,
disposals: 0,
periodMonths: 12,
cohorts: [{ label: '2025', acquisitionCost: 100_000, elapsedMonths: 12 }],
}
const main = computeTaxDepreciation({ ...shared, selectedRule: 'huvudregel_30' })
const complement = computeTaxDepreciation({
...shared,
selectedRule: 'kompletteringsregel_20',
})
expect(main.closingTaxValue).toBe(119_000)
expect(complement.closingTaxValue).toBe(80_000)
expect(main.alternatives).toEqual(complement.alternatives)
})
it('stores an elected deduction below the statutory maximum without changing the maximum', () => {
const result = computeTaxDepreciation({
method: 'rakenskapsenlig',
selectedRule: 'huvudregel_30',
openingTaxValue: 100_000,
additions: 0,
disposals: 0,
periodMonths: 12,
cohorts: [],
electedDeduction: 20_000,
})
expect(result.maximumDeduction).toBe(30_000)
expect(result.deduction).toBe(20_000)
expect(result.closingTaxValue).toBe(80_000)
})
it('rejects an elected deduction above the statutory maximum', () => {
expect(() => computeTaxDepreciation({
method: 'restvarde',
openingTaxValue: 100_000,
additions: 0,
disposals: 0,
periodMonths: 12,
cohorts: [],
electedDeduction: 25_001,
})).toThrow(/must not exceed the statutory maximum/)
})
it('adjusts 30 and 25 percent proportionally for short and long fiscal periods', () => {
const short = computeTaxDepreciation({
method: 'rakenskapsenlig',
selectedRule: 'huvudregel_30',
openingTaxValue: 100_000,
additions: 0,
disposals: 0,
periodMonths: 6,
cohorts: [],
})
const long = computeTaxDepreciation({
method: 'restvarde',
openingTaxValue: 100_000,
additions: 0,
disposals: 0,
periodMonths: 18,
cohorts: [],
})
expect(short.deduction).toBe(15_000)
expect(long.deduction).toBe(37_500)
})
it('adjusts the 20 percent cohort rate for a short fiscal period', () => {
const result = computeTaxDepreciation({
method: 'rakenskapsenlig',
selectedRule: 'kompletteringsregel_20',
openingTaxValue: 0,
additions: 100_000,
disposals: 0,
periodMonths: 6,
cohorts: [{ label: 'short', acquisitionCost: 100_000, elapsedMonths: 6 }],
})
expect(result.closingTaxValue).toBe(90_000)
expect(result.deduction).toBe(10_000)
})
it('rejects a 20 percent rule selection for rest value depreciation', () => {
expect(() => computeTaxDepreciation({
method: 'restvarde',
selectedRule: 'kompletteringsregel_20',
openingTaxValue: 100_000,
additions: 0,
disposals: 0,
periodMonths: 12,
cohorts: [],
})).toThrow(/only valid for rakenskapsenlig/)
})
})
describe('fiscalPeriodMonths', () => {
it('counts calendar months inclusively for normal, short and long years', () => {
expect(fiscalPeriodMonths('2025-01-01', '2025-12-31')).toBe(12)
expect(fiscalPeriodMonths('2025-07-01', '2025-12-31')).toBe(6)
expect(fiscalPeriodMonths('2024-07-01', '2025-12-31')).toBe(18)
})
})
@@ -105,28 +105,6 @@ describe('computeAssetNoteFigures', () => {
expect(f).toEqual({ ibAck: 146_000, aretsAvskrivning: 0, avgaendeAck: 0 })
})
it('applies declining_balance_30 on the posted book value', () => {
const asset = makeAsset({
acquisition_date: '2024-01-01',
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
const f = figuresFor(asset, [posted('asset-1', 'fp2024', 30_000)])
// Book value 70,000 * 30% = 21,000
expect(f).toEqual({ ibAck: 30_000, aretsAvskrivning: 21_000, avgaendeAck: 0 })
})
it('floors restvardesavskrivning_25 at the restvarde target', () => {
const asset = makeAsset({
acquisition_date: '2024-01-01',
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 50_000,
})
const f = figuresFor(asset, [posted('asset-1', 'fp2024', 50_000)])
expect(f).toEqual({ ibAck: 50_000, aretsAvskrivning: 0, avgaendeAck: 0 })
})
it('sums K3 component depreciation via the engine fallback', () => {
const asset = makeAsset({
acquisition_date: '2025-01-01',
@@ -330,8 +330,7 @@ describe('computeAnnualDepreciation: K3 dispatch', () => {
it('routes to component depreciation when k3_components is non-empty', () => {
const asset = makeAsset({
acquisition_cost: 1_000_000,
// method+life on the asset would compute different number: engine should ignore them
depreciation_method: 'declining_balance_30',
// Asset-level life would compute a different number: components win.
useful_life_months: 60,
k3_components: [
{ name: 'Tak', cost: 300_000, useful_life_months: 240 },
+10 -58
View File
@@ -10,6 +10,7 @@ import { assessJamkning, assessJamkningEligibility } from './jamkning'
import type {
Asset,
AssetCategory,
WritableDepreciationMethod,
AssetDisposalType,
DepreciationMethod,
FiscalPeriod,
@@ -53,15 +54,13 @@ export interface CreateAssetInput {
acquisition_cost: number
salvage_value?: number
useful_life_months: number
depreciation_method?: DepreciationMethod
/** Required when depreciation_method = 'restvardesavskrivning_25'. */
restvarde_target?: number | null
depreciation_method?: WritableDepreciationMethod
restvarde_target?: null
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
/** K3 component depreciation (BFNAR 2012:1 ch.17.4). When non-null, the
* engine sums per-component linear depreciation instead of applying
* `depreciation_method` to the asset as a whole. The API layer rejects
* engine sums per-component linear depreciation. The API layer rejects
* writes for K2 companies with K3_REQUIRED_FOR_COMPONENTS. */
k3_components?: K3Component[] | null
notes?: string
@@ -81,12 +80,6 @@ export async function createAsset(
input: CreateAssetInput,
): Promise<Asset> {
const defaults = DEFAULT_ACCOUNTS_BY_CATEGORY[input.category]
const method: DepreciationMethod = input.depreciation_method ?? 'linear'
// The DB CHECK constraint enforces the biconditional between method and
// restvarde_target. Pass null explicitly when not restvärde so a stale
// value never leaks through.
const restvarde =
method === 'restvardesavskrivning_25' ? input.restvarde_target ?? null : null
const row = {
user_id: userId,
company_id: companyId,
@@ -96,8 +89,8 @@ export async function createAsset(
acquisition_cost: input.acquisition_cost,
salvage_value: input.salvage_value ?? 0,
useful_life_months: input.useful_life_months,
depreciation_method: method,
restvarde_target: restvarde,
depreciation_method: 'linear' as const,
restvarde_target: null,
bas_asset_account: input.bas_asset_account ?? defaults.asset,
bas_accumulated_account: input.bas_accumulated_account ?? defaults.accumulated,
bas_expense_account: input.bas_expense_account ?? defaults.expense,
@@ -202,15 +195,13 @@ export interface UpdateAssetInput {
* legitimate *prospective* change and stays allowed after depreciation. */
salvage_value?: number
useful_life_months?: number
depreciation_method?: DepreciationMethod
/** Editable as long as method=restvärdeavskrivning. Set to null when
* switching back to a non-restvärde method (the DB CHECK enforces). */
restvarde_target?: number | null
depreciation_method?: WritableDepreciationMethod
restvarde_target?: null
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
/** K3 component breakdown. Pass null to clear an existing breakdown
* (engine then falls back to depreciation_method). The route handler
* (engine then falls back to ordinary linear depreciation). The route handler
* enforces accounting_framework='k3' + sum validation before delegating. */
k3_components?: K3Component[] | null
}
@@ -305,7 +296,7 @@ export async function updateAsset(
assetId: string,
inputParam: UpdateAssetInput,
): Promise<Asset> {
// Copy so we can adjust restvarde_target without mutating the caller's object.
// Copy so category-driven account defaults do not mutate the caller's object.
let input: UpdateAssetInput = { ...inputParam }
// Almost every meaningful patch needs the current row (range checks, the
@@ -315,7 +306,6 @@ export async function updateAsset(
input.acquisition_date !== undefined ||
input.acquisition_cost !== undefined ||
input.depreciation_method !== undefined ||
input.restvarde_target !== undefined ||
input.bas_asset_account !== undefined ||
input.bas_accumulated_account !== undefined ||
input.bas_expense_account !== undefined
@@ -415,44 +405,6 @@ export async function updateAsset(
}
}
// ── Method / restvärde-target biconditional ───────────────────────
// Required iff restvärdeavskrivning. Resolve final method+target+cost across
// the merged row (existing + patch) so we can null the target when switching
// away, require it when switching in, and re-check the floor when the cost
// itself is being corrected.
if (
input.depreciation_method !== undefined ||
input.restvarde_target !== undefined ||
input.acquisition_cost !== undefined
) {
if (!existing) throw new Error('Asset not found')
const finalMethod = input.depreciation_method ?? existing.depreciation_method
const finalTarget =
input.restvarde_target !== undefined ? input.restvarde_target : existing.restvarde_target
const finalCost =
input.acquisition_cost !== undefined ? input.acquisition_cost : Number(existing.acquisition_cost)
if (finalMethod === 'restvardesavskrivning_25' && (finalTarget === null || finalTarget === undefined)) {
throw new Error(
'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
)
}
if (finalMethod !== 'restvardesavskrivning_25' && finalTarget !== null && finalTarget !== undefined) {
// Auto-null the target when switching away from restvärde so the DB
// CHECK doesn't reject the update.
input = { ...input, restvarde_target: null }
}
if (
finalMethod === 'restvardesavskrivning_25' &&
finalTarget !== null &&
finalTarget !== undefined &&
Number(finalTarget) >= Number(finalCost)
) {
throw new Error(
'restvarde_target måste vara lägre än anskaffningsvärdet: annars finns inget kvar att skriva av.',
)
}
}
const { data, error } = await supabase
.from('assets')
.update(input)
+5 -71
View File
@@ -7,11 +7,6 @@ import type {
CreateJournalEntryLineInput,
} from '@/types'
/** Rate constants for the non-linear Swedish depreciation methods. */
const DECLINING_RATE_30 = 0.3
const DECLINING_RATE_20 = 0.2
const RESTVARDE_RATE_25 = 0.25
export interface AssetDepreciation {
asset: Asset
/** Planenlig avskrivning för denna period, avrundad till hela kronor. */
@@ -40,34 +35,14 @@ export interface DepreciationProposal {
/**
* Compute avskrivning för en enskild tillgång under en given fiscal period.
*
* Method dispatch:
* * 'linear' (planenlig raklinje): pro-rates by day-overlap of the
* period with the asset's active life and the disposal cutoff. Annual
* amount = (acquisition_cost − salvage_value) × 12 / useful_life_months.
* * 'declining_balance_30' (räkenskapsenlig huvudregel, IL 18 kap 13§): * 30% of the current book value. No annual pro-ration: K2 10.23 says
* "full annual amount regardless of partial year" when the asset is
* put into use, mirrored in Swedish tax practice for IL 18 kap. Disposal
* in-period still zeros out: we return 0 if disposed before the period
* and the full 30% if disposed during, because the disposal entry itself
* takes care of the asset's remaining book value.
* * 'declining_balance_20' (kompletteringsregel, IL 18 kap 17§): 20% of
* book value. Same proration semantics as 30%.
* * 'restvardesavskrivning_25' (IL 18 kap 13§ st.3): 25% of
* max(0, currentBookValue − restvarde_target). Floors at the target so
* the asset is never charged below restvärde. Same proration semantics
* as the other declining methods.
*
* For non-linear methods, currentBookValue =
* acquisition_cost − accumulated_depreciation_through_period_start.
*
* Callers that don't know prior accumulated depreciation pass 0 (e.g. tests
* for year-1 declining-balance). The orchestrator (`proposeAnnualPostings`)
* fetches it from posted depreciation_schedules.
* Ordinary depreciation is always linear at asset level and pro-rates by the
* active-life overlap. The pooled 30, 20 and 25 percent tax rules live in
* tax-depreciation.ts and never create ordinary per-asset postings.
*/
export function computeAnnualDepreciation(
asset: Asset,
fiscalPeriod: Pick<FiscalPeriod, 'period_start' | 'period_end'>,
priorAccumulated: number = 0,
_priorAccumulated: number = 0,
): { amount: number; proRated: boolean } {
if (asset.disposed_at && asset.disposed_at < fiscalPeriod.period_start) {
return { amount: 0, proRated: false }
@@ -84,48 +59,7 @@ export function computeAnnualDepreciation(
return { amount: result.amount, proRated: result.proRated }
}
const acquisitionCost = Number(asset.acquisition_cost)
const method = asset.depreciation_method
if (method === 'linear') {
return computeLinearAnnual(asset, fiscalPeriod)
}
// Declining-balance methods (huvudregel 30%, kompletteringsregel 20%,
// restvärde 25%) do NOT pro-rate annually: full-year amount applies
// regardless of acquisition month. Disposal during the period is handled
// by disposeAsset(); we still charge the full annual amount because the
// disposal entry zeroes out the residual.
const currentBookValue = acquisitionCost - priorAccumulated
// Already fully depreciated (linear-style accumulated overshoot) or
// negative: defensive guard.
if (currentBookValue <= 0.005) {
return { amount: 0, proRated: false }
}
let annualAmount = 0
if (method === 'declining_balance_30') {
annualAmount = currentBookValue * DECLINING_RATE_30
} else if (method === 'declining_balance_20') {
annualAmount = currentBookValue * DECLINING_RATE_20
} else if (method === 'restvardesavskrivning_25') {
const target = Number(asset.restvarde_target ?? 0)
const depreciable = currentBookValue - target
if (depreciable <= 0.005) {
// Already at or below restvärde: never deplete past the floor.
return { amount: 0, proRated: false }
}
annualAmount = depreciable * RESTVARDE_RATE_25
}
// Monetary rounding per CLAUDE.md guard-rail #9. Schedules store NUMERIC
// values, but the journal entry rounds to whole kronor downstream: match
// the linear branch which rounds to integer kronor for the entry amount.
return {
amount: Math.round(annualAmount),
proRated: false,
}
return computeLinearAnnual(asset, fiscalPeriod)
}
function computeLinearAnnual(
@@ -0,0 +1,610 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Asset, AssetCategory } from '@/types'
import { roundOre } from '@/lib/money'
import { listAssets } from './asset-service'
import {
TAX_DEPRECIATION_CATEGORIES,
computeTaxDepreciation,
fiscalPeriodMonths,
type TaxDepreciationCohort,
type TaxDepreciationMethod,
type TaxDepreciationResult,
type TaxDepreciationRule,
} from './tax-depreciation'
export interface TaxPeriodRow {
id: string
name: string
period_start: string
period_end: string
previous_period_id: string | null
is_closed: boolean
locked_at: string | null
closing_entry_id: string | null
tax_depreciation_method: TaxDepreciationMethod | null
tax_depreciation_rule: TaxDepreciationRule | null
tax_depreciation_opening_value: number | string | null
tax_depreciation_base: number | string | null
tax_depreciation_deduction: number | string | null
tax_depreciation_closing_value: number | string | null
tax_depreciation_calculation: Record<string, unknown> | null
}
export interface TaxDepreciationSnapshot {
method: TaxDepreciationMethod
selectedRule: TaxDepreciationRule | null
openingTaxValue: number
basis: number
deduction: number
closingTaxValue: number
calculation: Record<string, unknown> | null
}
export interface TaxDepreciationView {
status:
| 'needs_previous_period'
| 'needs_period_history'
| 'needs_method'
| 'needs_opening_value'
| 'needs_rule'
| 'ready'
method: TaxDepreciationMethod | null
selectedRule: TaxDepreciationRule | null
methodLocked: boolean
openingTaxValue: number | null
openingSource: 'saved' | 'previous_period' | 'previous_period_required' | 'manual_required'
periodMonths: number
eligibleAssetCount: number
excludedAssetCount: number
excludedCategories: AssetCategory[]
cohortHistoryComplete: boolean
incompleteCohortCount: number
result: TaxDepreciationResult | null
snapshot: TaxDepreciationSnapshot | null
isStale: boolean
}
export interface SaveTaxDepreciationInput {
method: TaxDepreciationMethod
selectedRule?: TaxDepreciationRule
openingTaxValue?: number
electedDeduction: number
bookConformityConfirmed?: boolean
}
export type PreviewTaxDepreciationInput = Omit<
SaveTaxDepreciationInput,
'electedDeduction' | 'bookConformityConfirmed'
>
export class TaxDepreciationValidationError extends Error {}
export class TaxDepreciationPeriodLockedError extends Error {}
// Literal select string at each call site: the no-phantom-columns guard can only
// verify columns it can resolve statically.
export async function loadTaxDepreciationView(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<TaxDepreciationView> {
const [periodResult, assets] = await Promise.all([
supabase
.from('fiscal_periods')
.select(
'id, name, period_start, period_end, previous_period_id, is_closed, locked_at, closing_entry_id, tax_depreciation_method, tax_depreciation_rule, tax_depreciation_opening_value, tax_depreciation_base, tax_depreciation_deduction, tax_depreciation_closing_value, tax_depreciation_calculation'
)
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
listAssets(supabase, companyId),
])
if (periodResult.error || !periodResult.data) throw new Error('Fiscal period not found')
const current = periodResult.data as TaxPeriodRow
const periodsResult = await supabase
.from('fiscal_periods')
.select(
'id, name, period_start, period_end, previous_period_id, is_closed, locked_at, closing_entry_id, tax_depreciation_method, tax_depreciation_rule, tax_depreciation_opening_value, tax_depreciation_base, tax_depreciation_deduction, tax_depreciation_closing_value, tax_depreciation_calculation'
)
.eq('company_id', companyId)
.lte('period_end', current.period_end)
.order('period_start', { ascending: true })
if (periodsResult.error) {
throw new Error(`Failed to load fiscal period history: ${periodsResult.error.message}`)
}
const periods = (periodsResult.data ?? []) as TaxPeriodRow[]
const currentIndex = periods.findIndex((period) => period.id === current.id)
const previousPeriod = findImmediatePreviousTaxPeriod(current, periods)
const previousSnapshot = previousPeriod ? snapshotFromPeriod(previousPeriod) : null
const openingState = resolveTaxDepreciationOpening(
snapshotFromPeriod(current),
previousSnapshot,
previousPeriod !== null,
)
const missingPreviousSnapshot = openingState.source === 'previous_period_required'
const previousMethod = previousSnapshot?.method ?? null
const snapshot = snapshotFromPeriod(current)
const method = snapshot?.method ?? previousMethod
const selectedRule = snapshot?.selectedRule ?? null
const methodLocked = previousSnapshot !== null
const openingTaxValue = openingState.value
const openingSource = openingState.source
const population = buildTaxDepreciationPopulation(assets, current, periods, currentIndex)
let result: TaxDepreciationResult | null = null
if (
method
&& openingTaxValue !== null
&& (method === 'restvarde' || selectedRule)
&& !(
method === 'rakenskapsenlig'
&& selectedRule === 'kompletteringsregel_20'
&& !population.cohortHistoryComplete
)
) {
const baseInput = {
method,
selectedRule: method === 'rakenskapsenlig' ? selectedRule ?? undefined : undefined,
openingTaxValue,
additions: population.additions,
disposals: population.disposals,
periodMonths: population.periodMonths,
cohorts: population.cohorts,
}
try {
result = computeTaxDepreciation({ ...baseInput, electedDeduction: snapshot?.deduction })
} catch (error) {
// A saved election can exceed the statutory maximum when the
// predecessor's closing value later changed. The view must surface
// that as a stale snapshot, not crash: recompute the statutory result
// and let the snapshot comparison flag the divergence.
if (!(error instanceof Error && /electedDeduction/.test(error.message))) throw error
result = computeTaxDepreciation(baseInput)
}
}
const status = missingPreviousSnapshot
? 'needs_previous_period'
: !method
? 'needs_method'
: openingTaxValue === null
? 'needs_opening_value'
: method === 'rakenskapsenlig'
&& selectedRule === 'kompletteringsregel_20'
&& !population.cohortHistoryComplete
? 'needs_period_history'
: method === 'rakenskapsenlig' && !selectedRule
? 'needs_rule'
: 'ready'
return {
status,
method,
selectedRule,
methodLocked,
openingTaxValue,
openingSource,
periodMonths: population.periodMonths,
eligibleAssetCount: population.eligibleAssetCount,
excludedAssetCount: population.excludedAssetCount,
excludedCategories: population.excludedCategories,
cohortHistoryComplete: population.cohortHistoryComplete,
incompleteCohortCount: population.incompleteCohortCount,
result,
snapshot,
isStale: snapshot !== null && (result === null || !taxDepreciationSnapshotMatches(snapshot, result)),
}
}
export async function saveTaxDepreciationElection(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
input: SaveTaxDepreciationInput,
): Promise<TaxDepreciationView> {
const currentView = await loadTaxDepreciationView(supabase, companyId, fiscalPeriodId)
if (currentView.openingSource === 'previous_period_required') {
throw new TaxDepreciationValidationError(
'Spara skattemässig avskrivning för närmast föregående räkenskapsår först.',
)
}
if (currentView.methodLocked && currentView.method && currentView.method !== input.method) {
throw new TaxDepreciationValidationError(
'Skattemässig avskrivningsmetod kan inte bytas efter ett sparat tidigare år utan en särskild övergångsbedömning.',
)
}
if (input.method === 'rakenskapsenlig' && !input.selectedRule) {
throw new TaxDepreciationValidationError('Välj 30-procentsregeln eller 20-procentsregeln.')
}
if (input.method === 'restvarde' && input.selectedRule) {
throw new TaxDepreciationValidationError(
'Restvärdeavskrivning har ingen kompletteringsregel.',
)
}
if (!Number.isFinite(input.electedDeduction) || input.electedDeduction < 0) {
throw new TaxDepreciationValidationError('Årets faktiska avdrag måste vara 0 kr eller mer.')
}
if (input.method === 'rakenskapsenlig' && input.bookConformityConfirmed !== true) {
throw new TaxDepreciationValidationError(
'Bekräfta att avdraget motsvarar bokslutets totala avskrivning.',
)
}
const openingTaxValue = currentView.openingSource === 'previous_period'
? currentView.openingTaxValue
: input.openingTaxValue ?? currentView.openingTaxValue
if (openingTaxValue === null || openingTaxValue === undefined) {
throw new TaxDepreciationValidationError(
'Ange skattemässigt värde vid årets ingång innan beräkningen sparas.',
)
}
if (currentView.openingSource === 'previous_period'
&& input.openingTaxValue !== undefined
&& roundOre(input.openingTaxValue) !== roundOre(openingTaxValue)) {
throw new TaxDepreciationValidationError(
'Ingående skattemässigt värde hämtas från föregående sparade år och kan inte skrivas över.',
)
}
let calculationView: TaxDepreciationView
try {
calculationView = await calculateWithElection(
supabase,
companyId,
fiscalPeriodId,
input.method,
input.selectedRule,
openingTaxValue,
input.electedDeduction,
)
} catch (error) {
if (error instanceof Error && /electedDeduction/.test(error.message)) {
throw new TaxDepreciationValidationError(
'Årets faktiska avdrag får inte överstiga högsta avdrag enligt den valda regeln.',
)
}
throw error
}
if (!calculationView.result) throw new Error('Tax depreciation calculation was not produced')
const result = calculationView.result
const calculation = {
version: 2,
saved_by: userId,
saved_at: new Date().toISOString(),
period_months: result.periodMonths,
additions: result.additions,
disposals: result.disposals,
excess_disposals: result.excessDisposals,
maximum_deduction: result.maximumDeduction,
elected_deduction: result.deduction,
book_conformity_confirmed:
input.method === 'rakenskapsenlig' ? input.bookConformityConfirmed === true : null,
alternatives: result.alternatives,
cohorts: result.cohorts,
eligible_asset_count: calculationView.eligibleAssetCount,
excluded_asset_count: calculationView.excludedAssetCount,
excluded_categories: calculationView.excludedCategories,
cohort_history_complete: calculationView.cohortHistoryComplete,
incomplete_cohort_count: calculationView.incompleteCohortCount,
}
const { data: savedPeriod, error: saveError } = await supabase
.from('fiscal_periods')
.update({
tax_depreciation_method: input.method,
tax_depreciation_rule: input.method === 'rakenskapsenlig' ? input.selectedRule : null,
tax_depreciation_opening_value: result.openingTaxValue,
tax_depreciation_base: result.basis,
tax_depreciation_deduction: result.deduction,
tax_depreciation_closing_value: result.closingTaxValue,
tax_depreciation_calculation: calculation,
})
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.eq('is_closed', false)
.is('locked_at', null)
.is('closing_entry_id', null)
.select('id')
.maybeSingle()
if (saveError) {
if (/locked for tax depreciation/i.test(saveError.message)) {
throw new TaxDepreciationPeriodLockedError('Fiscal period is locked')
}
if (
/later fiscal period|previous fiscal period|opening value must equal|method must match/i
.test(saveError.message)
) {
throw new TaxDepreciationValidationError(
'Skattemässig avskrivning kan inte ändras eftersom räkenskapsårskedjan skulle brytas.',
)
}
throw new Error(`Failed to save tax depreciation: ${saveError.message}`)
}
if (!savedPeriod) throw new TaxDepreciationPeriodLockedError('Fiscal period is locked')
return loadTaxDepreciationView(supabase, companyId, fiscalPeriodId)
}
export async function previewTaxDepreciationElection(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
input: PreviewTaxDepreciationInput,
): Promise<TaxDepreciationView> {
const currentView = await loadTaxDepreciationView(supabase, companyId, fiscalPeriodId)
if (currentView.openingSource === 'previous_period_required') {
throw new TaxDepreciationValidationError(
'Spara skattemässig avskrivning för närmast föregående räkenskapsår först.',
)
}
if (currentView.methodLocked && currentView.method && currentView.method !== input.method) {
throw new TaxDepreciationValidationError(
'Skattemässig avskrivningsmetod kan inte bytas efter ett sparat tidigare år utan en särskild övergångsbedömning.',
)
}
if (input.method === 'rakenskapsenlig' && !input.selectedRule) {
throw new TaxDepreciationValidationError('Välj 30-procentsregeln eller 20-procentsregeln.')
}
if (input.method === 'restvarde' && input.selectedRule) {
throw new TaxDepreciationValidationError(
'Restvärdeavskrivning har ingen kompletteringsregel.',
)
}
const openingTaxValue = currentView.openingSource === 'previous_period'
? currentView.openingTaxValue
: input.openingTaxValue ?? currentView.openingTaxValue
if (openingTaxValue === null || openingTaxValue === undefined) {
throw new TaxDepreciationValidationError(
'Ange skattemässigt värde vid årets ingång innan beräkningen förhandsgranskas.',
)
}
return calculateWithElection(
supabase,
companyId,
fiscalPeriodId,
input.method,
input.selectedRule,
openingTaxValue,
)
}
async function calculateWithElection(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
method: TaxDepreciationMethod,
selectedRule: TaxDepreciationRule | undefined,
openingTaxValue: number,
electedDeduction?: number,
): Promise<TaxDepreciationView> {
const view = await loadTaxDepreciationView(supabase, companyId, fiscalPeriodId)
const [periodResult, assets, periodsResult] = await Promise.all([
supabase
.from('fiscal_periods')
.select(
'id, name, period_start, period_end, previous_period_id, is_closed, locked_at, closing_entry_id, tax_depreciation_method, tax_depreciation_rule, tax_depreciation_opening_value, tax_depreciation_base, tax_depreciation_deduction, tax_depreciation_closing_value, tax_depreciation_calculation'
)
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
listAssets(supabase, companyId),
supabase
.from('fiscal_periods')
.select(
'id, name, period_start, period_end, previous_period_id, is_closed, locked_at, closing_entry_id, tax_depreciation_method, tax_depreciation_rule, tax_depreciation_opening_value, tax_depreciation_base, tax_depreciation_deduction, tax_depreciation_closing_value, tax_depreciation_calculation'
)
.eq('company_id', companyId)
.order('period_start', { ascending: true }),
])
if (periodResult.error || !periodResult.data) throw new Error('Fiscal period not found')
if (periodsResult.error) throw new Error(`Failed to load fiscal period history: ${periodsResult.error.message}`)
const current = periodResult.data as TaxPeriodRow
const periods = (periodsResult.data ?? []) as TaxPeriodRow[]
const population = buildTaxDepreciationPopulation(
assets,
current,
periods,
periods.findIndex((period) => period.id === current.id),
)
if (
method === 'rakenskapsenlig'
&& selectedRule === 'kompletteringsregel_20'
&& !population.cohortHistoryComplete
) {
throw new TaxDepreciationValidationError(
'Kompletteringsregeln kräver fullständig räkenskapsårshistorik för alla kvarvarande anskaffningskohorter.',
)
}
return {
...view,
status: 'ready',
method,
selectedRule: method === 'rakenskapsenlig' ? selectedRule ?? null : null,
openingTaxValue,
result: computeTaxDepreciation({
method,
selectedRule: method === 'rakenskapsenlig' ? selectedRule : undefined,
openingTaxValue,
additions: population.additions,
disposals: population.disposals,
periodMonths: population.periodMonths,
cohorts: population.cohorts,
electedDeduction,
}),
eligibleAssetCount: population.eligibleAssetCount,
excludedAssetCount: population.excludedAssetCount,
excludedCategories: population.excludedCategories,
cohortHistoryComplete: population.cohortHistoryComplete,
incompleteCohortCount: population.incompleteCohortCount,
}
}
export function findImmediatePreviousTaxPeriod(
current: TaxPeriodRow,
periods: TaxPeriodRow[],
): TaxPeriodRow | null {
if (current.previous_period_id) {
const linked = periods.find((period) => period.id === current.previous_period_id) ?? null
return linked && periodsAreAdjacent(linked, current) ? linked : null
}
const currentStart = new Date(`${current.period_start}T00:00:00Z`)
currentStart.setUTCDate(currentStart.getUTCDate() - 1)
const adjacentEnd = currentStart.toISOString().slice(0, 10)
return periods.find((period) => period.period_end === adjacentEnd) ?? null
}
export function resolveTaxDepreciationOpening(
currentSnapshot: TaxDepreciationSnapshot | null,
previousSnapshot: TaxDepreciationSnapshot | null,
hasPreviousPeriod: boolean,
): { value: number | null; source: TaxDepreciationView['openingSource'] } {
if (previousSnapshot) {
return { value: previousSnapshot.closingTaxValue, source: 'previous_period' }
}
if (hasPreviousPeriod) {
return { value: null, source: 'previous_period_required' }
}
if (currentSnapshot) {
return { value: currentSnapshot.openingTaxValue, source: 'saved' }
}
return { value: null, source: 'manual_required' }
}
export function buildTaxDepreciationPopulation(
assets: Asset[],
current: TaxPeriodRow,
periods: TaxPeriodRow[],
currentIndex: number,
): {
additions: number
disposals: number
cohorts: TaxDepreciationCohort[]
periodMonths: number
eligibleAssetCount: number
excludedAssetCount: number
excludedCategories: AssetCategory[]
cohortHistoryComplete: boolean
incompleteCohortCount: number
} {
const eligibleCategories = new Set<AssetCategory>(TAX_DEPRECIATION_CATEGORIES)
const acquiredByEnd = assets.filter((asset) => asset.acquisition_date <= current.period_end)
const heldAtEnd = acquiredByEnd.filter(
(asset) => !asset.disposed_at || asset.disposed_at > current.period_end,
)
const eligibleHeld = heldAtEnd.filter((asset) => eligibleCategories.has(asset.category))
const excluded = heldAtEnd.filter((asset) => !eligibleCategories.has(asset.category))
const additions = roundOre(
eligibleHeld
.filter((asset) => asset.acquisition_date >= current.period_start)
.reduce((sum, asset) => sum + Number(asset.acquisition_cost), 0),
)
const disposals = roundOre(
acquiredByEnd
.filter(
(asset) =>
eligibleCategories.has(asset.category)
&& asset.acquisition_date < current.period_start
&& asset.disposed_at !== null
&& asset.disposed_at >= current.period_start
&& asset.disposed_at <= current.period_end,
)
.reduce(
(sum, asset) =>
sum
+ Math.max(
0,
Number(asset.disposed_proceeds ?? 0) - Number(asset.disposed_proceeds_vat ?? 0),
),
0,
),
)
const cohortMap = new Map<string, TaxDepreciationCohort>()
let incompleteCohortCount = 0
for (const asset of eligibleHeld) {
const acquisitionIndex = periods.findIndex(
(period) =>
asset.acquisition_date >= period.period_start
&& asset.acquisition_date <= period.period_end,
)
const cohortPeriods = acquisitionIndex >= 0 && currentIndex >= acquisitionIndex
? periods.slice(acquisitionIndex, currentIndex + 1)
: []
const completeHistory = cohortPeriods.length > 0
&& cohortPeriods[cohortPeriods.length - 1]?.id === current.id
&& cohortPeriods.every(
(period, index) => index === 0 || periodsAreAdjacent(cohortPeriods[index - 1], period),
)
if (!completeHistory) {
incompleteCohortCount += 1
continue
}
const elapsedMonths = cohortPeriods.reduce(
(sum, period) => sum + fiscalPeriodMonths(period.period_start, period.period_end),
0,
)
const label = periods[acquisitionIndex].name
const key = `${label}:${elapsedMonths}`
const existing = cohortMap.get(key)
cohortMap.set(key, {
label,
elapsedMonths,
acquisitionCost: roundOre((existing?.acquisitionCost ?? 0) + Number(asset.acquisition_cost)),
})
}
return {
additions,
disposals,
cohorts: [...cohortMap.values()].sort((a, b) => a.elapsedMonths - b.elapsedMonths),
periodMonths: fiscalPeriodMonths(current.period_start, current.period_end),
eligibleAssetCount: eligibleHeld.length,
excludedAssetCount: excluded.length,
excludedCategories: [...new Set(excluded.map((asset) => asset.category))],
cohortHistoryComplete: incompleteCohortCount === 0,
incompleteCohortCount,
}
}
function periodsAreAdjacent(previous: TaxPeriodRow, current: TaxPeriodRow): boolean {
const nextDay = new Date(`${previous.period_end}T00:00:00Z`)
nextDay.setUTCDate(nextDay.getUTCDate() + 1)
return nextDay.toISOString().slice(0, 10) === current.period_start
}
function snapshotFromPeriod(period: TaxPeriodRow): TaxDepreciationSnapshot | null {
if (
!period.tax_depreciation_method
|| period.tax_depreciation_opening_value === null
|| period.tax_depreciation_base === null
|| period.tax_depreciation_deduction === null
|| period.tax_depreciation_closing_value === null
) return null
return {
method: period.tax_depreciation_method,
selectedRule: period.tax_depreciation_rule,
openingTaxValue: Number(period.tax_depreciation_opening_value),
basis: Number(period.tax_depreciation_base),
deduction: Number(period.tax_depreciation_deduction),
closingTaxValue: Number(period.tax_depreciation_closing_value),
calculation: period.tax_depreciation_calculation,
}
}
export function taxDepreciationSnapshotMatches(
snapshot: TaxDepreciationSnapshot,
result: TaxDepreciationResult,
): boolean {
return snapshot.method === result.method
&& snapshot.selectedRule === result.selectedRule
&& roundOre(snapshot.openingTaxValue) === result.openingTaxValue
&& roundOre(snapshot.basis) === result.basis
&& roundOre(snapshot.deduction) === result.deduction
&& roundOre(snapshot.closingTaxValue) === result.closingTaxValue
}
+219
View File
@@ -0,0 +1,219 @@
import type { AssetCategory } from '@/types'
import { roundOre } from '@/lib/money'
export type TaxDepreciationMethod = 'rakenskapsenlig' | 'restvarde'
export type TaxDepreciationRule = 'huvudregel_30' | 'kompletteringsregel_20'
export const TAX_DEPRECIATION_CATEGORIES: readonly AssetCategory[] = [
'machinery',
'equipment',
'vehicle',
'computer',
] as const
export interface TaxDepreciationCohort {
label: string
acquisitionCost: number
/**
* Sum of the fiscal-period lengths from the acquisition period through the
* current period. The 20 percent rule applies a full period's adjusted rate
* regardless of the acquisition date inside that period.
*/
elapsedMonths: number
}
export interface TaxDepreciationInput {
method: TaxDepreciationMethod
selectedRule?: TaxDepreciationRule
openingTaxValue: number
additions: number
disposals: number
periodMonths: number
cohorts: TaxDepreciationCohort[]
/** Actual deduction elected for the period. Omit when calculating the
* statutory maximum before the user has reconciled the election. */
electedDeduction?: number
}
export interface TaxDepreciationAlternative {
rule: TaxDepreciationRule | 'restvarde_25'
rate: number | null
deduction: number
closingTaxValue: number
}
export interface TaxDepreciationResult {
method: TaxDepreciationMethod
selectedRule: TaxDepreciationRule | null
openingTaxValue: number
additions: number
disposals: number
basis: number
periodMonths: number
maximumDeduction: number
deduction: number
closingTaxValue: number
excessDisposals: number
alternatives: TaxDepreciationAlternative[]
cohorts: Array<TaxDepreciationCohort & { remainingRate: number; closingValue: number }>
}
/**
* Calculate the annual tax value for the pooled machinery and inventory
* population under IL 18 kap. This is deliberately separate from ordinary
* per-asset depreciation: the 30, 20 and 25 percent rules are tax valuation
* rules for a pool, not book-depreciation methods on an individual asset.
*/
export function computeTaxDepreciation(input: TaxDepreciationInput): TaxDepreciationResult {
assertNonNegative('openingTaxValue', input.openingTaxValue)
assertNonNegative('additions', input.additions)
assertNonNegative('disposals', input.disposals)
if (!Number.isInteger(input.periodMonths) || input.periodMonths < 1 || input.periodMonths > 18) {
throw new Error('periodMonths must be an integer between 1 and 18')
}
const openingTaxValue = roundOre(input.openingTaxValue)
const additions = roundOre(input.additions)
const disposals = roundOre(input.disposals)
const grossBasis = roundOre(openingTaxValue + additions)
const basis = roundOre(Math.max(0, grossBasis - disposals))
const excessDisposals = roundOre(Math.max(0, disposals - grossBasis))
const cohorts = input.cohorts.map((cohort) => {
assertNonNegative('cohort acquisitionCost', cohort.acquisitionCost)
if (!Number.isInteger(cohort.elapsedMonths) || cohort.elapsedMonths < 1) {
throw new Error('cohort elapsedMonths must be a positive integer')
}
const remainingRate = clampRate(1 - 0.2 * (cohort.elapsedMonths / 12))
return {
...cohort,
acquisitionCost: roundOre(cohort.acquisitionCost),
remainingRate,
closingValue: roundOre(cohort.acquisitionCost * remainingRate),
}
})
if (input.method === 'restvarde') {
if (input.selectedRule !== undefined) {
throw new Error('selectedRule is only valid for rakenskapsenlig depreciation')
}
const rate = 0.25 * (input.periodMonths / 12)
const maximumDeduction = roundOre(Math.min(basis, basis * rate))
const deduction = resolveElectedDeduction(input.electedDeduction, maximumDeduction)
const closingTaxValue = roundOre(basis - deduction)
return {
method: input.method,
selectedRule: null,
openingTaxValue,
additions,
disposals,
basis,
periodMonths: input.periodMonths,
maximumDeduction,
deduction,
closingTaxValue,
excessDisposals,
alternatives: [{
rule: 'restvarde_25',
rate,
deduction: maximumDeduction,
closingTaxValue: roundOre(basis - maximumDeduction),
}],
cohorts,
}
}
if (!input.selectedRule) {
throw new Error('selectedRule is required for rakenskapsenlig depreciation')
}
// IL 18 kap. 17 §: the 20-rule's lowest permitted closing value is derived
// from acquisition-year cohorts. With a positive basis but no cohort at all,
// the reduce degenerates to a full write-off, which is not a computation the
// cohort evidence supports; refuse instead of silently deducting everything.
if (input.selectedRule === 'kompletteringsregel_20' && cohorts.length === 0 && basis > 0) {
throw new Error(
'kompletteringsregel_20 requires at least one acquisition cohort for a non-zero basis',
)
}
const mainRate = 0.3 * (input.periodMonths / 12)
const mainDeduction = roundOre(Math.min(basis, basis * mainRate))
const mainClosing = roundOre(basis - mainDeduction)
const complementaryMinimum = roundOre(
cohorts.reduce((sum, cohort) => sum + cohort.closingValue, 0),
)
const complementaryClosing = roundOre(Math.min(basis, complementaryMinimum))
const complementaryDeduction = roundOre(basis - complementaryClosing)
const alternatives: TaxDepreciationAlternative[] = [
{
rule: 'huvudregel_30',
rate: mainRate,
deduction: mainDeduction,
closingTaxValue: mainClosing,
},
{
rule: 'kompletteringsregel_20',
rate: null,
deduction: complementaryDeduction,
closingTaxValue: complementaryClosing,
},
]
const selected = alternatives.find((alternative) => alternative.rule === input.selectedRule)
if (!selected) throw new Error('Unsupported tax depreciation rule')
const deduction = resolveElectedDeduction(input.electedDeduction, selected.deduction)
return {
method: input.method,
selectedRule: input.selectedRule,
openingTaxValue,
additions,
disposals,
basis,
periodMonths: input.periodMonths,
maximumDeduction: selected.deduction,
deduction,
closingTaxValue: roundOre(basis - deduction),
excessDisposals,
alternatives,
cohorts,
}
}
export function fiscalPeriodMonths(periodStart: string, periodEnd: string): number {
const start = parseIsoDate(periodStart)
const end = parseIsoDate(periodEnd)
if (end < start) throw new Error('Fiscal period end must not precede its start')
return (
(end.getUTCFullYear() - start.getUTCFullYear()) * 12
+ end.getUTCMonth()
- start.getUTCMonth()
+ 1
)
}
function parseIsoDate(value: string): Date {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error(`Invalid ISO date: ${value}`)
const date = new Date(`${value}T00:00:00Z`)
if (Number.isNaN(date.getTime())) throw new Error(`Invalid ISO date: ${value}`)
return date
}
function assertNonNegative(label: string, value: number): void {
if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be non-negative`)
}
function clampRate(value: number): number {
return Math.max(0, Math.min(1, value))
}
function resolveElectedDeduction(value: number | undefined, maximumDeduction: number): number {
if (value === undefined) return maximumDeduction
assertNonNegative('electedDeduction', value)
const deduction = roundOre(value)
if (deduction > maximumDeduction) {
throw new Error('electedDeduction must not exceed the statutory maximum')
}
return deduction
}
+2 -2
View File
@@ -7,10 +7,10 @@
]
},
"naiveOreRound": {
"count": 641
"count": 638
},
"handRolledInvariants": {
"count": 114
"count": 115
},
"ledgerScanningReports": {
"count": 4,
@@ -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';
@@ -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 = '';
@@ -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';
@@ -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<SnapshotInput> = {},
): Promise<void> {
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)
})
})
+12 -2
View File
@@ -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<string, unknown> | 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