* 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>
140 lines
5.3 KiB
TypeScript
140 lines
5.3 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { errorResponse } from '@/lib/errors/get-structured-error'
|
|
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, WritableDepreciationMethod } from '@/types'
|
|
|
|
const ASSET_CATEGORIES: readonly AssetCategory[] = [
|
|
'immaterial',
|
|
'building',
|
|
'land_improvement',
|
|
'machinery',
|
|
'equipment',
|
|
'vehicle',
|
|
'computer',
|
|
'other_tangible',
|
|
] as const
|
|
|
|
const DEPRECIATION_METHODS: readonly WritableDepreciationMethod[] = [
|
|
'linear',
|
|
] as const
|
|
|
|
const UpdateAssetSchema = z
|
|
.object({
|
|
name: z.string().min(1).optional(),
|
|
notes: z.string().nullable().optional(),
|
|
// Acquisition-basis corrections. The service (updateAsset) only permits
|
|
// these while the asset is neither disposed nor depreciated, returning
|
|
// ASSET_CORRECTION_BLOCKED (409) otherwise: they redefine the
|
|
// depreciation basis, so a post-posting change must go through storno.
|
|
category: z
|
|
.enum(ASSET_CATEGORIES as unknown as [AssetCategory, ...AssetCategory[]])
|
|
.optional(),
|
|
acquisition_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
acquisition_cost: z.number().positive().optional(),
|
|
salvage_value: z.number().nonnegative().optional(),
|
|
useful_life_months: z.number().int().positive().optional(),
|
|
depreciation_method: z
|
|
.enum(DEPRECIATION_METHODS as unknown as [
|
|
WritableDepreciationMethod,
|
|
...WritableDepreciationMethod[],
|
|
])
|
|
.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(),
|
|
// K3 component depreciation. Accepting `null` lets the caller clear an
|
|
// existing breakdown (the engine then falls back to depreciation_method).
|
|
// Per-component validation runs whenever the field is set to a non-null
|
|
// value; the cross-sum check needs the asset's acquisition_cost so it runs
|
|
// in the PATCH handler below, which can read the existing row.
|
|
k3_components: z.array(K3ComponentSchema).nullable().optional(),
|
|
})
|
|
|
|
export const GET = withRouteContext(
|
|
'assets.get',
|
|
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
|
const { id } = await params
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
try {
|
|
const asset = await getAsset(supabase, companyId, id)
|
|
if (!asset) {
|
|
return NextResponse.json({ error: { code: 'ASSET_NOT_FOUND' } }, { status: 404 })
|
|
}
|
|
return NextResponse.json({ data: asset })
|
|
} catch (err) {
|
|
return errorResponse(err, log, { requestId })
|
|
}
|
|
},
|
|
)
|
|
|
|
export const PATCH = withRouteContext(
|
|
'assets.update',
|
|
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
|
const { id } = await params
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
const validation = await validateBody(request, UpdateAssetSchema)
|
|
if (!validation.success) return validation.response
|
|
|
|
// K3 component depreciation gating + cross-sum check.
|
|
// The Zod refinement cannot see the existing asset's acquisition_cost,
|
|
// so we do both the framework check and the sum validation here at
|
|
// route level before delegating to updateAsset().
|
|
if (validation.data.k3_components !== undefined && validation.data.k3_components !== null) {
|
|
const [{ data: company }, existing] = await Promise.all([
|
|
supabase
|
|
.from('companies')
|
|
.select('accounting_framework')
|
|
.eq('id', companyId)
|
|
.single(),
|
|
getAsset(supabase, companyId, id),
|
|
])
|
|
if (!company || company.accounting_framework !== 'k3') {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: 'K3_REQUIRED_FOR_COMPONENTS',
|
|
message: 'Komponentuppdelning (k3_components) kräver att företaget tillämpar K3 (BFNAR 2012:1).',
|
|
},
|
|
},
|
|
{ status: 422 },
|
|
)
|
|
}
|
|
if (!existing) {
|
|
return NextResponse.json({ error: { code: 'ASSET_NOT_FOUND' } }, { status: 404 })
|
|
}
|
|
// Validate against the cost that will be in effect after this PATCH —
|
|
// a body that changes acquisition_cost and k3_components together must
|
|
// sum to the NEW cost, not the stored one.
|
|
const { errors } = validateComponents({
|
|
acquisition_cost: validation.data.acquisition_cost ?? Number(existing.acquisition_cost),
|
|
k3_components: validation.data.k3_components,
|
|
})
|
|
if (errors.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: 'INVALID_K3_COMPONENTS',
|
|
message: errors.join(' '),
|
|
},
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
}
|
|
|
|
try {
|
|
const asset = await updateAsset(supabase, companyId, id, validation.data)
|
|
return NextResponse.json({ data: asset })
|
|
} catch (err) {
|
|
return errorResponse(err, log, { requestId })
|
|
}
|
|
},
|
|
{ requireWrite: true },
|
|
)
|