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
+3 -247
View File
@@ -1,21 +1,13 @@
import { describe, it, expect, vi } from 'vitest'
import {
AssetCorrectionBlockedError,
DEFAULT_ACCOUNTS_BY_CATEGORY,
disposeAsset,
updateAsset,
} from '../assets/asset-service'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import type { Asset } from '@/types'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn().mockResolvedValue({
id: 'entry-1',
voucher_series: 'A',
voucher_number: 1,
}),
}))
describe('DEFAULT_ACCOUNTS_BY_CATEGORY', () => {
it('maps every AssetCategory to a BAS-aligned account triple', () => {
const expected = {
@@ -67,244 +59,6 @@ describe('DEFAULT_ACCOUNTS_BY_CATEGORY', () => {
})
})
describe('disposeAsset: gain/loss account selection', () => {
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'u',
company_id: 'co',
name: 'Test',
category: 'equipment',
acquisition_date: '2023-01-01',
acquisition_cost: 100_000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
restvarde_target: null,
disposed_at: null,
disposed_proceeds: null,
disposed_proceeds_vat: 0,
disposed_vat_treatment: null,
jamkning_amount: 0,
jamkning_remaining_months: null,
jamkning_total_months: null,
jamkning_original_input_vat: null,
k3_components: null,
notes: null,
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
...overrides,
}
}
function makeSupabaseForDispose(asset: Asset, schedules: Array<{ planned_depreciation: number }>) {
// Three from() calls happen inside disposeAsset:
// 1. getAsset (.maybeSingle on 'assets')
// 2. sumPostedDepreciation (.then on 'depreciation_schedules': server-derived
// accumulated_depreciation; replaces the previously client-supplied value)
// 3. update (.single on 'assets', returning the disposed row)
const builders = {
getBuilder: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: asset, error: null }),
},
schedulesBuilder: (() => {
const b: Record<string, unknown> = {
select: vi.fn(),
eq: vi.fn(),
not: vi.fn(),
then: undefined,
}
;(b.select as ReturnType<typeof vi.fn>).mockReturnValue(b)
;(b.eq as ReturnType<typeof vi.fn>).mockReturnValue(b)
;(b.not as ReturnType<typeof vi.fn>).mockReturnValue(b)
b.then = (resolve: (v: { data: unknown; error: unknown }) => void) =>
resolve({ data: schedules, error: null })
return b as { select: ReturnType<typeof vi.fn>; eq: ReturnType<typeof vi.fn>; not: ReturnType<typeof vi.fn> }
})(),
updateBuilder: {
update: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { ...asset, disposed_at: '2025-06-30', disposed_proceeds: 50_000 },
error: null,
}),
},
}
let calls = 0
const supabase = {
from: vi.fn((table: string) => {
calls++
if (table === 'depreciation_schedules') return builders.schedulesBuilder
return calls === 1 ? builders.getBuilder : builders.updateBuilder
}),
}
return { supabase, builders } as const
}
it('uses 3973 / 7973 for tangible asset disposal (equipment)', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({ category: 'equipment' })
// Two prior posted schedules summing to 40_000 → NBV = 60_000, proceeds 80_000 → gain 20_000
const { supabase } = makeSupabaseForDispose(asset, [
{ planned_depreciation: 20_000 },
{ planned_depreciation: 20_000 },
])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 80_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
expect(call).toBeDefined()
const lines = (call![3] as { lines: { account_number: string; debit_amount: number; credit_amount: number }[] }).lines
// Server-derived accumulated debits 1229
expect(lines.find((l) => l.account_number === '1229')?.debit_amount).toBe(40_000)
// Gain goes to 3973 (tangible), not 3013
expect(lines.find((l) => l.account_number === '3973')).toBeDefined()
expect(lines.find((l) => l.account_number === '3013')).toBeUndefined()
})
it('uses 3013 / 7813 for immaterial asset disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'immaterial',
bas_asset_account: '1010',
bas_accumulated_account: '1019',
bas_expense_account: '7810',
})
// NBV = 50_000, proceeds 10_000 → loss 40_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 50_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 10_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
expect(call).toBeDefined()
const lines = (call![3] as { lines: { account_number: string; debit_amount: number }[] }).lines
expect(lines.find((l) => l.account_number === '7813')).toBeDefined()
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
})
it('uses 3971 / 7971 for building disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'building',
acquisition_cost: 2_000_000,
bas_asset_account: '1110',
bas_accumulated_account: '1119',
bas_expense_account: '7821',
})
// NBV = 1_500_000, proceeds 2_000_000 → gain 500_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 500_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 2_000_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string }[] }).lines
// Buildings route to 3971/7971, not 3973/7973
expect(lines.find((l) => l.account_number === '3971')).toBeDefined()
expect(lines.find((l) => l.account_number === '3973')).toBeUndefined()
})
it('uses 3971 / 7971 for land_improvement disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'land_improvement',
acquisition_cost: 100_000,
bas_asset_account: '1150',
bas_accumulated_account: '1159',
bas_expense_account: '7824',
})
// NBV = 80_000, proceeds 40_000 → loss 40_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 20_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 40_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string }[] }).lines
// Markanläggning routes to 7971 like buildings
expect(lines.find((l) => l.account_number === '7971')).toBeDefined()
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
})
it('server-derives accumulated_depreciation: caller cannot inflate gain', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({ category: 'equipment', acquisition_cost: 100_000 })
// Real accumulated = 30_000 from one posted schedule. A malicious client
// could previously pass accumulated_depreciation: 100_000 to fake a fully
// depreciated asset and pocket a 50_000 phantom gain on proceeds. With
// server derivation, the lines reflect the actual 30_000.
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 30_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 50_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string; debit_amount: number; credit_amount: number }[] }).lines
// accumulated debit must be 30_000 (server-derived), not anything else
expect(lines.find((l) => l.account_number === '1229')?.debit_amount).toBe(30_000)
// NBV = 100_000 − 30_000 = 70_000, proceeds 50_000 → loss 20_000 to 7973
expect(lines.find((l) => l.account_number === '7973')?.debit_amount).toBe(20_000)
})
})
describe('updateAsset: acquisition-basis correction guard', () => {
function makeAssetRow(overrides: Partial<Asset> = {}): Asset {
return {
@@ -507,3 +261,5 @@ describe('updateAsset: acquisition-basis correction guard', () => {
expect(captured.update).toMatchObject({ acquisition_date: '2025-08-15' })
})
})