diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts index db8b82e0..56537936 100644 --- a/app/api/assets/[id]/route.ts +++ b/app/api/assets/[id]/route.ts @@ -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 }) diff --git a/app/api/assets/__tests__/id.test.ts b/app/api/assets/__tests__/id.test.ts index 73716506..7f49fbe0 100644 --- a/app/api/assets/__tests__/id.test.ts +++ b/app/api/assets/__tests__/id.test.ts @@ -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() + 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() + }) }) diff --git a/app/api/assets/route.ts b/app/api/assets/route.ts index 4714c106..27688cdf 100644 --- a/app/api/assets/route.ts +++ b/app/api/assets/route.ts @@ -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 }, diff --git a/components/bookkeeping/assets/CreateAssetDialog.tsx b/components/bookkeeping/assets/CreateAssetDialog.tsx index 84d3ec4a..227ff018 100644 --- a/components/bookkeeping/assets/CreateAssetDialog.tsx +++ b/components/bookkeeping/assets/CreateAssetDialog.tsx @@ -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 ))} + {category === 'immaterial' && !isK3 && ( +

+ 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. +

+ )} + {category === 'immaterial' && isK3 && ( +

+ 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 §. +

+ )}
diff --git a/lib/bokslut/__tests__/asset-service.test.ts b/lib/bokslut/__tests__/asset-service.test.ts index 8826236a..387c75e7 100644 --- a/lib/bokslut/__tests__/asset-service.test.ts +++ b/lib/bokslut/__tests__/asset-service.test.ts @@ -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 { 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 | 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 = {} + 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 | null; companyReads: number } = { + insert: null, + companyReads: 0, + } + const supabase = { + from: vi.fn((table: string) => { + const chain: Record = {} + 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) => { + 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[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() + }) +}) + diff --git a/lib/bokslut/assets/__tests__/k2-account-guard.test.ts b/lib/bokslut/assets/__tests__/k2-account-guard.test.ts new file mode 100644 index 00000000..42c10bb2 --- /dev/null +++ b/lib/bokslut/assets/__tests__/k2-account-guard.test.ts @@ -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)') + }) +}) diff --git a/lib/bokslut/assets/asset-service.ts b/lib/bokslut/assets/asset-service.ts index 0d2e79c2..24c6fb16 100644 --- a/lib/bokslut/assets/asset-service.ts +++ b/lib/bokslut/assets/asset-service.ts @@ -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 = { 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 { + 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 { - 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, diff --git a/lib/bokslut/assets/k2-account-guard.ts b/lib/bokslut/assets/k2-account-guard.ts new file mode 100644 index 00000000..6fc26ba7 --- /dev/null +++ b/lib/bokslut/assets/k2-account-guard.ts @@ -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, +): 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.`, + } +} diff --git a/lib/bookkeeping/__tests__/bas-reference.test.ts b/lib/bookkeeping/__tests__/bas-reference.test.ts index a129fb65..3b5f7c86 100644 --- a/lib/bookkeeping/__tests__/bas-reference.test.ts +++ b/lib/bookkeeping/__tests__/bas-reference.test.ts @@ -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) }) }) diff --git a/lib/bookkeeping/bas-data/class-1-assets.ts b/lib/bookkeeping/bas-data/class-1-assets.ts index e83479f0..97fdf798 100644 --- a/lib/bookkeeping/bas-data/class-1-assets.ts +++ b/lib/bookkeeping/bas-data/class-1-assets.ts @@ -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', diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 8cc7ab09..58ed0ce5 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -3174,6 +3174,20 @@ const ASSETS: Record = { 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 diff --git a/supabase/migrations/20260805112000_flag_1081_ej_k2.sql b/supabase/migrations/20260805112000_flag_1081_ej_k2.sql new file mode 100644 index 00000000..81818075 --- /dev/null +++ b/supabase/migrations/20260805112000_flag_1081_ej_k2.sql @@ -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;