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>
This commit is contained in:
@@ -4,8 +4,16 @@ 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 {
|
||||
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[] = [
|
||||
@@ -128,6 +136,72 @@ export const PATCH = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
// 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 })
|
||||
|
||||
@@ -28,12 +28,19 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
|
||||
createAsset: vi.fn(),
|
||||
listAssets: vi.fn(),
|
||||
getAsset: vi.fn(),
|
||||
updateAsset: vi.fn(),
|
||||
}))
|
||||
// Keep DEFAULT_ACCOUNTS_BY_CATEGORY (and other pure exports) real: the routes
|
||||
// resolve category-default accounts through it for the K2_EXCLUDED_ACCOUNT
|
||||
// framework gate. Only the service functions that hit Supabase are mocked.
|
||||
vi.mock('@/lib/bokslut/assets/asset-service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/bokslut/assets/asset-service')>()
|
||||
return {
|
||||
...actual,
|
||||
createAsset: vi.fn(),
|
||||
listAssets: vi.fn(),
|
||||
getAsset: vi.fn(),
|
||||
updateAsset: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { createAsset, getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
|
||||
import { GET, PATCH } from '../[id]/route'
|
||||
@@ -114,6 +121,182 @@ describe('POST /api/assets', () => {
|
||||
expect.objectContaining({ depreciation_method: 'linear' }),
|
||||
)
|
||||
})
|
||||
|
||||
// K2 forbids only EGENUPPARBETADE immateriella tillgångar; an ACQUIRED one
|
||||
// (a bought licence, a trademark) is lawful, so the category defaults now
|
||||
// resolve to the acquired pair 1090/1099 for a non-K3 company and the gate
|
||||
// must let the create through. See
|
||||
// .claude/skills/swedish-year-end-closing/references/k2-vs-k3.md:24.
|
||||
it('lets a K2 company create an immaterial asset on the category defaults', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
mockCreateAsset.mockResolvedValue({ id: 'asset-licence' } as never)
|
||||
|
||||
const response = await POST(createMockRequest('/api/assets', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Programvarulicens',
|
||||
category: 'immaterial',
|
||||
acquisition_date: '2025-01-01',
|
||||
acquisition_cost: 100_000,
|
||||
useful_life_months: 60,
|
||||
},
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
// No accounts in the body: the service resolves 1090/1099 itself, so the
|
||||
// client never has to know the rule (asset-service.test.ts pins the pair).
|
||||
expect(mockCreateAsset).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ category: 'immaterial' }),
|
||||
)
|
||||
const [, , , input] = mockCreateAsset.mock.calls[0]
|
||||
expect(input.bas_asset_account).toBeUndefined()
|
||||
expect(input.bas_accumulated_account).toBeUndefined()
|
||||
})
|
||||
|
||||
// Only a deliberate override onto an Ej K2 account is still unlawful.
|
||||
it.each([
|
||||
['1010', '1039'],
|
||||
['1011', '1039'],
|
||||
['1012', '1039'],
|
||||
['1018', '1039'],
|
||||
['1019', '1039'],
|
||||
['1081', '1039'],
|
||||
])(
|
||||
'rejects a K2 company explicitly overriding onto %s with 422',
|
||||
async (assetAccount, accumulatedAccount) => {
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; message: string }
|
||||
}>(
|
||||
await POST(createMockRequest('/api/assets', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Utvecklingsprojekt',
|
||||
category: 'immaterial',
|
||||
acquisition_date: '2025-01-01',
|
||||
acquisition_cost: 50_000,
|
||||
useful_life_months: 60,
|
||||
bas_asset_account: assetAccount,
|
||||
bas_accumulated_account: accumulatedAccount,
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('K2_EXCLUDED_ACCOUNT')
|
||||
expect(body.error.message).toContain(assetAccount)
|
||||
// All six are kontogrupp 10, so the punkt 10.4 citation applies.
|
||||
expect(body.error.message).toContain('BFNAR 2016:10 punkt 10.4')
|
||||
expect(mockCreateAsset).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects a K2 company overriding the ACCUMULATED account onto 1019 with 422', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(
|
||||
await POST(createMockRequest('/api/assets', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Patent',
|
||||
category: 'immaterial',
|
||||
acquisition_date: '2025-01-01',
|
||||
acquisition_cost: 50_000,
|
||||
useful_life_months: 60,
|
||||
bas_asset_account: '1030',
|
||||
bas_accumulated_account: '1019',
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('K2_EXCLUDED_ACCOUNT')
|
||||
expect(body.error.message).toContain('1019')
|
||||
expect(mockCreateAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Switching regelverk drags in komponentavskrivning and uppskjuten skatt and
|
||||
// rewrites the whole årsredovisning: it is never the remedy for one account.
|
||||
// The rejection must also not assert which framework the company applies,
|
||||
// since the companies read behind it discards its error.
|
||||
it('does not tell the user to switch accounting framework in the rejection', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
|
||||
const { body } = await parseJsonResponse<{
|
||||
error: { message: string; message_en: string }
|
||||
}>(
|
||||
await POST(createMockRequest('/api/assets', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Utvecklingsprojekt',
|
||||
category: 'immaterial',
|
||||
acquisition_date: '2025-01-01',
|
||||
acquisition_cost: 50_000,
|
||||
useful_life_months: 60,
|
||||
bas_asset_account: '1010',
|
||||
bas_accumulated_account: '1039',
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
expect(body.error.message).not.toContain('Byt regelverk')
|
||||
expect(body.error.message).not.toContain('Inställningar')
|
||||
expect(body.error.message).not.toContain('företaget tillämpar')
|
||||
expect(body.error.message_en).not.toContain('Switch the accounting framework')
|
||||
expect(body.error.message_en).not.toContain('Settings')
|
||||
// It points at the lawful account instead.
|
||||
expect(body.error.message).toContain('1090')
|
||||
expect(body.error.message_en).toContain('1090')
|
||||
})
|
||||
|
||||
it('accepts a K2 company creating an immaterial asset on a purchased pair (1030/1039)', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
mockCreateAsset.mockResolvedValue({ id: 'asset-patent' } as never)
|
||||
|
||||
const response = await POST(createMockRequest('/api/assets', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Patent',
|
||||
category: 'immaterial',
|
||||
acquisition_date: '2025-01-01',
|
||||
acquisition_cost: 80_000,
|
||||
useful_life_months: 60,
|
||||
bas_asset_account: '1030',
|
||||
bas_accumulated_account: '1039',
|
||||
},
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreateAsset).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ bas_asset_account: '1030' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a K3 company creating an immaterial asset on the 1010/1019 defaults', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k3' } })
|
||||
mockCreateAsset.mockResolvedValue({ id: 'asset-dev' } as never)
|
||||
|
||||
const response = await POST(createMockRequest('/api/assets', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Utvecklingsutgifter plattform',
|
||||
category: 'immaterial',
|
||||
acquisition_date: '2025-01-01',
|
||||
acquisition_cost: 100_000,
|
||||
useful_life_months: 60,
|
||||
},
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreateAsset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/assets/[id]', () => {
|
||||
@@ -210,4 +393,200 @@ describe('PATCH /api/assets/[id]', () => {
|
||||
expect(body.error.code).toBe('INVALID_K3_COMPONENTS')
|
||||
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a K2 company patching the asset account onto 1010 with 422', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
mockGetAsset.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
category: 'immaterial',
|
||||
bas_asset_account: '1030',
|
||||
bas_accumulated_account: '1039',
|
||||
bas_expense_account: '7813',
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: { bas_asset_account: '1010' },
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(
|
||||
await PATCH(req, routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('K2_EXCLUDED_ACCOUNT')
|
||||
expect(body.error.message).toContain('1010')
|
||||
expect(body.error.message).toContain('BFNAR 2016:10 punkt 10.4')
|
||||
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a K2 company recategorizing AND overriding onto 1010 with 422', async () => {
|
||||
// Explicit accounts suppress the realign in updateAsset(), so the gate has
|
||||
// to evaluate the override rather than the (now lawful) category defaults.
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
mockGetAsset.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
category: 'equipment',
|
||||
bas_asset_account: '1220',
|
||||
bas_accumulated_account: '1229',
|
||||
bas_expense_account: '7832',
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
category: 'immaterial',
|
||||
bas_asset_account: '1010',
|
||||
bas_accumulated_account: '1019',
|
||||
},
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(
|
||||
await PATCH(req, routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('K2_EXCLUDED_ACCOUNT')
|
||||
expect(body.error.message).toContain('1010')
|
||||
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The Ej K2 flag also covers accounts that have nothing to do with
|
||||
// intangibles (uppskjuten skatt, verkligt värde, säkringsredovisning, ...).
|
||||
// PATCH can reach them: UpdateAssetSchema has no BAS range refinement, so an
|
||||
// override outside the category range hits this gate before updateAsset()
|
||||
// raises its range error. Those rejections must NOT claim punkt 10.4.
|
||||
it('rejects a K2 company patching onto 1370 without citing the intangible rule', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
mockGetAsset.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
category: 'immaterial',
|
||||
bas_asset_account: '1030',
|
||||
bas_accumulated_account: '1039',
|
||||
bas_expense_account: '7813',
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: { bas_asset_account: '1370' },
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; message: string; message_en: string }
|
||||
}>(await PATCH(req, routeParams))
|
||||
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('K2_EXCLUDED_ACCOUNT')
|
||||
expect(body.error.message).toContain('1370')
|
||||
expect(body.error.message).toContain('Ej K2')
|
||||
expect(body.error.message).toContain('K3')
|
||||
expect(body.error.message).not.toContain('10.4')
|
||||
expect(body.error.message).not.toContain('egenupparbetade')
|
||||
expect(body.error.message_en).toContain('Ej K2')
|
||||
expect(body.error.message_en).not.toContain('10.4')
|
||||
expect(body.error.message_en).not.toContain('intangible')
|
||||
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The concrete case this route used to break: a K2 aktiebolag bought a
|
||||
// software licence, first filed it as "Inventarier", and now recategorizes
|
||||
// it to "Immateriell tillgång" from a dialog that sends only the changed
|
||||
// field and has no account inputs. K2 forbids EGENUPPARBETADE intangibles
|
||||
// only, so this is lawful and the defaults must land on the acquired pair.
|
||||
it('lets a K2 company recategorize to immaterial (defaults land on 1090/1099)', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
mockGetAsset.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
category: 'equipment',
|
||||
bas_asset_account: '1220',
|
||||
bas_accumulated_account: '1229',
|
||||
bas_expense_account: '7832',
|
||||
} as never)
|
||||
mockUpdateAsset.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
category: 'immaterial',
|
||||
bas_asset_account: '1090',
|
||||
bas_accumulated_account: '1099',
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: { category: 'immaterial' },
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { bas_asset_account: string; bas_accumulated_account: string }
|
||||
}>(await PATCH(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.bas_asset_account).toBe('1090')
|
||||
expect(body.data.bas_accumulated_account).toBe('1099')
|
||||
// The route forwards the bare category patch: updateAsset realigns the
|
||||
// triple itself (asset-service.test.ts pins the realigned pair).
|
||||
expect(mockUpdateAsset).toHaveBeenCalledWith(supabase, 'company-1', 'asset-1', {
|
||||
category: 'immaterial',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows a K2 company moving a legacy 1010 asset onto a purchased pair', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
mockGetAsset.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
category: 'immaterial',
|
||||
bas_asset_account: '1010',
|
||||
bas_accumulated_account: '1019',
|
||||
bas_expense_account: '7811',
|
||||
} as never)
|
||||
mockUpdateAsset.mockResolvedValue({ id: 'asset-1', bas_asset_account: '1030' } as never)
|
||||
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: { bas_asset_account: '1030', bas_accumulated_account: '1039' },
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockUpdateAsset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows a K3 company patching the asset account onto 1010', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k3' } })
|
||||
mockGetAsset.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
category: 'immaterial',
|
||||
bas_asset_account: '1030',
|
||||
bas_accumulated_account: '1039',
|
||||
bas_expense_account: '7813',
|
||||
} as never)
|
||||
mockUpdateAsset.mockResolvedValue({ id: 'asset-1', bas_asset_account: '1010' } as never)
|
||||
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: { bas_asset_account: '1010', bas_accumulated_account: '1019' },
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockUpdateAsset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the framework gate for patches that touch neither category nor accounts', async () => {
|
||||
// No company row enqueued and getAsset unmocked: if the gate ran anyway
|
||||
// it would resolve a null company (treated as K2) and 404 on the missing
|
||||
// asset. A 200 therefore proves the name-only patch never hit the gate,
|
||||
// which keeps legacy K2 assets already sitting on 1010 editable.
|
||||
mockUpdateAsset.mockResolvedValue({ id: 'asset-1', name: 'Nytt namn' } as never)
|
||||
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: { name: 'Nytt namn' },
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockUpdateAsset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+57
-14
@@ -4,8 +4,16 @@ 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 {
|
||||
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[] = [
|
||||
@@ -195,22 +203,57 @@ export const POST = withRouteContext(
|
||||
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') {
|
||||
// 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.
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('accounting_framework')
|
||||
.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)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'K3_REQUIRED_FOR_COMPONENTS',
|
||||
message: 'Komponentuppdelning (k3_components) kräver att företaget tillämpar K3 (BFNAR 2012:1).',
|
||||
code: 'K2_EXCLUDED_ACCOUNT',
|
||||
message: messages.message_sv,
|
||||
message_en: messages.message_en,
|
||||
},
|
||||
},
|
||||
{ status: 422 },
|
||||
|
||||
@@ -67,6 +67,13 @@ const CATEGORY_OPTIONS: { value: AssetCategory; label: string; defaultYears: num
|
||||
{ value: 'other_tangible', label: 'Övrig materiell tillgång', defaultYears: 5 },
|
||||
]
|
||||
|
||||
// No account override here on purpose. The server resolves the immaterial
|
||||
// default per framework (defaultAccountsForCategory in
|
||||
// lib/bokslut/assets/asset-service.ts): 1090/1099 for K2, 1010/1019 for K3.
|
||||
// Sending an explicit pair from this dialog would duplicate that rule on a
|
||||
// second surface, and the edit dialog (which has no account inputs at all)
|
||||
// could never mirror it. The hint below just tells the user where it lands.
|
||||
|
||||
export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAssetDialogProps) {
|
||||
const { toast } = useToast()
|
||||
// useCompanyOptional so the dialog still works in tests / storyboards
|
||||
@@ -250,6 +257,19 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{category === 'immaterial' && !isK3 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bokförs som förvärvad immateriell tillgång (konto 1090). Egenupparbetad
|
||||
utveckling får inte aktiveras enligt K2 (BFNAR 2016:10 punkt 10.4): det kräver K3.
|
||||
</p>
|
||||
)}
|
||||
{category === 'immaterial' && isK3 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
För aktiebolag medför aktivering av egenupparbetad utveckling (konto 1010) att
|
||||
motsvarande belopp sätts av till fond för utvecklingsutgifter (konto 2089) enligt
|
||||
ÅRL 4 kap. 2 §.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -3,10 +3,12 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
AssetCorrectionBlockedError,
|
||||
DEFAULT_ACCOUNTS_BY_CATEGORY,
|
||||
createAsset,
|
||||
defaultAccountsForCategory,
|
||||
updateAsset,
|
||||
} from '../assets/asset-service'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import type { Asset } from '@/types'
|
||||
import type { AccountingFramework, Asset, AssetCategory } from '@/types'
|
||||
|
||||
describe('DEFAULT_ACCOUNTS_BY_CATEGORY', () => {
|
||||
it('maps every AssetCategory to a BAS-aligned account triple', () => {
|
||||
@@ -48,17 +50,72 @@ describe('DEFAULT_ACCOUNTS_BY_CATEGORY', () => {
|
||||
// triple must resolve in BAS_REFERENCE: otherwise the lazy backfill silently
|
||||
// can't add it and the depreciation posting fails on minimal charts.
|
||||
it('every account in the triple exists in the BAS reference (backfillable)', () => {
|
||||
for (const cat of Object.keys(DEFAULT_ACCOUNTS_BY_CATEGORY) as Array<
|
||||
keyof typeof DEFAULT_ACCOUNTS_BY_CATEGORY
|
||||
>) {
|
||||
const { asset, accumulated, expense } = DEFAULT_ACCOUNTS_BY_CATEGORY[cat]
|
||||
for (const account of [asset, accumulated, expense]) {
|
||||
expect(getBASReference(account), `${cat}: ${account} missing from BAS reference`).toBeDefined()
|
||||
for (const framework of ['k2', 'k3'] as const) {
|
||||
for (const cat of Object.keys(DEFAULT_ACCOUNTS_BY_CATEGORY) as AssetCategory[]) {
|
||||
const { asset, accumulated, expense } = defaultAccountsForCategory(cat, framework)
|
||||
for (const account of [asset, accumulated, expense]) {
|
||||
expect(
|
||||
getBASReference(account),
|
||||
`${framework}/${cat}: ${account} missing from BAS reference`,
|
||||
).toBeDefined()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* K2 (BFNAR 2016:10 punkt 10.4) forbids capitalizing EGENUPPARBETADE
|
||||
* immateriella tillgångar, which is what 1010/1019 carry. An ACQUIRED
|
||||
* intangible is lawful under K2 and belongs on 1090/1099
|
||||
* (.claude/skills/swedish-year-end-closing/references/k2-vs-k3.md:24, "Only
|
||||
* acquired intangibles may be recognized"), so the default has to follow the
|
||||
* company's framework rather than being one pair for everyone.
|
||||
*/
|
||||
describe('defaultAccountsForCategory', () => {
|
||||
it('gives a K3 company the egenupparbetade pair for immaterial', () => {
|
||||
expect(defaultAccountsForCategory('immaterial', 'k3')).toEqual({
|
||||
asset: '1010',
|
||||
accumulated: '1019',
|
||||
expense: '7810',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([['k2' as const], [null], [undefined]])(
|
||||
'gives the acquired pair 1090/1099 for immaterial when the framework is %s',
|
||||
(framework: AccountingFramework | null | undefined) => {
|
||||
expect(defaultAccountsForCategory('immaterial', framework)).toEqual({
|
||||
asset: '1090',
|
||||
accumulated: '1099',
|
||||
expense: '7810',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('leaves 1090/1099 unflagged in the BAS chart, so the K2 gate passes them', () => {
|
||||
expect(getBASReference('1090')?.k2_excluded).toBe(false)
|
||||
expect(getBASReference('1099')?.k2_excluded).toBe(false)
|
||||
// Inside the 1010-1099 window the immaterial Zod/service range checks allow.
|
||||
expect('1090' >= '1010' && '1099' <= '1099').toBe(true)
|
||||
})
|
||||
|
||||
it('is framework-independent for every tangible category', () => {
|
||||
const tangible: AssetCategory[] = [
|
||||
'building',
|
||||
'land_improvement',
|
||||
'machinery',
|
||||
'equipment',
|
||||
'vehicle',
|
||||
'computer',
|
||||
'other_tangible',
|
||||
]
|
||||
for (const cat of tangible) {
|
||||
expect(defaultAccountsForCategory(cat, 'k2')).toEqual(DEFAULT_ACCOUNTS_BY_CATEGORY[cat])
|
||||
expect(defaultAccountsForCategory(cat, 'k3')).toEqual(DEFAULT_ACCOUNTS_BY_CATEGORY[cat])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateAsset: acquisition-basis correction guard', () => {
|
||||
function makeAssetRow(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
@@ -111,12 +168,25 @@ describe('updateAsset: acquisition-basis correction guard', () => {
|
||||
postedCount?: number
|
||||
otherAssetEntryIds?: string[]
|
||||
accumulatedCredits?: { journal_entry_id: string }[]
|
||||
accountingFramework?: AccountingFramework | null
|
||||
} = {},
|
||||
) {
|
||||
const captured: { update: Record<string, unknown> | null } = { update: null }
|
||||
let schedCall = 0
|
||||
const supabase = {
|
||||
from: vi.fn((table: string) => {
|
||||
if (table === 'companies') {
|
||||
// Only read when a category change needs a framework-dependent
|
||||
// default (the immaterial category).
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.select = vi.fn(() => chain)
|
||||
chain.eq = vi.fn(() => chain)
|
||||
chain.single = vi.fn(async () => ({
|
||||
data: { accounting_framework: opts.accountingFramework ?? null },
|
||||
error: null,
|
||||
}))
|
||||
return chain
|
||||
}
|
||||
if (table === 'depreciation_schedules') {
|
||||
schedCall += 1
|
||||
const isCountQuery = schedCall === 1
|
||||
@@ -232,6 +302,73 @@ describe('updateAsset: acquisition-basis correction guard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A K2 aktiebolag that bought a software licence and first filed it under
|
||||
// "Inventarier" must be able to recategorize it to "Immateriell tillgång":
|
||||
// K2 forbids only EGENUPPARBETADE intangibles, and an acquired one is
|
||||
// lawful on 1090/1099. Landing it on 1010/1019 would be the Ej K2 pair and
|
||||
// the API gate would reject the whole edit.
|
||||
it('realigns a category correction to immaterial onto 1090/1099 for a K2 company', async () => {
|
||||
const { supabase, captured } = mockForUpdate(
|
||||
makeAssetRow({
|
||||
category: 'equipment',
|
||||
bas_asset_account: '1220',
|
||||
bas_accumulated_account: '1229',
|
||||
bas_expense_account: '7832',
|
||||
}),
|
||||
{ postedCount: 0, accountingFramework: 'k2' },
|
||||
)
|
||||
await updateAsset(asSupabase(supabase), 'co', 'asset-1', { category: 'immaterial' })
|
||||
expect(captured.update).toMatchObject({
|
||||
category: 'immaterial',
|
||||
bas_asset_account: '1090',
|
||||
bas_accumulated_account: '1099',
|
||||
bas_expense_account: '7810',
|
||||
})
|
||||
})
|
||||
|
||||
it('realigns a category correction to immaterial onto 1010/1019 for a K3 company', async () => {
|
||||
const { supabase, captured } = mockForUpdate(
|
||||
makeAssetRow({
|
||||
category: 'equipment',
|
||||
bas_asset_account: '1220',
|
||||
bas_accumulated_account: '1229',
|
||||
bas_expense_account: '7832',
|
||||
}),
|
||||
{ postedCount: 0, accountingFramework: 'k3' },
|
||||
)
|
||||
await updateAsset(asSupabase(supabase), 'co', 'asset-1', { category: 'immaterial' })
|
||||
expect(captured.update).toMatchObject({
|
||||
category: 'immaterial',
|
||||
bas_asset_account: '1010',
|
||||
bas_accumulated_account: '1019',
|
||||
bas_expense_account: '7810',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an explicit account override on a recategorization to immaterial', async () => {
|
||||
// Explicit accounts suppress the realign entirely (unchanged semantics):
|
||||
// a K3 company deliberately picking 1010/1019 still gets them.
|
||||
const { supabase, captured } = mockForUpdate(
|
||||
makeAssetRow({
|
||||
category: 'equipment',
|
||||
bas_asset_account: '1220',
|
||||
bas_accumulated_account: '1229',
|
||||
bas_expense_account: '7832',
|
||||
}),
|
||||
{ postedCount: 0, accountingFramework: 'k3' },
|
||||
)
|
||||
await updateAsset(asSupabase(supabase), 'co', 'asset-1', {
|
||||
category: 'immaterial',
|
||||
bas_asset_account: '1010',
|
||||
bas_accumulated_account: '1019',
|
||||
bas_expense_account: '7810',
|
||||
})
|
||||
expect(captured.update).toMatchObject({
|
||||
bas_asset_account: '1010',
|
||||
bas_accumulated_account: '1019',
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks a correction when depreciation was hand-posted (no engine schedule)', async () => {
|
||||
// No depreciation_schedules row, but a manual credit to the asset's 1259
|
||||
// accumulated account exists in the ledger: must still block.
|
||||
@@ -262,4 +399,106 @@ describe('updateAsset: acquisition-basis correction guard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAsset: framework-aware immaterial defaults', () => {
|
||||
function mockForCreate(
|
||||
opts: { accountingFramework?: AccountingFramework | null; companyError?: string } = {},
|
||||
) {
|
||||
const captured: { insert: Record<string, unknown> | null; companyReads: number } = {
|
||||
insert: null,
|
||||
companyReads: 0,
|
||||
}
|
||||
const supabase = {
|
||||
from: vi.fn((table: string) => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
if (table === 'companies') {
|
||||
captured.companyReads += 1
|
||||
chain.select = vi.fn(() => chain)
|
||||
chain.eq = vi.fn(() => chain)
|
||||
chain.single = vi.fn(async () =>
|
||||
opts.companyError
|
||||
? { data: null, error: { message: opts.companyError } }
|
||||
: { data: { accounting_framework: opts.accountingFramework ?? null }, error: null },
|
||||
)
|
||||
return chain
|
||||
}
|
||||
chain.insert = vi.fn((payload: Record<string, unknown>) => {
|
||||
captured.insert = payload
|
||||
return chain
|
||||
})
|
||||
chain.select = vi.fn(() => chain)
|
||||
chain.single = vi.fn(async () => ({
|
||||
data: { id: 'asset-new', ...(captured.insert ?? {}) },
|
||||
error: null,
|
||||
}))
|
||||
return chain
|
||||
}),
|
||||
}
|
||||
return { supabase, captured }
|
||||
}
|
||||
|
||||
const asSupabase = (s: unknown) => s as Parameters<typeof createAsset>[0]
|
||||
|
||||
const baseInput = {
|
||||
name: 'Programvarulicens',
|
||||
category: 'immaterial' as const,
|
||||
acquisition_date: '2025-03-01',
|
||||
acquisition_cost: 60_000,
|
||||
useful_life_months: 60,
|
||||
}
|
||||
|
||||
it('books a K2 company immaterial asset on the acquired pair 1090/1099', async () => {
|
||||
const { supabase, captured } = mockForCreate({ accountingFramework: 'k2' })
|
||||
await createAsset(asSupabase(supabase), 'co', 'user-1', baseInput)
|
||||
expect(captured.insert).toMatchObject({
|
||||
bas_asset_account: '1090',
|
||||
bas_accumulated_account: '1099',
|
||||
bas_expense_account: '7810',
|
||||
})
|
||||
})
|
||||
|
||||
it('books a K3 company immaterial asset on 1010/1019', async () => {
|
||||
const { supabase, captured } = mockForCreate({ accountingFramework: 'k3' })
|
||||
await createAsset(asSupabase(supabase), 'co', 'user-1', baseInput)
|
||||
expect(captured.insert).toMatchObject({
|
||||
bas_asset_account: '1010',
|
||||
bas_accumulated_account: '1019',
|
||||
})
|
||||
})
|
||||
|
||||
it('still honours an explicit account override', async () => {
|
||||
const { supabase, captured } = mockForCreate({ accountingFramework: 'k2' })
|
||||
await createAsset(asSupabase(supabase), 'co', 'user-1', {
|
||||
...baseInput,
|
||||
bas_asset_account: '1030',
|
||||
bas_accumulated_account: '1039',
|
||||
})
|
||||
expect(captured.insert).toMatchObject({
|
||||
bas_asset_account: '1030',
|
||||
bas_accumulated_account: '1039',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not read the company for a tangible category', async () => {
|
||||
const { supabase, captured } = mockForCreate({ accountingFramework: 'k2' })
|
||||
await createAsset(asSupabase(supabase), 'co', 'user-1', {
|
||||
...baseInput,
|
||||
name: 'MacBook Pro',
|
||||
category: 'computer',
|
||||
})
|
||||
expect(captured.companyReads).toBe(0)
|
||||
expect(captured.insert).toMatchObject({
|
||||
bas_asset_account: '1250',
|
||||
bas_accumulated_account: '1259',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws instead of guessing a framework when the company read fails', async () => {
|
||||
const { supabase, captured } = mockForCreate({ companyError: 'connection reset' })
|
||||
await expect(
|
||||
createAsset(asSupabase(supabase), 'co', 'user-1', baseInput),
|
||||
).rejects.toThrow(/accounting framework/i)
|
||||
expect(captured.insert).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* The K2 gate fires on ANY account the BAS chart flags k2_excluded ("Ej K2"),
|
||||
* but those accounts are excluded for different reasons. These tests pin that
|
||||
* the rejection text cites BFNAR 2016:10 punkt 10.4 only for the
|
||||
* egenupparbetade immateriella group and stays generic everywhere else, so a
|
||||
* deferred-tax or fair-value account never gets a wrong legal citation.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
findK2ExcludedAccount,
|
||||
k2ExcludedAccountMessages,
|
||||
} from '@/lib/bokslut/assets/k2-account-guard'
|
||||
import { BAS_REFERENCE, getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
function messagesFor(accountNumber: string) {
|
||||
const account = getBASReference(accountNumber)
|
||||
expect(account, `${accountNumber} missing from the BAS reference`).toBeTruthy()
|
||||
return k2ExcludedAccountMessages(account!)
|
||||
}
|
||||
|
||||
describe('findK2ExcludedAccount', () => {
|
||||
it('returns the first Ej K2 account in the list', () => {
|
||||
expect(findK2ExcludedAccount(['1030', '1010'])?.account_number).toBe('1010')
|
||||
expect(findK2ExcludedAccount(['1010', '1370'])?.account_number).toBe('1010')
|
||||
})
|
||||
|
||||
it('returns null when every account is allowed under K2', () => {
|
||||
expect(findK2ExcludedAccount(['1030', '1039'])).toBeNull()
|
||||
expect(findK2ExcludedAccount([undefined, '1220', '1229'])).toBeNull()
|
||||
})
|
||||
|
||||
it('treats unknown account numbers as allowed', () => {
|
||||
expect(findK2ExcludedAccount(['9999'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('k2ExcludedAccountMessages', () => {
|
||||
// The boundary is derived from the chart (kontogrupp 10 + k2_excluded), not
|
||||
// from a literal list. This asserts the derivation still selects exactly the
|
||||
// egenupparbetade accounts, so a future flag change surfaces here.
|
||||
const intangibleGroup = BAS_REFERENCE.filter(
|
||||
(a) => a.k2_excluded && a.account_class === 1 && a.account_group === '10',
|
||||
).map((a) => a.account_number)
|
||||
|
||||
it('covers exactly the egenupparbetade immateriella accounts', () => {
|
||||
expect(intangibleGroup).toEqual(['1010', '1011', '1012', '1018', '1019', '1081'])
|
||||
})
|
||||
|
||||
it.each(['1010', '1011', '1012', '1018', '1019', '1081'])(
|
||||
'cites BFNAR 2016:10 punkt 10.4 for %s',
|
||||
(accountNumber) => {
|
||||
const { message_sv, message_en } = messagesFor(accountNumber)
|
||||
expect(message_sv).toContain(accountNumber)
|
||||
expect(message_sv).toContain('egenupparbetade utvecklingsutgifter')
|
||||
expect(message_sv).toContain('BFNAR 2016:10 punkt 10.4')
|
||||
expect(message_en).toContain('internally generated development expenditure')
|
||||
expect(message_en).toContain('BFNAR 2016:10 paragraph 10.4')
|
||||
},
|
||||
)
|
||||
|
||||
// K2 forbids only EGENUPPARBETADE immateriella tillgångar; an ACQUIRED one
|
||||
// is lawful and belongs on 1090 (k2-vs-k3.md:24, "Only acquired intangibles
|
||||
// may be recognized"). The rejection has to point there, otherwise the only
|
||||
// way out it offers is the one that is wrong.
|
||||
it.each(['1010', '1011', '1012', '1018', '1019', '1081'])(
|
||||
'points %s at the acquired-intangible account 1090 instead',
|
||||
(accountNumber) => {
|
||||
const { message_sv, message_en } = messagesFor(accountNumber)
|
||||
expect(message_sv).toContain('1090')
|
||||
expect(message_sv).toContain('förvärvad immateriell tillgång')
|
||||
expect(message_en).toContain('1090')
|
||||
expect(message_en).toContain('acquired intangible asset')
|
||||
},
|
||||
)
|
||||
|
||||
// Switching regelverk pulls in komponentavskrivning and uppskjuten skatt and
|
||||
// rewrites the whole årsredovisning: it is never the remedy for one
|
||||
// misdirected account. No message may suggest it, on any Ej K2 account.
|
||||
it('never tells the user to change the accounting framework', () => {
|
||||
for (const account of BAS_REFERENCE.filter((a) => a.k2_excluded)) {
|
||||
const { message_sv, message_en } = k2ExcludedAccountMessages(account)
|
||||
expect(message_sv).not.toContain('Byt regelverk')
|
||||
expect(message_sv).not.toContain('Inställningar')
|
||||
expect(message_en).not.toContain('Switch the accounting framework')
|
||||
expect(message_en).not.toContain('Settings')
|
||||
}
|
||||
})
|
||||
|
||||
// The routes resolve accounting_framework from a read whose error they
|
||||
// discard, so a transient failure looks like "not K3" even for a K3
|
||||
// company. The text must therefore describe the ACCOUNT, never claim which
|
||||
// framework this company applies.
|
||||
it('never asserts which framework the company applies', () => {
|
||||
for (const account of BAS_REFERENCE.filter((a) => a.k2_excluded)) {
|
||||
const { message_sv, message_en } = k2ExcludedAccountMessages(account)
|
||||
expect(message_sv).not.toContain('företaget tillämpar')
|
||||
expect(message_sv).not.toContain('ert företag')
|
||||
expect(message_en).not.toContain('the company applies')
|
||||
expect(message_en).not.toContain('your company')
|
||||
}
|
||||
})
|
||||
|
||||
it('never cites punkt 10.4 for an Ej K2 account outside kontogrupp 10', () => {
|
||||
const others = BAS_REFERENCE.filter((a) => a.k2_excluded).filter(
|
||||
(a) => !intangibleGroup.includes(a.account_number),
|
||||
)
|
||||
// 1370, 1518, 2089, 2092, 2096, 2240, 2448, 3940, 7940, 82xx-84xx, 8940.
|
||||
expect(others.length).toBeGreaterThan(0)
|
||||
for (const account of others) {
|
||||
const { message_sv, message_en } = k2ExcludedAccountMessages(account)
|
||||
expect(message_sv).toContain(account.account_number)
|
||||
expect(message_sv).toContain('Ej K2')
|
||||
expect(message_sv).toContain('K3')
|
||||
expect(message_sv).not.toContain('10.4')
|
||||
expect(message_sv).not.toContain('egenupparbetade')
|
||||
// 1090 is the answer for a misdirected intangible only: proposing it for
|
||||
// a deferred-tax or fair-value account would be its own wrong advice.
|
||||
expect(message_sv).not.toContain('1090')
|
||||
expect(message_en).toContain('Ej K2')
|
||||
expect(message_en).not.toContain('10.4')
|
||||
expect(message_en).not.toContain('intangible')
|
||||
expect(message_en).not.toContain('1090')
|
||||
}
|
||||
})
|
||||
|
||||
it('names the account so the user can see what was rejected', () => {
|
||||
const { message_sv } = messagesFor('1370')
|
||||
expect(message_sv).toContain('1370 (Uppskjuten skattefordran)')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-l
|
||||
import { computeAnnualDepreciation } from './depreciation-engine'
|
||||
import { assessJamkning, assessJamkningEligibility } from './jamkning'
|
||||
import type {
|
||||
AccountingFramework,
|
||||
Asset,
|
||||
AssetCategory,
|
||||
WritableDepreciationMethod,
|
||||
@@ -20,23 +21,32 @@ import type {
|
||||
VatTreatment,
|
||||
} from '@/types'
|
||||
|
||||
export interface AssetAccountTriple {
|
||||
asset: string
|
||||
accumulated: string
|
||||
expense: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Default BAS account triples per category. The user can override at create
|
||||
* time; these only kick in when the form doesn't specify accounts. Every
|
||||
* account here MUST exist in BAS_REFERENCE (lib/bookkeeping/bas-data/) so the
|
||||
* engine's backfillStandardBASAccounts can seed it on a minimal chart:
|
||||
* otherwise depreciation throws AccountsNotInChartError (#755). A guard test in
|
||||
* asset-service.test.ts enforces that invariant.
|
||||
* Default BAS account triples per category, in their K3 form. The user can
|
||||
* override at create time; these only kick in when the form doesn't specify
|
||||
* accounts. Every account here MUST exist in BAS_REFERENCE
|
||||
* (lib/bookkeeping/bas-data/) so the engine's backfillStandardBASAccounts can
|
||||
* seed it on a minimal chart: otherwise depreciation throws
|
||||
* AccountsNotInChartError (#755). A guard test in asset-service.test.ts
|
||||
* enforces that invariant.
|
||||
*
|
||||
* The intangible entry is framework-dependent: resolve it through
|
||||
* defaultAccountsForCategory() rather than reading this map directly, so a K2
|
||||
* company never lands on the egenupparbetade pair. See
|
||||
* ACQUIRED_IMMATERIAL_ACCOUNTS below.
|
||||
*
|
||||
* vehicle (1240) and computer (1250) both sit in the maskiner-och-inventarier
|
||||
* asset range, so their depreciation maps to 7832 (Avskrivningar på
|
||||
* inventarier, verktyg och installationer). 7833/7834 are not in the standard
|
||||
* BAS catalog (removed as non-standard in #463).
|
||||
*/
|
||||
export const DEFAULT_ACCOUNTS_BY_CATEGORY: Record<
|
||||
AssetCategory,
|
||||
{ asset: string; accumulated: string; expense: string }
|
||||
> = {
|
||||
export const DEFAULT_ACCOUNTS_BY_CATEGORY: Record<AssetCategory, AssetAccountTriple> = {
|
||||
immaterial: { asset: '1010', accumulated: '1019', expense: '7810' },
|
||||
building: { asset: '1110', accumulated: '1119', expense: '7821' },
|
||||
land_improvement: { asset: '1150', accumulated: '1159', expense: '7824' },
|
||||
@@ -47,6 +57,76 @@ export const DEFAULT_ACCOUNTS_BY_CATEGORY: Record<
|
||||
other_tangible: { asset: '1290', accumulated: '1299', expense: '7839' },
|
||||
}
|
||||
|
||||
/**
|
||||
* The acquired-intangible pair, i.e. the K2 default for the immaterial
|
||||
* category.
|
||||
*
|
||||
* K2 forbids capitalizing EGENUPPARBETADE immateriella tillgångar, which is
|
||||
* exactly what 1010/1019 (Utvecklingsutgifter) carry: the BAS chart flags them
|
||||
* k2_excluded ("Ej K2"). A PURCHASED intangible (a software licence, a
|
||||
* trademark, a patent) is perfectly lawful under K2 and belongs on 1090
|
||||
* Övriga immateriella anläggningstillgångar / 1099 Ackumulerade avskrivningar
|
||||
* på övriga immateriella anläggningstillgångar. Both carry k2_excluded: false
|
||||
* and both sit inside the 1010-1099 range the immaterial category permits, so
|
||||
* the Zod range refinement and the K2 gate accept them.
|
||||
*
|
||||
* Source: .claude/skills/swedish-year-end-closing/references/k2-vs-k3.md:24,
|
||||
* "K2: All development costs must be expensed immediately. Only acquired
|
||||
* intangibles may be recognized."
|
||||
*/
|
||||
const ACQUIRED_IMMATERIAL_ACCOUNTS = { asset: '1090', accumulated: '1099' } as const
|
||||
|
||||
/**
|
||||
* Category defaults for a given accounting framework. Only the intangible
|
||||
* category depends on the framework; everything else is identical either way.
|
||||
* Anything other than 'k3' (including a null column) counts as K2, mirroring
|
||||
* how the rest of the codebase reads the flag.
|
||||
*
|
||||
* The expense account is framework-independent: 7810 (Avskrivningar på
|
||||
* immateriella anläggningstillgångar) covers both pairs.
|
||||
*/
|
||||
export function defaultAccountsForCategory(
|
||||
category: AssetCategory,
|
||||
framework: AccountingFramework | null | undefined,
|
||||
): AssetAccountTriple {
|
||||
const defaults = DEFAULT_ACCOUNTS_BY_CATEGORY[category]
|
||||
if (category !== 'immaterial' || framework === 'k3') return defaults
|
||||
return { ...defaults, ...ACQUIRED_IMMATERIAL_ACCOUNTS }
|
||||
}
|
||||
|
||||
/**
|
||||
* The same defaults, resolved against the company's stored framework. Doing
|
||||
* the lookup here rather than at the API layer means every write path (create
|
||||
* dialog, edit dialog, MCP, a future importer) gets the lawful default without
|
||||
* having to know the rule: the edit dialog in particular sends only the fields
|
||||
* it changed and has no account inputs at all.
|
||||
*
|
||||
* The companies read only happens for the intangible category, the one case
|
||||
* whose answer depends on it. A failed read throws instead of guessing a
|
||||
* framework: silently picking an account off an unchecked read is what put
|
||||
* purchased intangibles on 1010 in the first place.
|
||||
*/
|
||||
async function resolveDefaultAccounts(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
category: AssetCategory,
|
||||
): Promise<AssetAccountTriple> {
|
||||
if (category !== 'immaterial') return DEFAULT_ACCOUNTS_BY_CATEGORY[category]
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select('accounting_framework')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
if (error) {
|
||||
throw new Error(
|
||||
`Failed to load accounting framework for company ${companyId}: ${error.message}`,
|
||||
)
|
||||
}
|
||||
const framework = (data as { accounting_framework?: AccountingFramework | null } | null)
|
||||
?.accounting_framework
|
||||
return defaultAccountsForCategory(category, framework)
|
||||
}
|
||||
|
||||
export interface CreateAssetInput {
|
||||
name: string
|
||||
category: AssetCategory
|
||||
@@ -68,7 +148,8 @@ export interface CreateAssetInput {
|
||||
|
||||
/**
|
||||
* Create a new asset. Defaults BAS accounts from the category mapping when
|
||||
* the caller doesn't override them. Does NOT post a journal entry: the
|
||||
* the caller doesn't override them, framework-aware for the intangible
|
||||
* category (see resolveDefaultAccounts). Does NOT post a journal entry: the
|
||||
* acquisition is assumed to already be in the books (bank payment or
|
||||
* supplier invoice). Posting an acquisition entry alongside an existing
|
||||
* payment would double-count.
|
||||
@@ -79,7 +160,7 @@ export async function createAsset(
|
||||
userId: string,
|
||||
input: CreateAssetInput,
|
||||
): Promise<Asset> {
|
||||
const defaults = DEFAULT_ACCOUNTS_BY_CATEGORY[input.category]
|
||||
const defaults = await resolveDefaultAccounts(supabase, companyId, input.category)
|
||||
const row = {
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
@@ -343,7 +424,9 @@ export async function updateAsset(
|
||||
// The BAS triple is category-scoped (INK2R mapping + engine defaults depend
|
||||
// on it). When the category changes and the caller didn't supply explicit
|
||||
// accounts, reset the triple to the new category's defaults so the chart
|
||||
// stays aligned, mirrors createAsset()'s defaulting.
|
||||
// stays aligned, mirrors createAsset()'s defaulting. Framework-aware for the
|
||||
// intangible category, so recategorizing a purchased licence to "Immateriell
|
||||
// tillgång" lands a K2 company on 1090/1099 instead of the Ej K2 pair.
|
||||
if (
|
||||
input.category !== undefined &&
|
||||
existing &&
|
||||
@@ -352,7 +435,7 @@ export async function updateAsset(
|
||||
input.bas_accumulated_account === undefined &&
|
||||
input.bas_expense_account === undefined
|
||||
) {
|
||||
const defaults = DEFAULT_ACCOUNTS_BY_CATEGORY[input.category]
|
||||
const defaults = await resolveDefaultAccounts(supabase, companyId, input.category)
|
||||
input = {
|
||||
...input,
|
||||
bas_asset_account: defaults.asset,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* K2 framework gate for the asset register.
|
||||
*
|
||||
* The BAS chart marks every account a K2 company may not use with the
|
||||
* k2_excluded flag ("Ej K2"). The gate asks the BAS reference instead of
|
||||
* hardcoding number ranges: any account whose flag is set requires the K3
|
||||
* framework. The asset API routes reject writes that would land an asset (or
|
||||
* its accumulated-depreciation counterpart) on such an account when the
|
||||
* company's accounting_framework is not 'k3'.
|
||||
*
|
||||
* Those accounts are excluded for DIFFERENT reasons, so the rejection message
|
||||
* has to name the rule that actually applies:
|
||||
*
|
||||
* - Kontogrupp 10 (immateriella anläggningstillgångar): 1010-1019 balanserade
|
||||
* utvecklingsutgifter plus 1081 pågående projekt. These are the
|
||||
* egenupparbetade immateriella tillgångar that K2 forbids capitalizing
|
||||
* (BFNAR 2016:10 punkt 10.4), so the message cites that rule.
|
||||
* - Everything else on the Ej K2 list (uppskjuten skatt 1370/2240/8940,
|
||||
* verkligt värde, säkringsredovisning, aktiverade ränteutgifter, ...) is
|
||||
* excluded for unrelated reasons. Those get a generic message: what the
|
||||
* chart says, and that it presumes K3. No paragraph reference is invented
|
||||
* for them, since citing punkt 10.4 on a deferred-tax account would put a
|
||||
* factually wrong legal claim in front of the user.
|
||||
*
|
||||
* Two things the messages deliberately do NOT do:
|
||||
*
|
||||
* - They never assert which framework the company applies. The routes read
|
||||
* companies.accounting_framework without checking the read error, so a
|
||||
* transient failure resolves to "not K3" for a company that IS on K3;
|
||||
* phrasing the rejection as a fact about the account keeps a failed read
|
||||
* from turning into a false claim about the customer's regelverk.
|
||||
* - They never propose switching regelverk as the remedy. Moving a company
|
||||
* from K2 to K3 pulls in komponentavskrivning and uppskjuten skatt and
|
||||
* rewrites the whole årsredovisning; it is not a fix for one misdirected
|
||||
* account. The remedy offered is the lawful account instead: an ACQUIRED
|
||||
* intangible belongs on 1090, which K2 permits
|
||||
* (.claude/skills/swedish-year-end-closing/references/k2-vs-k3.md:24,
|
||||
* "Only acquired intangibles may be recognized").
|
||||
*/
|
||||
import { getBASReference, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/** Swedish and English rejection text, mirroring the structured-errors registry shape. */
|
||||
export interface K2ExcludedAccountMessages {
|
||||
message_sv: string
|
||||
message_en: string
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the BAS chart itself puts the account in kontogrupp 10
|
||||
* (immateriella anläggningstillgångar) and flags it Ej K2. That intersection
|
||||
* is exactly the egenupparbetade set (1010, 1011, 1012, 1018, 1019, 1081):
|
||||
* every other group-10 account covers an ACQUIRED intangible (koncessioner,
|
||||
* patent, licenser, varumärken, hyresrätter, goodwill, förskott, övriga) and
|
||||
* carries k2_excluded=false. Reading the boundary off the chart instead of a
|
||||
* literal account list means a flag change in
|
||||
* lib/bookkeeping/bas-data/class-1-assets.ts moves the boundary with it.
|
||||
*/
|
||||
function isEgenupparbetadImmateriell(account: BASReferenceAccount): boolean {
|
||||
return account.k2_excluded && account.account_class === 1 && account.account_group === '10'
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first account in the list that the BAS reference flags as
|
||||
* k2_excluded ("Ej K2"), or null when every account is allowed under K2.
|
||||
* Unknown account numbers are treated as allowed: the Zod range checks and
|
||||
* chart validation own that concern.
|
||||
*/
|
||||
export function findK2ExcludedAccount(
|
||||
accountNumbers: Array<string | undefined>,
|
||||
): BASReferenceAccount | null {
|
||||
for (const accountNumber of accountNumbers) {
|
||||
if (!accountNumber) continue
|
||||
const reference = getBASReference(accountNumber)
|
||||
if (reference?.k2_excluded) return reference
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing text for the K2_EXCLUDED_ACCOUNT rejection, in both languages.
|
||||
* The legal citation is conditional on what actually triggered the gate: see
|
||||
* the file header.
|
||||
*/
|
||||
export function k2ExcludedAccountMessages(
|
||||
account: BASReferenceAccount,
|
||||
): K2ExcludedAccountMessages {
|
||||
const label = `${account.account_number} (${account.account_name})`
|
||||
|
||||
if (isEgenupparbetadImmateriell(account)) {
|
||||
return {
|
||||
message_sv:
|
||||
`Konto ${label} är reserverat för egenupparbetade utvecklingsutgifter, som bara får ` +
|
||||
`aktiveras enligt K3 (BFNAR 2016:10 punkt 10.4). En förvärvad immateriell tillgång ` +
|
||||
`bokförs på 1090 (Övriga immateriella anläggningstillgångar).`,
|
||||
message_en:
|
||||
`Account ${label} is reserved for internally generated development expenditure, which ` +
|
||||
`may only be capitalized under K3 (BFNAR 2016:10 paragraph 10.4). An acquired ` +
|
||||
`intangible asset belongs on 1090 (Övriga immateriella anläggningstillgångar).`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message_sv: `Konto ${label} är markerat Ej K2 i BAS-kontoplanen och förutsätter K3.`,
|
||||
message_en:
|
||||
`Account ${label} is marked Ej K2 in the BAS chart of accounts and presumes the K3 ` +
|
||||
`framework.`,
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,7 @@ describe('Contra accounts have opposite normal_balance', () => {
|
||||
describe('K2-excluded accounts', () => {
|
||||
const k2Excluded = [
|
||||
'1010', '1011', '1012', '1018', '1019',
|
||||
'1081',
|
||||
'1370', '1518',
|
||||
'2089', '2092', '2096', '2240', '2448',
|
||||
'3940', '7940',
|
||||
@@ -192,9 +193,9 @@ describe('K2-excluded accounts', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('total K2-excluded count matches expected (26)', () => {
|
||||
it('total K2-excluded count matches expected (27)', () => {
|
||||
const k2Count = BAS_REFERENCE.filter((a) => a.k2_excluded).length
|
||||
expect(k2Count).toBe(26)
|
||||
expect(k2Count).toBe(27)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -274,7 +274,10 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
|
||||
normal_balance: 'debit',
|
||||
description: 'Pågående projekt för immateriella anläggningstillgångar',
|
||||
sru_code: '7201',
|
||||
k2_excluded: false,
|
||||
// Ej K2: pagaende egenupparbetade immateriella projekt ar forbjudna under
|
||||
// BFNAR 2016:10 punkt 10.4 (egenupparbetade immateriella far inte aktiveras).
|
||||
// Saknades i 20260225103139-backfillen; DB-raderna rattas i 20260805112000.
|
||||
k2_excluded: true,
|
||||
},
|
||||
{
|
||||
account_number: '1088',
|
||||
|
||||
@@ -3174,6 +3174,20 @@ const ASSETS: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'Acquisition date, cost and category cannot be changed once the asset has been disposed or depreciation has been posted. Reverse (storno) first, or use the disposal flow.',
|
||||
},
|
||||
// Generic on purpose: the flag covers accounts excluded from K2 for several
|
||||
// different reasons (egenupparbetade immateriella, uppskjuten skatt,
|
||||
// verkligt värde, säkringsredovisning, ...), so the static entry states only
|
||||
// what the BAS chart says. The asset routes override it with an
|
||||
// account-specific message from lib/bokslut/assets/k2-account-guard.ts,
|
||||
// which cites BFNAR 2016:10 punkt 10.4 only when the intangible group is
|
||||
// what actually triggered the gate.
|
||||
K2_EXCLUDED_ACCOUNT: {
|
||||
httpStatus: 422,
|
||||
message_sv:
|
||||
'Kontot är markerat Ej K2 i BAS-kontoplanen och förutsätter K3. Välj ett konto som är tillåtet enligt K2.',
|
||||
message_en:
|
||||
'The account is marked Ej K2 in the BAS chart of accounts and presumes the K3 framework. Pick an account that K2 permits.',
|
||||
},
|
||||
}
|
||||
|
||||
// Dimensions registry (kostnadsställe/projekt): dev_docs/dimensions_implementation_plan.md §6
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Flag account 1081 (Pagaende projekt for immateriella anlaggningstillgangar)
|
||||
-- as k2_excluded. Ongoing self-developed intangible projects are forbidden
|
||||
-- under K2 (BFNAR 2016:10 punkt 10.4: egenupparbetade immateriella
|
||||
-- anlaggningstillgangar far inte aktiveras), so the account is K3-only.
|
||||
--
|
||||
-- The account was missing from the k2_excluded backfill list in
|
||||
-- 20260225103139_full_bas_2026.sql. The TS BAS reference
|
||||
-- (lib/bookkeeping/bas-data/class-1-assets.ts) is updated in the same change;
|
||||
-- this migration brings already-provisioned chart_of_accounts rows in line so
|
||||
-- catalog filtering and future account syncs agree with the reference.
|
||||
-- Metadata-only update: no balances, triggers, RPCs, or RLS involved.
|
||||
|
||||
UPDATE public.chart_of_accounts
|
||||
SET k2_excluded = true, updated_at = now()
|
||||
WHERE account_number = '1081'
|
||||
AND k2_excluded = false;
|
||||
Reference in New Issue
Block a user