* 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>
229 lines
7.4 KiB
TypeScript
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')
|
|
})
|
|
})
|