Files
accounted/app/api/assets/route.ts
T
Mattsson 5ca64bde30 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>
2026-08-04 11:43:58 +02:00

229 lines
8.8 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 { createAsset, listAssets } 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 CreateAssetSchema = z
.object({
name: z.string().min(1),
category: z.enum(ASSET_CATEGORIES as unknown as [AssetCategory, ...AssetCategory[]]),
acquisition_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
// Positive: a zero-value asset would dodge the depreciation engine and
// create a no-op row that confuses the balance sheet.
acquisition_cost: z.number().positive(),
salvage_value: z.number().nonnegative().optional(),
useful_life_months: z.number().int().positive(),
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 (BFNAR 2012:1 ch.17.4). Only meaningful for
// companies with accounting_framework='k3': the route handler rejects
// K3_REQUIRED_FOR_COMPONENTS for K2 companies. When present, the engine
// dispatches to per-component linear depreciation instead of the
// asset-level depreciation_method.
k3_components: z.array(K3ComponentSchema).nullable().optional(),
notes: z.string().optional(),
})
.superRefine((value, ctx) => {
// Defense-in-depth: when the user overrides BAS accounts, refuse anything
// outside the legitimate range for the asset category so the chart stays
// BAS-aligned and INK2R mappings continue to work.
validateBasOverrides(value, ctx)
validateK3Components(value, ctx)
})
function validateK3Components(
value: {
acquisition_cost: number
k3_components?: { name: string; cost: number; useful_life_months: number; salvage_value?: number }[] | null
},
ctx: z.RefinementCtx,
): void {
if (value.k3_components === undefined || value.k3_components === null) return
const { errors } = validateComponents({
acquisition_cost: value.acquisition_cost,
k3_components: value.k3_components,
})
for (const message of errors) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['k3_components'],
message,
})
}
}
function validateBasOverrides(
value: {
category: AssetCategory
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
},
ctx: z.RefinementCtx,
): void {
const ranges = BAS_RANGES_BY_CATEGORY[value.category]
if (value.bas_asset_account && !inRange(value.bas_asset_account, ranges.asset)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_asset_account'],
message: `Account must be in range ${ranges.asset[0]}-${ranges.asset[1]} for ${value.category}`,
})
}
if (
value.bas_accumulated_account &&
!inRange(value.bas_accumulated_account, ranges.accumulated)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_accumulated_account'],
message: `Account must be in range ${ranges.accumulated[0]}-${ranges.accumulated[1]} for ${value.category}`,
})
}
if (value.bas_expense_account && !inRange(value.bas_expense_account, ranges.expense)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_expense_account'],
message: `Account must be in range ${ranges.expense[0]}-${ranges.expense[1]} for ${value.category}`,
})
}
// Anskaffningskonto and ackumulerade-avskrivningar-konto live in the same
// class range (e.g. 1010-1099 for immaterial, 1100-1199 for buildings), so
// a user could pick the same account for both. That would silently net
// acquisition cost against accumulated depreciation in one bucket and
// break the INK2R 720x mappings. Force them apart.
if (
value.bas_asset_account &&
value.bas_accumulated_account &&
value.bas_asset_account === value.bas_accumulated_account
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_accumulated_account'],
message:
'Anskaffningskonto och ackumulerade-avskrivningar-konto måste vara olika konton.',
})
}
}
const BAS_RANGES_BY_CATEGORY: Record<
AssetCategory,
{ asset: [string, string]; accumulated: [string, string]; expense: [string, string] }
> = {
immaterial: { asset: ['1010', '1099'], accumulated: ['1010', '1099'], expense: ['7810', '7819'] },
building: { asset: ['1100', '1199'], accumulated: ['1100', '1199'], expense: ['7820', '7829'] },
land_improvement:{ asset: ['1150', '1159'], accumulated: ['1150', '1159'], expense: ['7820', '7829'] },
machinery: { asset: ['1210', '1219'], accumulated: ['1210', '1219'], expense: ['7830', '7839'] },
equipment: { asset: ['1220', '1229'], accumulated: ['1220', '1229'], expense: ['7830', '7839'] },
vehicle: { asset: ['1240', '1249'], accumulated: ['1240', '1249'], expense: ['7830', '7839'] },
computer: { asset: ['1250', '1259'], accumulated: ['1250', '1259'], expense: ['7830', '7839'] },
other_tangible: { asset: ['1280', '1299'], accumulated: ['1280', '1299'], expense: ['7830', '7839'] },
}
function inRange(account: string, range: [string, string]): boolean {
return account >= range[0] && account <= range[1]
}
export const GET = withRouteContext('assets.list', async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const activeOnly = url.searchParams.get('active') === 'true'
try {
const data = await listAssets(supabase, companyId, { activeOnly })
// Annotate each asset with whether any depreciation has been posted
// against it. The UI uses this to lock the acquisition-basis fields
// (date/cost/category): once avskrivningar are booked a correction must
// go through storno (the service enforces the same rule server-side).
const postedAssetIds = new Set<string>()
if (data.length > 0) {
const { data: posted, error } = await supabase
.from('depreciation_schedules')
.select('asset_id')
.eq('company_id', companyId)
.in(
'asset_id',
data.map((a) => a.id),
)
.not('journal_entry_id', 'is', null)
if (error) throw new Error(`Failed to load depreciation status: ${error.message}`)
for (const row of (posted ?? []) as { asset_id: string }[]) {
postedAssetIds.add(row.asset_id)
}
}
const annotated = data.map((asset) => ({
...asset,
has_posted_depreciation: postedAssetIds.has(asset.id),
}))
return NextResponse.json({ data: annotated })
} catch (err) {
return errorResponse(err, log, { requestId })
}
})
export const POST = withRouteContext(
'assets.create',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, CreateAssetSchema)
if (!validation.success) return validation.response
// K3_REQUIRED_FOR_COMPONENTS: K3 component depreciation is only
// meaningful when the company applies the K3 framework. Reject the
// write with 422 (Unprocessable Entity) rather than silently dropping
// the field so the user knows their input was discarded.
if (validation.data.k3_components !== undefined && validation.data.k3_components !== null) {
const { data: company } = await supabase
.from('companies')
.select('accounting_framework')
.eq('id', companyId)
.single()
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 },
)
}
}
try {
const asset = await createAsset(supabase, companyId, user.id, validation.data)
return NextResponse.json({ data: asset })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)