Files
accounted/lib/bokslut/assets/__tests__/asset-disposal-plan.test.ts
T
MattssonandClaude Fable 5 cb3ef45f14 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>
2026-08-04 09:59:52 +02:00

229 lines
7.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { buildAssetDisposalPlan, type DisposeAssetInput } from '../asset-service'
import type { Asset, AssetCategory } from '@/types'
const PERIOD = { id: 'period-2026', period_start: '2026-01-01', period_end: '2026-12-31' }
const PERIODS = [
{ id: 'period-2025', period_start: '2025-01-01' },
{ id: 'period-2026', period_start: '2026-01-01' },
{ id: 'period-2027', period_start: '2027-01-01' },
]
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'company-1',
name: 'Machine',
category: 'equipment',
acquisition_date: '2025-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: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
...overrides,
}
}
function makeInput(overrides: Partial<DisposeAssetInput> = {}): DisposeAssetInput {
return {
disposal_type: 'sale',
disposed_at: '2026-06-30',
disposed_proceeds: 100_000,
proceeds_account: '1930',
fiscal_period_id: PERIOD.id,
vat_treatment: 'standard_25',
...overrides,
}
}
function build(overrides: {
asset?: Partial<Asset>
input?: Partial<DisposeAssetInput>
schedules?: Array<{
fiscal_period_id: string
planned_depreciation: number
journal_entry_id: string | null
}>
} = {}) {
return buildAssetDisposalPlan({
asset: makeAsset(overrides.asset),
input: makeInput(overrides.input),
fiscalPeriod: PERIOD,
periods: PERIODS,
schedules: overrides.schedules ?? [
{
fiscal_period_id: 'period-2025',
planned_depreciation: 20_000,
journal_entry_id: 'entry-2025',
},
],
})
}
describe('buildAssetDisposalPlan', () => {
it('books depreciation through the disposal date before removing the asset', () => {
const plan = build()
expect(plan.currentDepreciation).toBe(9_918)
expect(plan.accumulatedDepreciation).toBe(29_918)
expect(plan.lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ account_number: '7832', debit_amount: 9_918 }),
expect.objectContaining({ account_number: '1229', credit_amount: 9_918 }),
expect.objectContaining({ account_number: '1229', debit_amount: 29_918 }),
expect.objectContaining({ account_number: '1220', credit_amount: 100_000 }),
]),
)
})
it('derives 25 percent output VAT from gross proceeds on the server', () => {
const plan = build({ input: { disposed_proceeds: 125_000 } })
expect(plan.proceedsVat).toBe(25_000)
expect(plan.lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ account_number: '1930', debit_amount: 125_000 }),
expect.objectContaining({ account_number: '2611', credit_amount: 25_000 }),
]),
)
})
it.each<[AssetCategory, string, string]>([
['immaterial', '3971', '7971'],
['building', '3972', '7972'],
['land_improvement', '3972', '7972'],
['equipment', '3973', '7973'],
])('uses the BAS disposal pair for %s', (category, gainAccount, lossAccount) => {
const accountOverrides =
category === 'immaterial'
? { bas_asset_account: '1010', bas_accumulated_account: '1019', bas_expense_account: '7810' }
: category === 'building' || category === 'land_improvement'
? { bas_asset_account: '1110', bas_accumulated_account: '1119', bas_expense_account: '7821' }
: {}
const gain = build({
asset: { category, acquisition_cost: 20_000, ...accountOverrides },
input: { disposed_proceeds: 125_000 },
schedules: [],
})
const loss = build({
asset: { category, acquisition_cost: 100_000, ...accountOverrides },
input: { disposal_type: 'scrap', disposed_proceeds: 0, vat_treatment: undefined },
schedules: [],
})
expect(gain.lines.some((line) => line.account_number === gainAccount)).toBe(true)
expect(loss.lines.some((line) => line.account_number === lossAccount)).toBe(true)
})
it('fully clears a fully depreciated asset on scrapping', () => {
const plan = build({
asset: { acquisition_date: '2021-01-01' },
input: { disposal_type: 'scrap', disposed_proceeds: 0, vat_treatment: undefined },
schedules: [
{ fiscal_period_id: 'period-2025', planned_depreciation: 100_000, journal_entry_id: 'entry' },
],
})
expect(plan.gainOrLoss).toBe(0)
expect(plan.lines).toEqual([
expect.objectContaining({ account_number: '1229', debit_amount: 100_000 }),
expect.objectContaining({ account_number: '1220', credit_amount: 100_000 }),
])
})
it('refuses disposal when a later period already has posted depreciation', () => {
expect(() =>
build({
schedules: [
{ fiscal_period_id: 'period-2027', planned_depreciation: 20_000, journal_entry_id: 'entry' },
],
}),
).toThrow('later_depreciation_posted')
})
it('refuses to overwrite a mismatched posted current-period schedule', () => {
expect(() =>
build({
schedules: [
{ fiscal_period_id: 'period-2026', planned_depreciation: 20_000, journal_entry_id: 'entry' },
],
}),
).toThrow('current_depreciation_mismatch')
})
it('books negative VAT adjustment to 6999 and 2641', () => {
const plan = build({
asset: { acquisition_cost: 300_000 },
input: {
vat_treatment: 'exempt',
jamkning_original_input_vat: 75_000,
jamkning_original_deduction_percent: 100,
},
})
expect(plan.jamkning).toMatchObject({ direction: 'decrease', amount: 60_000 })
expect(plan.lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ account_number: '6999', debit_amount: 60_000 }),
expect.objectContaining({ account_number: '2641', credit_amount: 60_000 }),
]),
)
})
it('requires explicit ML 5:38 confirmation for a business transfer', () => {
expect(() =>
build({
input: {
disposal_type: 'business_transfer',
vat_treatment: undefined,
},
}),
).toThrow('ASSET_BUSINESS_TRANSFER_CONFIRMATION_REQUIRED')
})
it('requires an adjustment document when an investment-good obligation transfers', () => {
expect(() =>
build({
asset: { acquisition_cost: 300_000 },
input: {
disposal_type: 'business_transfer',
vat_treatment: undefined,
business_transfer_confirmed: true,
jamkning_original_input_vat: 50_000,
jamkning_original_deduction_percent: 100,
},
}),
).toThrow('ASSET_ADJUSTMENT_DOCUMENT_REQUIRED')
const plan = build({
asset: { acquisition_cost: 300_000 },
input: {
disposal_type: 'business_transfer',
vat_treatment: undefined,
business_transfer_confirmed: true,
adjustment_document_confirmed: true,
jamkning_original_input_vat: 50_000,
jamkning_original_deduction_percent: 100,
},
})
expect(plan.jamkning.direction).toBe('transferred')
})
})