Files
accounted/app/api/assets/[id]/route.ts
T
8f38baca05 fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults (#1422)
* fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults

K2 companies (BFNAR 2016:10 punkt 10.4) may not capitalize internally
developed intangibles, but the asset register defaulted the immaterial
category onto 1010/1019 (Utvecklingsutgifter) for everyone and had no
framework gate beyond K3_REQUIRED_FOR_COMPONENTS.

- New K2_EXCLUDED_ACCOUNT gate (422) in POST /api/assets and PATCH
  /api/assets/[id]: when accounting_framework is not k3, reject any asset
  whose resolved asset or accumulated account is flagged k2_excluded in
  the BAS reference. Resolution mirrors the service defaults so category
  defaults cannot sneak onto 1010/1019; patches that leave category and
  accounts untouched skip the gate so legacy assets stay editable.
- Shared guard helper in lib/bokslut/assets/k2-account-guard.ts; code
  registered in structured-errors.ts with Swedish and English messages.
- CreateAssetDialog: non K3 companies now book immaterial assets on the
  purchased pair 1090/1099 with a quiet hint that egenupparbetad
  utveckling requires K3; K3 companies picking immaterial see a note
  about fond for utvecklingsutgifter (2089) per ARL 4 kap. 2 par.
- Route tests: K2 rejected on 1010 defaults and explicit overrides, K2
  accepted on purchased accounts, K3 accepted on 1010, PATCH equivalents
  and a gate skip regression test.

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

* fix(assets): cite punkt 10.4 only when the intangible group triggered the K2 gate

The K2 gate fires on ANY account the BAS chart flags k2_excluded, but the
rejection hardcoded an egenupparbetade immateriella / BFNAR 2016:10 punkt 10.4
citation. The flag also covers accounts excluded from K2 for unrelated reasons
(1370/2240/8940 uppskjuten skatt, 1518, 2089, 2092, 2096, 2448, 3940, 7940,
8290 to 8480), so those users got a factually wrong legal citation in a
compliance product. PATCH can reach them today: UpdateAssetSchema has no BAS
range refinement, so an explicit bas_asset_account override outside the
category range hits the gate before updateAsset() raises its range error.

- k2ExcludedAccountMessages() now picks the wording from what actually
  triggered the gate. The boundary is derived from the chart itself
  (k2_excluded + account_class 1 + kontogrupp 10), which is exactly the
  egenupparbetade set 1010, 1011, 1012, 1018, 1019, 1081; no magic list, so a
  flag change in bas-data moves the boundary with it. Other Ej K2 accounts get
  a generic message: the chart marks it Ej K2 and it requires K3, with no
  invented paragraph reference.
- Both messages are bilingual (message_sv / message_en, registry shape) and
  the routes now return message_en alongside message.
- The static K2_EXCLUDED_ACCOUNT registry entry drops the intangible citation
  too: it is the code level fallback for every k2_excluded account.
- Tests: route level distinction pinned in id.test.ts (1010/1081 cite 10.4,
  1370 must not), plus a guard unit test asserting the derived group and that
  no non group 10 Ej K2 account ever cites 10.4.

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

* fix(assets): let K2 companies register acquired intangibles, server side

The K2 gate blocked a lawful case. K2 forbids only EGENUPPARBETADE
immateriella tillgangar; acquired ones may be recognized (k2-vs-k3.md:24,
"Only acquired intangibles may be recognized"). But asset-service still
resolved category 'immaterial' to 1010/1019 for everyone, and only
CreateAssetDialog compensated with an explicit 1090/1099 override.
EditAssetDialog sends just the changed fields and has no account inputs, so a
K2 aktiebolag recategorizing a bought licence to "Immateriell tillgang" hit
the defaults, got a 422, and was told to switch the company to K3, which
would pull in komponentavskrivning and uppskjuten skatt and rewrite the whole
arsredovisning. The asset stayed on 1220/1229 and kept being presented as a
tangible asset.

- defaultAccountsForCategory(category, framework) is the single resolution
  point: immaterial resolves to the acquired pair 1090/1099 unless the
  framework is k3, every other category is unchanged. Both createAsset() and
  updateAsset()'s category realign go through resolveDefaultAccounts(), which
  reads companies.accounting_framework only for the intangible category and
  throws rather than guessing when that read fails. Explicit overrides and the
  realign-skip semantics are untouched.
- Both routes resolve gate accounts through the same function, so the check
  mirrors what the service will persist. A K2 company on the defaults now
  passes; a deliberate override onto 1010/1011/1012/1018/1019/1081 still 422s.
- CreateAssetDialog drops its now redundant client override so the two
  surfaces cannot drift; the hint text stays.
- The 422 no longer asserts the company's framework (the companies read
  behind it discards its error, so a transient failure would assert it against
  a K3 company) and no longer proposes a regelverk change. It states that the
  account is reserved for egenupparbetade utvecklingsutgifter, which require
  K3, and points at 1090 for an acquired intangible. Punkt 10.4 stays scoped
  to the kontogrupp 10 group, derived from the chart as before. sv and en.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:04:58 +02:00

