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:
co-authored by
Claude Fable 5
parent
00ae3540db
commit
5ca64bde30
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user