5b0ca3d874
* fix(copy): make K3, leasing and year-end claims match what the code does
Follow-up to the batch that removed the uppskjuten-skatt posting on
obeskattade reserver (K3 29.37 gross in juridisk person) and added the K2
asset-account gate. Six user-facing strings still described the old
behaviour or made claims the code cannot support.
1. Arsredovisning page: the K3 explainer promised an uppskjuten skatt-not
and a materiella anlaggningstillgangar-not in every K3 document. Both
are conditional (a 2240/8940 balance, assets in the register) and the
first is now absent in the normal case. The kassaflodesanalys is
dropped with a warning when it cannot be generated, so it is named
only when the document actually carries one.
2. Regelverk settings: kassaflodesanalys was presented as following from
K3. It follows from being ett storre foretag
(swedish-year-end-closing/references/reporting-and-filing.md:10,
legal-framework.md:42); the copy now says the product includes one and
states the storre-foretag rule separately. Komponentavskrivning was
presented as optional under K3; it is mandatory where component useful
lives differ materially (k2-vs-k3.md:5, asset-accounting
references/depreciation.md:33).
3. Note 1 and the Uppskjutna skatter-not no longer claim the 2240 balance
is hanforlig till obeskattade reserver. deriveLatentTaxMovement reads
the 2240/8940 balances only, and under K3 that account carries deferred
tax on all temporary differences (k2-vs-k3.md:11-13).
4. The deferredTax 'unknown' branch emitted the gross-reserve statement,
which is the denial phrased positively: the same affirmative claim
about books that could not be read. It now emits no deferred-tax
paragraph at all; build-data already warns on that path.
5. Capitalized-lease detection looked at 1260/1269 only. On the shipped
BAS 2026 chart 1260 is a free inventarier account and 1269 is ack.
avskrivningar pa datorer, so owned computers were reported as leased,
while 1217/1227 (finansiellt leasade) were missed. Detection now reads
the company's own account names in kontogrupp 12, which is where BAS
keeps capitalized leases (leasing-and-disposal.md:28) and which owned
inventarier on 1220 never matches. 1720 forutbetalda leasingavgifter
stays out: that is the operational treatment.
6a. gnubok_year_end_readiness listed FX revaluation as a blocker (it is a
warning) and omitted UNBOOKED_TRANSACTIONS, the common one. The
description now names every actionable blocker kind, within the
280-char budget, and a test pins it against YEAR_END_BLOCKER_KIND.
6b. companies.accounting_framework defaults to 'k2', so every enskild
firma hit the K2 asset gate and was handed a BFNAR 2016:10 punkt 10.4
citation plus a K3 remedy it cannot take: a sole trader prepares ett
forenklat arsbokslut, not an arsredovisning (legal-framework.md:29,
:48). entity_type now rides along on the companies read the routes
already do, and non-AB entities get wording with no citation and no
K3, keeping the 1090 remedy. The K1 counterpart of punkt 10.4 is not
sourced in the repo skills, so nothing was invented in its place.
* fix(copy): close the review findings on the copy-truth sweep
Three follow-ups from the source and code reviews. (1) The K2/K3 help text had upgraded a vague sentence into a definite boundary claim ('gransen gar vid <trosklar>'), which excludes the other routes into mandatory K3 that are live right now for this control's audience: noterade vardepapper, and from fiscal years starting after 2025-12-31 also utlandsk filial, kryptotillgangar, aktierelaterade ersattningar and fastighetsbolag. An AB in one of those categories would have read the sentence and stayed on a regelverk it may no longer use. (2) hasCapitalizedLeaseAsset compared per-side cumulative totals, so a lease acquired earlier and disposed this year still claimed the balance sheet carries a leased asset; it now compares the net balance. (3) The K3 warning enumerated a kassaflodesanalys the document may not contain, contradicting the newly conditional page copy on the same screen.
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>
273 lines
10 KiB
TypeScript
273 lines
10 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,
|
|
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 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
|
|
// Framework gates. One companies fetch serves both checks:
|
|
// 1. 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.
|
|
// 2. K2_EXCLUDED_ACCOUNT: accounts flagged k2_excluded ("Ej K2") in the
|
|
// BAS reference may not carry assets under K2. Checked on the RESOLVED
|
|
// accounts, mirroring what createAsset() will persist: an explicit
|
|
// override, or the framework-aware category default (a non-K3 company's
|
|
// immaterial default is the acquired pair 1090/1099, which is lawful,
|
|
// so only a deliberate override can trip this). The guard supplies the
|
|
// message: the egenupparbetade group cites BFNAR 2016:10 punkt 10.4,
|
|
// other Ej K2 accounts do not, and an enskild firma gets neither, since
|
|
// K2 is not its regelverk. entity_type rides along on the same fetch.
|
|
const { data: company } = await supabase
|
|
.from('companies')
|
|
.select('accounting_framework, entity_type')
|
|
.eq('id', companyId)
|
|
.single()
|
|
const isK3Company = company?.accounting_framework === 'k3'
|
|
if (
|
|
validation.data.k3_components !== undefined &&
|
|
validation.data.k3_components !== null &&
|
|
!isK3Company
|
|
) {
|
|
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 (!isK3Company) {
|
|
const defaults = defaultAccountsForCategory(
|
|
validation.data.category,
|
|
company?.accounting_framework,
|
|
)
|
|
const excluded = findK2ExcludedAccount([
|
|
validation.data.bas_asset_account ?? defaults.asset,
|
|
validation.data.bas_accumulated_account ?? defaults.accumulated,
|
|
])
|
|
if (excluded) {
|
|
const messages = k2ExcludedAccountMessages(excluded, company?.entity_type)
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: 'K2_EXCLUDED_ACCOUNT',
|
|
message: messages.message_sv,
|
|
message_en: messages.message_en,
|
|
},
|
|
},
|
|
{ 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 },
|
|
)
|