214 lines
8.5 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,
defaultAccountsForCategory,
} from '@/lib/bokslut/assets/asset-service'
import { validateComponents } from '@/lib/bokslut/assets/k3-components'
import {
findK2ExcludedAccount,
k2ExcludedAccountMessages,
} from '@/lib/bokslut/assets/k2-account-guard'
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 },
)
}
}
// K2_EXCLUDED_ACCOUNT gate: when the patch touches the category or the
// asset/accumulated accounts, the asset must not END UP on an account the
// BAS reference flags as k2_excluded ("Ej K2") unless the company applies
// K3. The guard supplies the message, citing BFNAR 2016:10 punkt 10.4
// only for the egenupparbetade-immateriella group and staying generic for
// the other Ej K2 accounts (uppskjuten skatt, verkligt värde, ...), which
// this route can reach: UpdateAssetSchema has no BAS range refinement, so
// an explicit override outside the category range lands here first.
// The final accounts mirror updateAsset()'s resolution: a category
// change without explicit accounts realigns the triple to the new
// category's framework-aware defaults, so recategorizing to "Immateriell
// tillgång" lands a K2 company on the acquired pair 1090/1099 and passes.
// Only a deliberate override onto an Ej K2 account trips the gate.
// Patches that leave category and accounts alone skip the gate entirely,
// so legacy assets already sitting on an excluded account stay editable
// (name, notes, useful life, ...).
const touchesAccounts =
validation.data.category !== undefined ||
validation.data.bas_asset_account !== undefined ||
validation.data.bas_accumulated_account !== undefined
if (touchesAccounts) {
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') {
if (!existing) {
return NextResponse.json({ error: { code: 'ASSET_NOT_FOUND' } }, { status: 404 })
}
const finalCategory = validation.data.category ?? existing.category
const categoryDefaultsApply =
validation.data.category !== undefined &&
validation.data.category !== existing.category &&
validation.data.bas_asset_account === undefined &&
validation.data.bas_accumulated_account === undefined &&
validation.data.bas_expense_account === undefined
const defaults = defaultAccountsForCategory(
finalCategory,
company?.accounting_framework,
)
const excluded = findK2ExcludedAccount([
validation.data.bas_asset_account ??
(categoryDefaultsApply ? defaults.asset : existing.bas_asset_account),
validation.data.bas_accumulated_account ??
(categoryDefaultsApply ? defaults.accumulated : existing.bas_accumulated_account),
])
if (excluded) {
const messages = k2ExcludedAccountMessages(excluded)
return NextResponse.json(
{
error: {
code: 'K2_EXCLUDED_ACCOUNT',
message: messages.message_sv,
message_en: messages.message_en,
},
},
{ status: 422 },
)
}
}
}
try {
const asset = await updateAsset(supabase, companyId, id, validation.data)
return NextResponse.json({ data: asset })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)