feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) (#1391)
* feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) Disposal books depreciation to the disposal date, clears cost and accumulated depreciation, books gain (3973) or loss (7973), applies output VAT on third-party sales, honors the ML 5 kap. 38 § verksamhetsoverlatelse exemption, and recalculates ML 15 kap. jamkning server-side from tax years and original input VAT. The voucher, the disposal-date depreciation schedule and the immutable register state commit in one dedicated commit_asset_disposal RPC transaction that delegates voucher numbering to commit_journal_entry. Fixes #325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): harden disposal per review and pg-real findings - commit_asset_disposal now uses the NULL-safe caller_is_company_member() guard (tenant-guard ratchet) and passes the allowed 'user_accept' commit_method instead of the unlisted 'asset_disposal' value - disposal metadata invariants validated in the RPC (non-negative proceeds/VAT, VAT requires a treatment, VAT <= gross, scrap carries no proceeds) since the RPC is independently callable - new FK and CHECK constraints added NOT VALID + VALIDATE CONSTRAINT so the migration never blocks writes on the hot journal_entries table - disposeAsset paginates fiscal periods and depreciation schedules with fetchAllRows; jamkning_remaining_years keeps a valid 0 (?? not ||) - engine imports shared AssetDisposalType/AssetJamkningDirection/ VatTreatment unions; post-commit reload retries once and logs before surfacing, so a transient read cannot masquerade as a failed disposal - dispose page parses Swedish-formatted amounts (125 000,50) and blocks submission on unparseable proceeds - assets pg tests write disposal attributes in the disposal transition itself and gain a regression test that the register is frozen after Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1c9d378df8
commit
cb3ef45f14
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
const requireAuthMock = vi.fn()
|
||||
const requireWriteMock = 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'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
|
||||
disposeAsset: vi.fn(),
|
||||
}))
|
||||
|
||||
import { disposeAsset } from '@/lib/bokslut/assets/asset-service'
|
||||
import { POST } from '../[id]/dispose/route'
|
||||
|
||||
const mockDisposeAsset = vi.mocked(disposeAsset)
|
||||
const routeParams = { params: Promise.resolve({ id: 'asset-1' }) }
|
||||
const validBody = {
|
||||
disposal_type: 'sale',
|
||||
disposed_at: '2026-06-30',
|
||||
disposed_proceeds: 125_000,
|
||||
proceeds_account: '1930',
|
||||
fiscal_period_id: '11111111-1111-4111-8111-111111111111',
|
||||
vat_treatment: 'standard_25',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('POST /api/assets/[id]/dispose', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/assets/asset-1/dispose', { method: 'POST', body: validBody }),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockDisposeAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 for inconsistent scrapping proceeds', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('/api/assets/asset-1/dispose', {
|
||||
method: 'POST',
|
||||
body: { ...validBody, disposal_type: 'scrap', disposed_proceeds: 100, vat_treatment: undefined },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockDisposeAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the asset does not exist', async () => {
|
||||
mockDisposeAsset.mockRejectedValue(Object.assign(new Error('Asset not found'), { code: 'ASSET_NOT_FOUND' }))
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await POST(
|
||||
createMockRequest('/api/assets/asset-1/dispose', { method: 'POST', body: validBody }),
|
||||
routeParams,
|
||||
),
|
||||
)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('ASSET_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns the atomically posted disposal', async () => {
|
||||
mockDisposeAsset.mockResolvedValue({
|
||||
asset: { id: 'asset-1', disposed_at: '2026-06-30' },
|
||||
disposal_entry: { id: 'entry-1', status: 'posted', voucher_number: 42 },
|
||||
gain_or_loss: 10_000,
|
||||
} as Awaited<ReturnType<typeof disposeAsset>>)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { gain_or_loss: number } }>(
|
||||
await POST(
|
||||
createMockRequest('/api/assets/asset-1/dispose', { method: 'POST', body: validBody }),
|
||||
routeParams,
|
||||
),
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.gain_or_loss).toBe(10_000)
|
||||
expect(mockDisposeAsset).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'asset-1',
|
||||
validBody,
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user