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:
Mattsson
2026-08-04 09:59:52 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1c9d378df8
commit cb3ef45f14
22 changed files with 19644 additions and 1805 deletions
+35 -62
View File
@@ -7,8 +7,6 @@ import { disposeAsset } from '@/lib/bokslut/assets/asset-service'
const VAT_TREATMENTS = [
'standard_25',
'reduced_12',
'reduced_6',
'reverse_charge',
'export',
'exempt',
@@ -16,77 +14,52 @@ const VAT_TREATMENTS = [
const DisposeAssetSchema = z
.object({
disposal_type: z.enum(['sale', 'scrap', 'business_transfer']),
disposed_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
/** Gross proceeds (INCL VAT when applicable). */
disposed_proceeds: z.number().nonnegative(),
proceeds_account: z.string().regex(/^\d{4}$/).optional(),
fiscal_period_id: z.string().uuid(),
/** Output VAT on the proceeds. Defaults to 0 (sale was momsfri). */
proceeds_vat: z.number().nonnegative().optional(),
/** Required when proceeds_vat > 0 so the engine can resolve a 26xx account. */
vat_treatment: z.enum(VAT_TREATMENTS).optional(),
/** Precomputed jämkning amount (ML 8a kap 7 §). Caller supplies; engine
* books a 2641 credit + loss-account debit. */
jamkning_amount: z.number().nonnegative().optional(),
/** Audit metadata. */
jamkning_remaining_months: z.number().int().nonnegative().optional(),
jamkning_total_months: z.number().int().positive().optional(),
jamkning_original_input_vat: z.number().nonnegative().optional(),
// accumulated_depreciation is intentionally NOT accepted from the client:
// disposeAsset sums depreciation_schedules server-side so callers cannot
// inflate the book-value calculation.
jamkning_original_deduction_percent: z.number().min(0).max(100).optional(),
business_transfer_confirmed: z.boolean().optional(),
adjustment_document_confirmed: z.boolean().optional(),
})
.superRefine((value, ctx) => {
// VAT consistency: if a treatment that produces a VAT line is selected,
// the VAT amount must equal 25%/12%/6% of the net proceeds. Tolerance is
// ±0.50 kr to handle rounding on item prices.
if (value.proceeds_vat && value.proceeds_vat > 0) {
if (!value.vat_treatment) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['vat_treatment'],
message: 'vat_treatment krävs när proceeds_vat > 0.',
})
return
}
const rate = vatRateFromTreatment(value.vat_treatment)
if (rate === null) {
// Treatments without a VAT line must carry 0 VAT.
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['proceeds_vat'],
message: `proceeds_vat måste vara 0 för momsbehandling "${value.vat_treatment}".`,
})
return
}
// Expected: proceeds_gross = net × (1 + rate), so net = gross / (1 + rate)
// and vat = gross - net = gross × rate / (1 + rate).
const expectedVat = (value.disposed_proceeds * rate) / (1 + rate)
if (Math.abs(expectedVat - value.proceeds_vat) > 0.5) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['proceeds_vat'],
message: `proceeds_vat ska vara ~${Math.round(expectedVat * 100) / 100} kr för momsbehandling "${value.vat_treatment}" på ${value.disposed_proceeds} kr brutto.`,
})
}
if (value.disposal_type === 'scrap' && value.disposed_proceeds !== 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['disposed_proceeds'],
message: 'disposed_proceeds måste vara 0 vid utrangering.',
})
}
if (value.disposal_type === 'sale' && value.disposed_proceeds > 0 && !value.vat_treatment) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['vat_treatment'],
message: 'vat_treatment krävs vid försäljning.',
})
}
if (value.disposal_type !== 'sale' && value.vat_treatment !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['vat_treatment'],
message: 'vat_treatment får bara anges vid försäljning.',
})
}
const hasVat = value.jamkning_original_input_vat !== undefined
const hasPercent = value.jamkning_original_deduction_percent !== undefined
if (hasVat !== hasPercent) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: hasVat
? ['jamkning_original_deduction_percent']
: ['jamkning_original_input_vat'],
message: 'Ursprungsmoms och ursprunglig avdragsprocent måste anges tillsammans.',
})
}
})
function vatRateFromTreatment(t: (typeof VAT_TREATMENTS)[number]): number | null {
switch (t) {
case 'standard_25':
return 0.25
case 'reduced_12':
return 0.12
case 'reduced_6':
return 0.06
case 'reverse_charge':
case 'export':
case 'exempt':
return null
}
}
export const POST = withRouteContext(
'assets.dispose',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+112
View File
@@ -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,
)
})
})