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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-04 11:43:58 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 00ae3540db
commit 5ca64bde30
25 changed files with 2863 additions and 657 deletions
+7 -35
View File
@@ -6,7 +6,7 @@ import { validateBody } from '@/lib/api/validate'
import { K3ComponentSchema } from '@/lib/api/schemas'
import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
import { validateComponents } from '@/lib/bokslut/assets/k3-components'
import type { AssetCategory, DepreciationMethod } from '@/types'
import type { AssetCategory, WritableDepreciationMethod } from '@/types'
const ASSET_CATEGORIES: readonly AssetCategory[] = [
'immaterial',
@@ -19,11 +19,8 @@ const ASSET_CATEGORIES: readonly AssetCategory[] = [
'other_tangible',
] as const
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
const DEPRECIATION_METHODS: readonly WritableDepreciationMethod[] = [
'linear',
'declining_balance_30',
'declining_balance_20',
'restvardesavskrivning_25',
] as const
const UpdateAssetSchema = z
@@ -42,9 +39,12 @@ const UpdateAssetSchema = z
salvage_value: z.number().nonnegative().optional(),
useful_life_months: z.number().int().positive().optional(),
depreciation_method: z
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
.enum(DEPRECIATION_METHODS as unknown as [
WritableDepreciationMethod,
...WritableDepreciationMethod[],
])
.optional(),
restvarde_target: z.number().nonnegative().nullable().optional(),
restvarde_target: z.null().optional(),
bas_asset_account: z.string().regex(/^\d{4}$/).optional(),
bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(),
bas_expense_account: z.string().regex(/^\d{4}$/).optional(),
@@ -55,34 +55,6 @@ const UpdateAssetSchema = z
// in the PATCH handler below, which can read the existing row.
k3_components: z.array(K3ComponentSchema).nullable().optional(),
})
.superRefine((value, ctx) => {
// Enforce the method/target biconditional when EITHER field is supplied.
// We can't see the existing row from a zod refinement, so the
// application-level updateAsset() carries the cross-row check; here we
// only catch the obviously inconsistent combinations within a single
// PATCH body.
const hasMethod = value.depreciation_method !== undefined
const hasTarget = value.restvarde_target !== undefined
if (!hasMethod && !hasTarget) return
const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25'
const targetIsSet = value.restvarde_target !== null && value.restvarde_target !== undefined
if (hasMethod && isRestvarde && hasTarget && !targetIsSet) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
})
}
if (hasMethod && !isRestvarde && hasTarget && targetIsSet) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).',
})
}
})
export const GET = withRouteContext(
'assets.get',
+58 -1
View File
@@ -29,15 +29,19 @@ vi.mock('@/lib/auth/require-write', () => ({
}))
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
createAsset: vi.fn(),
listAssets: vi.fn(),
getAsset: vi.fn(),
updateAsset: vi.fn(),
}))
import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
import { createAsset, getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
import { GET, PATCH } from '../[id]/route'
import { POST } from '../route'
const mockGetAsset = vi.mocked(getAsset)
const mockUpdateAsset = vi.mocked(updateAsset)
const mockCreateAsset = vi.mocked(createAsset)
const routeParams = { params: Promise.resolve({ id: 'asset-1' }) }
beforeEach(() => {
@@ -71,7 +75,60 @@ describe('GET /api/assets/[id]', () => {
})
})
describe('POST /api/assets', () => {
it('rejects legacy per-asset tax depreciation methods with 400', async () => {
const response = await POST(createMockRequest('/api/assets', {
method: 'POST',
body: {
name: 'Maskin',
category: 'machinery',
acquisition_date: '2025-01-01',
acquisition_cost: 100_000,
useful_life_months: 60,
depreciation_method: 'declining_balance_30',
},
}))
expect(response.status).toBe(400)
})
it('creates a valid asset with ordinary linear depreciation', async () => {
mockCreateAsset.mockResolvedValue({ id: 'asset-new', depreciation_method: 'linear' } as never)
const response = await POST(createMockRequest('/api/assets', {
method: 'POST',
body: {
name: 'Maskin',
category: 'machinery',
acquisition_date: '2025-01-01',
acquisition_cost: 100_000,
useful_life_months: 60,
depreciation_method: 'linear',
},
}))
expect(response.status).toBe(200)
expect(mockCreateAsset).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({ depreciation_method: 'linear' }),
)
})
})
describe('PATCH /api/assets/[id]', () => {
it('rejects legacy per-asset tax depreciation methods with 400', async () => {
const req = createMockRequest('/api/assets/asset-1', {
method: 'PATCH',
body: { depreciation_method: 'declining_balance_30' },
})
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
expect(status).toBe(400)
expect(mockUpdateAsset).not.toHaveBeenCalled()
})
it('rejects an invalid body (non-positive acquisition_cost) with 400', async () => {
const req = createMockRequest('/api/assets/asset-1', {
method: 'PATCH',
+7 -54
View File
@@ -6,7 +6,7 @@ import { validateBody } from '@/lib/api/validate'
import { K3ComponentSchema } from '@/lib/api/schemas'
import { createAsset, listAssets } from '@/lib/bokslut/assets/asset-service'
import { validateComponents } from '@/lib/bokslut/assets/k3-components'
import type { AssetCategory, DepreciationMethod } from '@/types'
import type { AssetCategory, WritableDepreciationMethod } from '@/types'
const ASSET_CATEGORIES: readonly AssetCategory[] = [
'immaterial',
@@ -19,14 +19,8 @@ const ASSET_CATEGORIES: readonly AssetCategory[] = [
'other_tangible',
] as const
// All four depreciation methods are now implemented by the engine. The DB
// CHECK constraint mirrors this list (see
// 20260526120100_restvardeavskrivning.sql).
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
const DEPRECIATION_METHODS: readonly WritableDepreciationMethod[] = [
'linear',
'declining_balance_30',
'declining_balance_20',
'restvardesavskrivning_25',
] as const
const CreateAssetSchema = z
@@ -40,13 +34,12 @@ const CreateAssetSchema = z
salvage_value: z.number().nonnegative().optional(),
useful_life_months: z.number().int().positive(),
depreciation_method: z
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
.enum(DEPRECIATION_METHODS as unknown as [
WritableDepreciationMethod,
...WritableDepreciationMethod[],
])
.optional(),
// Restvärde-target floor for restvärdeavskrivning. Required iff
// depreciation_method = 'restvardesavskrivning_25'. The DB CHECK enforces
// the same biconditional; we mirror it in the API for an early, Swedish
// error message rather than a Postgres check_violation surfacing.
restvarde_target: z.number().nonnegative().nullable().optional(),
restvarde_target: z.null().optional(),
bas_asset_account: z.string().regex(/^\d{4}$/).optional(),
bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(),
bas_expense_account: z.string().regex(/^\d{4}$/).optional(),
@@ -63,7 +56,6 @@ const CreateAssetSchema = z
// outside the legitimate range for the asset category so the chart stays
// BAS-aligned and INK2R mappings continue to work.
validateBasOverrides(value, ctx)
validateRestvardeTarget(value, ctx)
validateK3Components(value, ctx)
})
@@ -88,45 +80,6 @@ function validateK3Components(
}
}
function validateRestvardeTarget(
value: {
depreciation_method?: DepreciationMethod
restvarde_target?: number | null
acquisition_cost?: number
},
ctx: z.RefinementCtx,
): void {
const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25'
const hasTarget = value.restvarde_target !== undefined && value.restvarde_target !== null
if (isRestvarde && !hasTarget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
})
}
if (!isRestvarde && hasTarget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).',
})
}
if (
isRestvarde &&
hasTarget &&
value.acquisition_cost !== undefined &&
(value.restvarde_target as number) >= value.acquisition_cost
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['restvarde_target'],
message:
'restvarde_target måste vara lägre än anskaffningsvärdet: annars finns inget kvar att skriva av.',
})
}
}
function validateBasOverrides(
value: {
category: AssetCategory
@@ -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 }> }) => {