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
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user