* 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>
593 lines
21 KiB
TypeScript
593 lines
21 KiB
TypeScript
/**
|
|
* Tests for GET/PATCH /api/assets/[id].
|
|
*
|
|
* Exercises the routes through the real withRouteContext wrapper, mocking the
|
|
* asset service and auth/company dependencies. The K3 component cross-sum
|
|
* validation runs the REAL validateComponents so the regression case (body
|
|
* changes acquisition_cost and k3_components together — sum must match the
|
|
* NEW cost) is covered end to end.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
|
|
|
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
|
|
|
const requireAuthMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-auth', () => ({
|
|
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
const requireWriteMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
|
}))
|
|
|
|
// 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'
|
|
import { POST } from '../route'
|
|
|
|
const mockGetAsset = vi.mocked(getAsset)
|
|
const mockUpdateAsset = vi.mocked(updateAsset)
|
|
const mockCreateAsset = vi.mocked(createAsset)
|
|
const routeParams = { params: Promise.resolve({ id: 'asset-1' }) }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
|
requireWriteMock.mockResolvedValue({ ok: true })
|
|
})
|
|
|
|
describe('GET /api/assets/[id]', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
|
|
const res = await GET(createMockRequest('/api/assets/asset-1'), routeParams)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 404 when the asset does not exist', async () => {
|
|
mockGetAsset.mockResolvedValue(null)
|
|
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
|
await GET(createMockRequest('/api/assets/asset-1'), routeParams)
|
|
)
|
|
|
|
expect(status).toBe(404)
|
|
expect(body.error.code).toBe('ASSET_NOT_FOUND')
|
|
})
|
|
})
|
|
|
|
describe('POST /api/assets', () => {
|
|
it('rejects legacy per-asset tax depreciation methods with 400', async () => {
|
|
const response = await POST(createMockRequest('/api/assets', {
|
|
method: 'POST',
|
|
body: {
|
|
name: 'Maskin',
|
|
category: 'machinery',
|
|
acquisition_date: '2025-01-01',
|
|
acquisition_cost: 100_000,
|
|
useful_life_months: 60,
|
|
depreciation_method: 'declining_balance_30',
|
|
},
|
|
}))
|
|
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it('creates a valid asset with ordinary linear depreciation', async () => {
|
|
mockCreateAsset.mockResolvedValue({ id: 'asset-new', depreciation_method: 'linear' } as never)
|
|
const response = await POST(createMockRequest('/api/assets', {
|
|
method: 'POST',
|
|
body: {
|
|
name: 'Maskin',
|
|
category: 'machinery',
|
|
acquisition_date: '2025-01-01',
|
|
acquisition_cost: 100_000,
|
|
useful_life_months: 60,
|
|
depreciation_method: 'linear',
|
|
},
|
|
}))
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(mockCreateAsset).toHaveBeenCalledWith(
|
|
supabase,
|
|
'company-1',
|
|
'user-1',
|
|
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]', () => {
|
|
it('rejects legacy per-asset tax depreciation methods with 400', async () => {
|
|
const req = createMockRequest('/api/assets/asset-1', {
|
|
method: 'PATCH',
|
|
body: { depreciation_method: 'declining_balance_30' },
|
|
})
|
|
|
|
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
|
|
|
expect(status).toBe(400)
|
|
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects an invalid body (non-positive acquisition_cost) with 400', async () => {
|
|
const req = createMockRequest('/api/assets/asset-1', {
|
|
method: 'PATCH',
|
|
body: { acquisition_cost: -5 },
|
|
})
|
|
|
|
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
|
|
|
expect(status).toBe(400)
|
|
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects k3_components for a K2 company with 422', async () => {
|
|
enqueue({ data: { accounting_framework: 'k2' } })
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
|
|
|
|
const req = createMockRequest('/api/assets/asset-1', {
|
|
method: 'PATCH',
|
|
body: {
|
|
k3_components: [{ name: 'Stomme', cost: 100000, useful_life_months: 600 }],
|
|
},
|
|
})
|
|
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
|
await PATCH(req, routeParams)
|
|
)
|
|
|
|
expect(status).toBe(422)
|
|
expect(body.error.code).toBe('K3_REQUIRED_FOR_COMPONENTS')
|
|
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('validates the component sum against the NEW acquisition_cost when both change', async () => {
|
|
// Regression: stored cost is 100 000 but the PATCH raises it to 120 000.
|
|
// Components summing to 120 000 must pass — previously they were checked
|
|
// against the stale stored cost and wrongly rejected.
|
|
enqueue({ data: { accounting_framework: 'k3' } })
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockUpdateAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 120000 } as any)
|
|
|
|
const req = createMockRequest('/api/assets/asset-1', {
|
|
method: 'PATCH',
|
|
body: {
|
|
acquisition_cost: 120000,
|
|
k3_components: [
|
|
{ name: 'Stomme', cost: 90000, useful_life_months: 600 },
|
|
{ name: 'Tak', cost: 30000, useful_life_months: 240 },
|
|
],
|
|
},
|
|
})
|
|
|
|
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
|
|
|
expect(status).toBe(200)
|
|
expect(mockUpdateAsset).toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects components that sum to the OLD cost when the PATCH changes the cost', async () => {
|
|
enqueue({ data: { accounting_framework: 'k3' } })
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
|
|
|
|
const req = createMockRequest('/api/assets/asset-1', {
|
|
method: 'PATCH',
|
|
body: {
|
|
acquisition_cost: 120000,
|
|
k3_components: [{ name: 'Stomme', cost: 100000, useful_life_months: 600 }],
|
|
},
|
|
})
|
|
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
|
await PATCH(req, routeParams)
|
|
)
|
|
|
|
expect(status).toBe(400)
|
|
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()
|
|
})
|
|
})
|