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
@@ -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 }> }) => {
|
||||
|
||||
Reference in New Issue
Block a user