Files
accounted/app/api/assets/__tests__/id.test.ts
T
Mattsson 5ca64bde30 feat(bokslut): IL 18 kap pooled tax depreciation with method election (#1393)
* feat(bokslut): IL 18 kap pooled tax depreciation with method election

Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25
as a company-level annual pool separate from per-asset book depreciation.
Method election persisted with immutable snapshots and book-conformity
confirmation for rakenskapsenlig.

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

* chore(db): move tax depreciation migrations to coordinated versions

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

* fix(bokslut): keep tax depreciation view loadable when a saved election goes stale

A predecessor's changed closing value can push a saved elected deduction
above the new statutory maximum; the view now falls back to the statutory
recomputation so the snapshot is flagged stale instead of crashing.
Ratchet naive-ore-round baseline down by 3.

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

* fix(bokslut): resolve tax-depreciation period selects statically

The no-phantom-columns guard counts every select it cannot resolve
toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo
4 over (364 > 360). Inline the literal column list at the four call
sites so the guard verifies these columns instead of skipping them.

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

* fix(bokslut): address review findings on tax depreciation election

- DepreciationPanel: gate the saving flag on a dedicated save sequence
  so a successful save (which refreshes the view and bumps the request
  version) no longer leaves the card permanently busy
- computeTaxDepreciation: refuse kompletteringsregel_20 with a positive
  basis and no acquisition cohorts instead of degenerating to a full
  write-off the cohort evidence does not support (IL 18 kap. 17 §)
- migration 227000: judge the asset-method guards on NEW.disposed_at so
  reversing a disposal cannot reactivate a grandfathered non-linear row
- migration 227200: require snapshot column completeness in the CHECK;
  SQL NULL semantics let partially populated snapshots pass the pure
  arithmetic comparisons
- depreciation route: use the string issue code 'custom' like the rest
  of the codebase instead of the Zod 3 compat enum

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:43:58 +02:00

214 lines
7.2 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),
}))
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
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' }),
)
})
})
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()
})
})